Sync your WordPress posts to standard.site records on your PDS
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

initial commit

Tyler Fisher 955281fc

+5418
+7
.gitignore
··· 1 + /vendor/ 2 + .DS_Store 3 + 4 + CLAUDE.md 5 + AGENTS.md 6 + .claude 7 + .cursorrules
+76
assets/js/meta-box.js
··· 1 + (function ($) { 2 + "use strict"; 3 + 4 + function toggleCustomField(selectId, fieldId) { 5 + var $select = $("#" + selectId); 6 + var $field = $("#" + fieldId); 7 + 8 + function update() { 9 + if ($select.val() === "custom") { 10 + $field.show(); 11 + } else { 12 + $field.hide(); 13 + } 14 + } 15 + 16 + $select.on("change", update); 17 + update(); 18 + } 19 + 20 + $(document).ready(function () { 21 + toggleCustomField( 22 + "wireservice_title_source", 23 + "wireservice-custom-title-field" 24 + ); 25 + toggleCustomField( 26 + "wireservice_description_source", 27 + "wireservice-custom-description-field" 28 + ); 29 + toggleCustomField( 30 + "wireservice_image_source", 31 + "wireservice-custom-image-field" 32 + ); 33 + 34 + var frame; 35 + 36 + $("#wireservice-select-image").on("click", function (e) { 37 + e.preventDefault(); 38 + 39 + if (frame) { 40 + frame.open(); 41 + return; 42 + } 43 + 44 + frame = wp.media({ 45 + title: wireserviceMetaBox.selectImageTitle, 46 + button: { text: wireserviceMetaBox.useImageButton }, 47 + multiple: false, 48 + library: { type: "image" }, 49 + }); 50 + 51 + frame.on("select", function () { 52 + var attachment = frame.state().get("selection").first().toJSON(); 53 + $("#wireservice_custom_image_id").val(attachment.id); 54 + var thumbUrl = 55 + attachment.sizes && attachment.sizes.thumbnail 56 + ? attachment.sizes.thumbnail.url 57 + : attachment.url; 58 + $("#wireservice-custom-image-preview").html( 59 + '<img src="' + 60 + thumbUrl + 61 + '" style="max-width:150px;height:auto;display:block;margin-bottom:8px;" />' 62 + ); 63 + $("#wireservice-remove-image").show(); 64 + }); 65 + 66 + frame.open(); 67 + }); 68 + 69 + $("#wireservice-remove-image").on("click", function (e) { 70 + e.preventDefault(); 71 + $("#wireservice_custom_image_id").val(""); 72 + $("#wireservice-custom-image-preview").html(""); 73 + $(this).hide(); 74 + }); 75 + }); 76 + })(jQuery);
+120
assets/js/settings.js
··· 1 + (function ($) { 2 + "use strict"; 3 + 4 + function toggleCustomField(selectId, fieldId, currentValueId) { 5 + var $select = $("#" + selectId); 6 + var $field = $("#" + fieldId); 7 + var $currentValue = $("#" + currentValueId); 8 + var $previewText = $currentValue.find(".wireservice-preview-text"); 9 + 10 + function update() { 11 + var selected = $select.find("option:selected"); 12 + var preview = selected.attr("data-value") || ""; 13 + 14 + if ($select.val() === "custom") { 15 + $field.show(); 16 + $currentValue.hide(); 17 + } else { 18 + $field.hide(); 19 + $currentValue.show(); 20 + if ($previewText.length) { 21 + var truncated = 22 + preview.length > 100 23 + ? preview.substring(0, 100) + "..." 24 + : preview; 25 + $previewText.text(truncated || "—"); 26 + } 27 + } 28 + } 29 + 30 + $select.on("change", update); 31 + update(); 32 + } 33 + 34 + function initIconField() { 35 + var $select = $("#wireservice_pub_icon_source"); 36 + var $customField = $("#wireservice-pub-custom-icon-field"); 37 + var $preview = $("#wireservice-pub-icon-preview"); 38 + var $uploadBtn = $("#wireservice-pub-icon-upload"); 39 + var $removeBtn = $("#wireservice-pub-icon-remove"); 40 + var $hiddenInput = $("#wireservice_pub_custom_icon_id"); 41 + var $customPreview = $("#wireservice-pub-custom-icon-preview"); 42 + var frame; 43 + 44 + function updateVisibility() { 45 + var val = $select.val(); 46 + if (val === "custom") { 47 + $customField.show(); 48 + $preview.hide(); 49 + } else if (val === "none") { 50 + $customField.hide(); 51 + $preview.hide(); 52 + } else { 53 + $customField.hide(); 54 + $preview.show(); 55 + } 56 + } 57 + 58 + $select.on("change", updateVisibility); 59 + updateVisibility(); 60 + 61 + $uploadBtn.on("click", function (e) { 62 + e.preventDefault(); 63 + 64 + if (frame) { 65 + frame.open(); 66 + return; 67 + } 68 + 69 + frame = wp.media({ 70 + title: "Select Icon Image", 71 + button: { text: "Use as Icon" }, 72 + multiple: false, 73 + library: { type: "image" }, 74 + }); 75 + 76 + frame.on("select", function () { 77 + var attachment = frame.state().get("selection").first().toJSON(); 78 + $hiddenInput.val(attachment.id); 79 + var thumbUrl = 80 + attachment.sizes && attachment.sizes.thumbnail 81 + ? attachment.sizes.thumbnail.url 82 + : attachment.url; 83 + $customPreview.html( 84 + '<img src="' + 85 + thumbUrl + 86 + '" alt="" style="width: 64px; height: 64px; object-fit: cover; border-radius: 4px;">' 87 + ); 88 + $removeBtn.show(); 89 + }); 90 + 91 + frame.open(); 92 + }); 93 + 94 + $removeBtn.on("click", function (e) { 95 + e.preventDefault(); 96 + $hiddenInput.val(""); 97 + $customPreview.html(""); 98 + $removeBtn.hide(); 99 + }); 100 + } 101 + 102 + $(document).ready(function () { 103 + toggleCustomField( 104 + "wireservice_pub_name_source", 105 + "wireservice-pub-custom-name-field", 106 + "wireservice-pub-name-current-value" 107 + ); 108 + toggleCustomField( 109 + "wireservice_pub_description_source", 110 + "wireservice-pub-custom-desc-field", 111 + "wireservice-pub-desc-current-value" 112 + ); 113 + 114 + // Initialize icon source toggle and media picker. 115 + initIconField(); 116 + 117 + // Initialize color pickers. 118 + $(".wireservice-color-picker").wpColorPicker(); 119 + }); 120 + })(jQuery);
+17
composer.json
··· 1 + { 2 + "name": "wireservice/wireservice", 3 + "description": "A WordPress plugin for publishing posts to the AT Protocol based on the standard.site lexicon.", 4 + "type": "wordpress-plugin", 5 + "license": "AGPL-3.0-or-later", 6 + "autoload": { 7 + "psr-4": { 8 + "Wireservice\\": "includes/" 9 + } 10 + }, 11 + "require": { 12 + "php": "^8.4", 13 + "danielburger1337/oauth2-dpop": "^1.2", 14 + "web-token/jwt-library": "^4.1", 15 + "symfony/clock": "^8.0" 16 + } 17 + }
+534
composer.lock
··· 1 + { 2 + "_readme": [ 3 + "This file locks the dependencies of your project to a known state", 4 + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", 5 + "This file is @generated automatically" 6 + ], 7 + "content-hash": "4fa9ecb03ab563b18aca2ad0ef114f65", 8 + "packages": [ 9 + { 10 + "name": "brick/math", 11 + "version": "0.14.8", 12 + "source": { 13 + "type": "git", 14 + "url": "https://github.com/brick/math.git", 15 + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" 16 + }, 17 + "dist": { 18 + "type": "zip", 19 + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", 20 + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", 21 + "shasum": "" 22 + }, 23 + "require": { 24 + "php": "^8.2" 25 + }, 26 + "require-dev": { 27 + "php-coveralls/php-coveralls": "^2.2", 28 + "phpstan/phpstan": "2.1.22", 29 + "phpunit/phpunit": "^11.5" 30 + }, 31 + "type": "library", 32 + "autoload": { 33 + "psr-4": { 34 + "Brick\\Math\\": "src/" 35 + } 36 + }, 37 + "notification-url": "https://packagist.org/downloads/", 38 + "license": [ 39 + "MIT" 40 + ], 41 + "description": "Arbitrary-precision arithmetic library", 42 + "keywords": [ 43 + "Arbitrary-precision", 44 + "BigInteger", 45 + "BigRational", 46 + "arithmetic", 47 + "bigdecimal", 48 + "bignum", 49 + "bignumber", 50 + "brick", 51 + "decimal", 52 + "integer", 53 + "math", 54 + "mathematics", 55 + "rational" 56 + ], 57 + "support": { 58 + "issues": "https://github.com/brick/math/issues", 59 + "source": "https://github.com/brick/math/tree/0.14.8" 60 + }, 61 + "funding": [ 62 + { 63 + "url": "https://github.com/BenMorel", 64 + "type": "github" 65 + } 66 + ], 67 + "time": "2026-02-10T14:33:43+00:00" 68 + }, 69 + { 70 + "name": "danielburger1337/oauth2-dpop", 71 + "version": "v1.2.0", 72 + "source": { 73 + "type": "git", 74 + "url": "https://github.com/danielburger1337/oauth2-dpop-php.git", 75 + "reference": "551c510c7c43cd921a63a0a20faf103b1774ac52" 76 + }, 77 + "dist": { 78 + "type": "zip", 79 + "url": "https://api.github.com/repos/danielburger1337/oauth2-dpop-php/zipball/551c510c7c43cd921a63a0a20faf103b1774ac52", 80 + "reference": "551c510c7c43cd921a63a0a20faf103b1774ac52", 81 + "shasum": "" 82 + }, 83 + "require": { 84 + "php": "^8.4", 85 + "psr/clock": "^1.0", 86 + "spomky-labs/base64url": "^2.0" 87 + }, 88 + "require-dev": { 89 + "friendsofphp/php-cs-fixer": "^3.91", 90 + "nyholm/psr7": "^1.8", 91 + "paragonie/constant_time_encoding": "^3.1", 92 + "phpstan/phpstan": "^2.1", 93 + "phpunit/phpunit": "^12.5", 94 + "psr/cache": "^3.0", 95 + "psr/http-message": "^2.0", 96 + "spomky-labs/otphp": "^11.3", 97 + "symfony/clock": "^7.2 || ^8.0", 98 + "symfony/http-foundation": "^7.2 || ^8.0", 99 + "web-token/jwt-library": "^3.4.6 || ^4.0" 100 + }, 101 + "type": "library", 102 + "extra": { 103 + "branch-alias": { 104 + "dev-main": "1.x-dev" 105 + } 106 + }, 107 + "autoload": { 108 + "psr-4": { 109 + "danielburger1337\\OAuth2\\DPoP\\": "src/" 110 + } 111 + }, 112 + "notification-url": "https://packagist.org/downloads/", 113 + "license": [ 114 + "MIT" 115 + ], 116 + "authors": [ 117 + { 118 + "name": "Daniel Burger", 119 + "homepage": "https://github.com/danielburger1337" 120 + } 121 + ], 122 + "description": "Create/Verify OAuth2 DPoP tokens.", 123 + "homepage": "https://github.com/danielburger1337/oauth2-dpop-php", 124 + "keywords": [ 125 + "dpop", 126 + "oauth2" 127 + ], 128 + "support": { 129 + "issues": "https://github.com/danielburger1337/oauth2-dpop-php/issues", 130 + "source": "https://github.com/danielburger1337/oauth2-dpop-php/tree/v1.2.0" 131 + }, 132 + "time": "2026-01-06T18:56:07+00:00" 133 + }, 134 + { 135 + "name": "psr/clock", 136 + "version": "1.0.0", 137 + "source": { 138 + "type": "git", 139 + "url": "https://github.com/php-fig/clock.git", 140 + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" 141 + }, 142 + "dist": { 143 + "type": "zip", 144 + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", 145 + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", 146 + "shasum": "" 147 + }, 148 + "require": { 149 + "php": "^7.0 || ^8.0" 150 + }, 151 + "type": "library", 152 + "autoload": { 153 + "psr-4": { 154 + "Psr\\Clock\\": "src/" 155 + } 156 + }, 157 + "notification-url": "https://packagist.org/downloads/", 158 + "license": [ 159 + "MIT" 160 + ], 161 + "authors": [ 162 + { 163 + "name": "PHP-FIG", 164 + "homepage": "https://www.php-fig.org/" 165 + } 166 + ], 167 + "description": "Common interface for reading the clock.", 168 + "homepage": "https://github.com/php-fig/clock", 169 + "keywords": [ 170 + "clock", 171 + "now", 172 + "psr", 173 + "psr-20", 174 + "time" 175 + ], 176 + "support": { 177 + "issues": "https://github.com/php-fig/clock/issues", 178 + "source": "https://github.com/php-fig/clock/tree/1.0.0" 179 + }, 180 + "time": "2022-11-25T14:36:26+00:00" 181 + }, 182 + { 183 + "name": "spomky-labs/base64url", 184 + "version": "v2.0.4", 185 + "source": { 186 + "type": "git", 187 + "url": "https://github.com/Spomky-Labs/base64url.git", 188 + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d" 189 + }, 190 + "dist": { 191 + "type": "zip", 192 + "url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d", 193 + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d", 194 + "shasum": "" 195 + }, 196 + "require": { 197 + "php": ">=7.1" 198 + }, 199 + "require-dev": { 200 + "phpstan/extension-installer": "^1.0", 201 + "phpstan/phpstan": "^0.11|^0.12", 202 + "phpstan/phpstan-beberlei-assert": "^0.11|^0.12", 203 + "phpstan/phpstan-deprecation-rules": "^0.11|^0.12", 204 + "phpstan/phpstan-phpunit": "^0.11|^0.12", 205 + "phpstan/phpstan-strict-rules": "^0.11|^0.12" 206 + }, 207 + "type": "library", 208 + "autoload": { 209 + "psr-4": { 210 + "Base64Url\\": "src/" 211 + } 212 + }, 213 + "notification-url": "https://packagist.org/downloads/", 214 + "license": [ 215 + "MIT" 216 + ], 217 + "authors": [ 218 + { 219 + "name": "Florent Morselli", 220 + "homepage": "https://github.com/Spomky-Labs/base64url/contributors" 221 + } 222 + ], 223 + "description": "Base 64 URL Safe Encoding/Decoding PHP Library", 224 + "homepage": "https://github.com/Spomky-Labs/base64url", 225 + "keywords": [ 226 + "base64", 227 + "rfc4648", 228 + "safe", 229 + "url" 230 + ], 231 + "support": { 232 + "issues": "https://github.com/Spomky-Labs/base64url/issues", 233 + "source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4" 234 + }, 235 + "funding": [ 236 + { 237 + "url": "https://github.com/Spomky", 238 + "type": "github" 239 + }, 240 + { 241 + "url": "https://www.patreon.com/FlorentMorselli", 242 + "type": "patreon" 243 + } 244 + ], 245 + "time": "2020-11-03T09:10:25+00:00" 246 + }, 247 + { 248 + "name": "spomky-labs/pki-framework", 249 + "version": "1.4.1", 250 + "source": { 251 + "type": "git", 252 + "url": "https://github.com/Spomky-Labs/pki-framework.git", 253 + "reference": "f0e9a548df4e3942886adc9b7830581a46334631" 254 + }, 255 + "dist": { 256 + "type": "zip", 257 + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/f0e9a548df4e3942886adc9b7830581a46334631", 258 + "reference": "f0e9a548df4e3942886adc9b7830581a46334631", 259 + "shasum": "" 260 + }, 261 + "require": { 262 + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14", 263 + "ext-mbstring": "*", 264 + "php": ">=8.1" 265 + }, 266 + "require-dev": { 267 + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", 268 + "ext-gmp": "*", 269 + "ext-openssl": "*", 270 + "infection/infection": "^0.28|^0.29|^0.31", 271 + "php-parallel-lint/php-parallel-lint": "^1.3", 272 + "phpstan/extension-installer": "^1.3|^2.0", 273 + "phpstan/phpstan": "^1.8|^2.0", 274 + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", 275 + "phpstan/phpstan-phpunit": "^1.1|^2.0", 276 + "phpstan/phpstan-strict-rules": "^1.3|^2.0", 277 + "phpunit/phpunit": "^10.1|^11.0|^12.0", 278 + "rector/rector": "^1.0|^2.0", 279 + "roave/security-advisories": "dev-latest", 280 + "symfony/string": "^6.4|^7.0|^8.0", 281 + "symfony/var-dumper": "^6.4|^7.0|^8.0", 282 + "symplify/easy-coding-standard": "^12.0" 283 + }, 284 + "suggest": { 285 + "ext-bcmath": "For better performance (or GMP)", 286 + "ext-gmp": "For better performance (or BCMath)", 287 + "ext-openssl": "For OpenSSL based cyphering" 288 + }, 289 + "type": "library", 290 + "autoload": { 291 + "psr-4": { 292 + "SpomkyLabs\\Pki\\": "src/" 293 + } 294 + }, 295 + "notification-url": "https://packagist.org/downloads/", 296 + "license": [ 297 + "MIT" 298 + ], 299 + "authors": [ 300 + { 301 + "name": "Joni Eskelinen", 302 + "email": "jonieske@gmail.com", 303 + "role": "Original developer" 304 + }, 305 + { 306 + "name": "Florent Morselli", 307 + "email": "florent.morselli@spomky-labs.com", 308 + "role": "Spomky-Labs PKI Framework developer" 309 + } 310 + ], 311 + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", 312 + "homepage": "https://github.com/spomky-labs/pki-framework", 313 + "keywords": [ 314 + "DER", 315 + "Private Key", 316 + "ac", 317 + "algorithm identifier", 318 + "asn.1", 319 + "asn1", 320 + "attribute certificate", 321 + "certificate", 322 + "certification request", 323 + "cryptography", 324 + "csr", 325 + "decrypt", 326 + "ec", 327 + "encrypt", 328 + "pem", 329 + "pkcs", 330 + "public key", 331 + "rsa", 332 + "sign", 333 + "signature", 334 + "verify", 335 + "x.509", 336 + "x.690", 337 + "x509", 338 + "x690" 339 + ], 340 + "support": { 341 + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", 342 + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.1" 343 + }, 344 + "funding": [ 345 + { 346 + "url": "https://github.com/Spomky", 347 + "type": "github" 348 + }, 349 + { 350 + "url": "https://www.patreon.com/FlorentMorselli", 351 + "type": "patreon" 352 + } 353 + ], 354 + "time": "2025-12-20T12:57:40+00:00" 355 + }, 356 + { 357 + "name": "symfony/clock", 358 + "version": "v8.0.0", 359 + "source": { 360 + "type": "git", 361 + "url": "https://github.com/symfony/clock.git", 362 + "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f" 363 + }, 364 + "dist": { 365 + "type": "zip", 366 + "url": "https://api.github.com/repos/symfony/clock/zipball/832119f9b8dbc6c8e6f65f30c5969eca1e88764f", 367 + "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f", 368 + "shasum": "" 369 + }, 370 + "require": { 371 + "php": ">=8.4", 372 + "psr/clock": "^1.0" 373 + }, 374 + "provide": { 375 + "psr/clock-implementation": "1.0" 376 + }, 377 + "type": "library", 378 + "autoload": { 379 + "files": [ 380 + "Resources/now.php" 381 + ], 382 + "psr-4": { 383 + "Symfony\\Component\\Clock\\": "" 384 + }, 385 + "exclude-from-classmap": [ 386 + "/Tests/" 387 + ] 388 + }, 389 + "notification-url": "https://packagist.org/downloads/", 390 + "license": [ 391 + "MIT" 392 + ], 393 + "authors": [ 394 + { 395 + "name": "Nicolas Grekas", 396 + "email": "p@tchwork.com" 397 + }, 398 + { 399 + "name": "Symfony Community", 400 + "homepage": "https://symfony.com/contributors" 401 + } 402 + ], 403 + "description": "Decouples applications from the system clock", 404 + "homepage": "https://symfony.com", 405 + "keywords": [ 406 + "clock", 407 + "psr20", 408 + "time" 409 + ], 410 + "support": { 411 + "source": "https://github.com/symfony/clock/tree/v8.0.0" 412 + }, 413 + "funding": [ 414 + { 415 + "url": "https://symfony.com/sponsor", 416 + "type": "custom" 417 + }, 418 + { 419 + "url": "https://github.com/fabpot", 420 + "type": "github" 421 + }, 422 + { 423 + "url": "https://github.com/nicolas-grekas", 424 + "type": "github" 425 + }, 426 + { 427 + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 428 + "type": "tidelift" 429 + } 430 + ], 431 + "time": "2025-11-12T15:46:48+00:00" 432 + }, 433 + { 434 + "name": "web-token/jwt-library", 435 + "version": "4.1.3", 436 + "source": { 437 + "type": "git", 438 + "url": "https://github.com/web-token/jwt-library.git", 439 + "reference": "690d4dd47b78f423cb90457f858e4106e1deb728" 440 + }, 441 + "dist": { 442 + "type": "zip", 443 + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/690d4dd47b78f423cb90457f858e4106e1deb728", 444 + "reference": "690d4dd47b78f423cb90457f858e4106e1deb728", 445 + "shasum": "" 446 + }, 447 + "require": { 448 + "brick/math": "^0.12|^0.13|^0.14", 449 + "php": ">=8.2", 450 + "psr/clock": "^1.0", 451 + "spomky-labs/pki-framework": "^1.2.1" 452 + }, 453 + "conflict": { 454 + "spomky-labs/jose": "*" 455 + }, 456 + "suggest": { 457 + "ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance", 458 + "ext-gmp": "GMP or BCMath is highly recommended to improve the library performance", 459 + "ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)", 460 + "ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", 461 + "paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", 462 + "spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)", 463 + "symfony/console": "Needed to use console commands", 464 + "symfony/http-client": "To enable JKU/X5U support." 465 + }, 466 + "type": "library", 467 + "autoload": { 468 + "psr-4": { 469 + "Jose\\Component\\": "" 470 + } 471 + }, 472 + "notification-url": "https://packagist.org/downloads/", 473 + "license": [ 474 + "MIT" 475 + ], 476 + "authors": [ 477 + { 478 + "name": "Florent Morselli", 479 + "homepage": "https://github.com/Spomky" 480 + }, 481 + { 482 + "name": "All contributors", 483 + "homepage": "https://github.com/web-token/jwt-framework/contributors" 484 + } 485 + ], 486 + "description": "JWT library", 487 + "homepage": "https://github.com/web-token", 488 + "keywords": [ 489 + "JOSE", 490 + "JWE", 491 + "JWK", 492 + "JWKSet", 493 + "JWS", 494 + "Jot", 495 + "RFC7515", 496 + "RFC7516", 497 + "RFC7517", 498 + "RFC7518", 499 + "RFC7519", 500 + "RFC7520", 501 + "bundle", 502 + "jwa", 503 + "jwt", 504 + "symfony" 505 + ], 506 + "support": { 507 + "issues": "https://github.com/web-token/jwt-library/issues", 508 + "source": "https://github.com/web-token/jwt-library/tree/4.1.3" 509 + }, 510 + "funding": [ 511 + { 512 + "url": "https://github.com/Spomky", 513 + "type": "github" 514 + }, 515 + { 516 + "url": "https://www.patreon.com/FlorentMorselli", 517 + "type": "patreon" 518 + } 519 + ], 520 + "time": "2025-12-18T14:27:35+00:00" 521 + } 522 + ], 523 + "packages-dev": [], 524 + "aliases": [], 525 + "minimum-stability": "stable", 526 + "stability-flags": [], 527 + "prefer-stable": false, 528 + "prefer-lowest": false, 529 + "platform": { 530 + "php": ">=7.4" 531 + }, 532 + "platform-dev": [], 533 + "plugin-api-version": "2.3.0" 534 + }
+483
includes/API.php
··· 1 + <?php 2 + declare(strict_types=1); 3 + /** 4 + * API client for communicating with the OAuth service and PDS. 5 + * 6 + * @package Wireservice 7 + */ 8 + 9 + namespace Wireservice; 10 + 11 + class API 12 + { 13 + /** 14 + * OAuth service base URL. 15 + * 16 + * @var string 17 + */ 18 + private string $oauth_url; 19 + 20 + /** 21 + * Cached session data. 22 + * 23 + * @var array|null 24 + */ 25 + private ?array $session = null; 26 + 27 + /** 28 + * Cached PDS credentials. 29 + * 30 + * @var array|null 31 + */ 32 + private ?array $pds_credentials = null; 33 + 34 + /** 35 + * Constructor. 36 + */ 37 + public function __construct(private ConnectionsManager $connections_manager) 38 + { 39 + $this->oauth_url = rtrim(get_option("wireservice_oauth_url", "https://aip.wireservice.net"), "/"); 40 + } 41 + 42 + /** 43 + * Make an authenticated request to the OAuth service. 44 + * 45 + * @param string $method HTTP method. 46 + * @param string $endpoint API endpoint. 47 + * @param array $args Request arguments. 48 + * @return array|\WP_Error 49 + */ 50 + public function request(string $method, string $endpoint, array $args = []) 51 + { 52 + $access_token = $this->connections_manager->get_access_token(); 53 + 54 + if (is_wp_error($access_token)) { 55 + return $access_token; 56 + } 57 + 58 + if (empty($this->oauth_url)) { 59 + return new \WP_Error( 60 + "no_oauth_url", 61 + __("OAuth service URL is not configured.", "wireservice"), 62 + ); 63 + } 64 + 65 + $url = $this->oauth_url . $endpoint; 66 + 67 + $default_args = [ 68 + "method" => $method, 69 + "headers" => [ 70 + "Authorization" => "Bearer " . $access_token, 71 + "Content-Type" => "application/json", 72 + ], 73 + "timeout" => 30, 74 + ]; 75 + 76 + $args = wp_parse_args($args, $default_args); 77 + 78 + if (!empty($args["body"]) && is_array($args["body"])) { 79 + $args["body"] = wp_json_encode($args["body"]); 80 + } 81 + 82 + $response = wp_remote_request($url, $args); 83 + 84 + if (is_wp_error($response)) { 85 + return $response; 86 + } 87 + 88 + $status_code = wp_remote_retrieve_response_code($response); 89 + $body = json_decode(wp_remote_retrieve_body($response), true); 90 + 91 + return $this->maybe_error($status_code, $body, "api_error", "API request failed."); 92 + } 93 + 94 + /** 95 + * Make an authenticated request to the user's PDS. 96 + * 97 + * @param string $method HTTP method. 98 + * @param string $endpoint API endpoint (e.g., /xrpc/com.atproto.repo.createRecord). 99 + * @param array $args Request arguments. 100 + * @param string $nonce Optional DPoP nonce from a previous request. 101 + * @return array|\WP_Error 102 + */ 103 + public function pds_request( 104 + string $method, 105 + string $endpoint, 106 + array $args = [], 107 + ?string $nonce = null, 108 + ) { 109 + $original_args = $args; 110 + 111 + $credentials = $this->get_pds_credentials(); 112 + if (is_wp_error($credentials)) { 113 + return $credentials; 114 + } 115 + 116 + $pds_endpoint = $credentials["pds_endpoint"]; 117 + $pds_token = $credentials["pds_token"]; 118 + $dpop_jwk = $credentials["dpop_jwk"]; 119 + 120 + $url = rtrim($pds_endpoint, "/") . $endpoint; 121 + 122 + $content_type = $args["headers"]["Content-Type"] ?? "application/json"; 123 + unset($args["headers"]); 124 + 125 + $headers = [ 126 + "Authorization" => "DPoP " . $pds_token, 127 + "Content-Type" => $content_type, 128 + ]; 129 + 130 + if (!empty($dpop_jwk)) { 131 + $dpop_proof = $this->generate_dpop_header($dpop_jwk, $method, $url, $pds_token, $nonce); 132 + if ($dpop_proof) { 133 + $headers["DPoP"] = $dpop_proof; 134 + } 135 + } 136 + 137 + $default_args = [ 138 + "method" => $method, 139 + "headers" => $headers, 140 + "timeout" => 30, 141 + ]; 142 + 143 + $args = wp_parse_args($args, $default_args); 144 + 145 + if (!empty($args["body"]) && is_array($args["body"])) { 146 + $args["body"] = wp_json_encode($args["body"]); 147 + } 148 + 149 + $response = wp_remote_request($url, $args); 150 + 151 + if (is_wp_error($response)) { 152 + return $response; 153 + } 154 + 155 + $status_code = wp_remote_retrieve_response_code($response); 156 + $body = json_decode(wp_remote_retrieve_body($response), true); 157 + 158 + $response_nonce = $this->store_response_nonce($response, $dpop_jwk, $url); 159 + 160 + if ($this->is_dpop_nonce_error($status_code, $body) && $response_nonce && $nonce === null) { 161 + return $this->pds_request($method, $endpoint, $original_args, $response_nonce); 162 + } 163 + 164 + return $this->maybe_error($status_code, $body, "pds_error", "PDS request failed."); 165 + } 166 + 167 + /** 168 + * Make a GET request to the OAuth service. 169 + * 170 + * @param string $endpoint API endpoint. 171 + * @param array $params Query parameters. 172 + * @return array|\WP_Error 173 + */ 174 + public function get(string $endpoint, array $params = []) 175 + { 176 + if (!empty($params)) { 177 + $endpoint .= "?" . http_build_query($params); 178 + } 179 + 180 + return $this->request("GET", $endpoint); 181 + } 182 + 183 + /** 184 + * Make a POST request to the OAuth service. 185 + * 186 + * @param string $endpoint API endpoint. 187 + * @param array $body Request body. 188 + * @return array|\WP_Error 189 + */ 190 + public function post(string $endpoint, array $body = []) 191 + { 192 + return $this->request("POST", $endpoint, ["body" => $body]); 193 + } 194 + 195 + /** 196 + * Make a GET request to the PDS. 197 + * 198 + * @param string $endpoint API endpoint. 199 + * @param array $params Query parameters. 200 + * @return array|\WP_Error 201 + */ 202 + public function pds_get(string $endpoint, array $params = []) 203 + { 204 + if (!empty($params)) { 205 + $endpoint .= "?" . http_build_query($params); 206 + } 207 + 208 + return $this->pds_request("GET", $endpoint); 209 + } 210 + 211 + /** 212 + * Make a POST request to the PDS. 213 + * 214 + * @param string $endpoint API endpoint. 215 + * @param array $body Request body. 216 + * @return array|\WP_Error 217 + */ 218 + public function pds_post(string $endpoint, array $body = []) 219 + { 220 + return $this->pds_request("POST", $endpoint, ["body" => $body]); 221 + } 222 + 223 + /** 224 + * Get session information from the OAuth service. 225 + * 226 + * @return array|\WP_Error 227 + */ 228 + public function get_session() 229 + { 230 + if ($this->session !== null) { 231 + return $this->session; 232 + } 233 + 234 + $session = $this->get("/api/atprotocol/session"); 235 + 236 + if (!is_wp_error($session)) { 237 + $this->session = $session; 238 + } 239 + 240 + return $session; 241 + } 242 + 243 + /** 244 + * Get the user's DID from the session. 245 + * 246 + * @return string|\WP_Error 247 + */ 248 + public function get_did() 249 + { 250 + $session = $this->get_session(); 251 + 252 + if (is_wp_error($session)) { 253 + return $session; 254 + } 255 + 256 + return $session["did"] ?? 257 + new \WP_Error("no_did", __("DID not found in session.", "wireservice")); 258 + } 259 + 260 + /** 261 + * Get PDS credentials from session. 262 + * 263 + * @return array|\WP_Error Array with pds_endpoint, pds_token, dpop_jwk or error. 264 + */ 265 + private function get_pds_credentials() 266 + { 267 + if ($this->pds_credentials !== null) { 268 + return $this->pds_credentials; 269 + } 270 + 271 + $session = $this->get_session(); 272 + 273 + if (is_wp_error($session)) { 274 + return $session; 275 + } 276 + 277 + $pds_endpoint = $session["pds_endpoint"] ?? null; 278 + $pds_token = $session["access_token"] ?? null; 279 + $dpop_jwk = $session["dpop_jwk"] ?? null; 280 + 281 + if (empty($pds_endpoint) || empty($pds_token)) { 282 + return new \WP_Error( 283 + "no_pds", 284 + __("PDS endpoint or token not available.", "wireservice"), 285 + ); 286 + } 287 + 288 + $this->pds_credentials = [ 289 + "pds_endpoint" => $pds_endpoint, 290 + "pds_token" => $pds_token, 291 + "dpop_jwk" => $dpop_jwk, 292 + ]; 293 + 294 + return $this->pds_credentials; 295 + } 296 + 297 + /** 298 + * Generate a DPoP proof header value. 299 + * 300 + * @param array $dpop_jwk The DPoP JWK. 301 + * @param string $method HTTP method. 302 + * @param string $url Full request URL. 303 + * @param string $pds_token Access token. 304 + * @param string|null $nonce Optional nonce. 305 + * @return string|null The DPoP proof or null. 306 + */ 307 + private function generate_dpop_header( 308 + array $dpop_jwk, 309 + string $method, 310 + string $url, 311 + string $pds_token, 312 + ?string $nonce = null, 313 + ): ?string { 314 + $proof = DPoP::generate_proof($dpop_jwk, $method, $url, $nonce, $pds_token); 315 + return $proof !== false ? $proof : null; 316 + } 317 + 318 + /** 319 + * Store DPoP nonce from response if present. 320 + * 321 + * @param array|object $response WordPress HTTP response. 322 + * @param array|null $dpop_jwk The DPoP JWK. 323 + * @param string $url Request URL. 324 + * @return string|null The nonce if present. 325 + */ 326 + private function store_response_nonce($response, ?array $dpop_jwk, string $url): ?string 327 + { 328 + $nonce = wp_remote_retrieve_header($response, "dpop-nonce"); 329 + if ($nonce && !empty($dpop_jwk)) { 330 + DPoP::store_nonce($dpop_jwk, $url, $nonce); 331 + } 332 + return $nonce ?: null; 333 + } 334 + 335 + /** 336 + * Check if response indicates a DPoP nonce error. 337 + * 338 + * @param int $status_code HTTP status code. 339 + * @param array $body Response body. 340 + * @return bool True if nonce error. 341 + */ 342 + private function is_dpop_nonce_error(int $status_code, ?array $body): bool 343 + { 344 + return in_array($status_code, [400, 401], true) && 345 + isset($body["error"]) && 346 + $body["error"] === "use_dpop_nonce"; 347 + } 348 + 349 + /** 350 + * Create a record on the PDS. 351 + * 352 + * @param string $collection The collection NSID. 353 + * @param array $record The record data. 354 + * @param string $rkey Optional record key. 355 + * @return array|\WP_Error 356 + */ 357 + public function create_record( 358 + string $collection, 359 + array $record, 360 + ?string $rkey = null, 361 + ) { 362 + $did = $this->get_did(); 363 + 364 + if (is_wp_error($did)) { 365 + return $did; 366 + } 367 + 368 + $body = [ 369 + "repo" => $did, 370 + "collection" => $collection, 371 + "record" => $record, 372 + ]; 373 + 374 + if ($rkey !== null) { 375 + $body["rkey"] = $rkey; 376 + } 377 + 378 + return $this->pds_post("/xrpc/com.atproto.repo.createRecord", $body); 379 + } 380 + 381 + /** 382 + * Update a record on the PDS. 383 + * 384 + * @param string $collection The collection NSID. 385 + * @param string $rkey The record key. 386 + * @param array $record The record data. 387 + * @return array|\WP_Error 388 + */ 389 + public function put_record(string $collection, string $rkey, array $record) 390 + { 391 + $did = $this->get_did(); 392 + 393 + if (is_wp_error($did)) { 394 + return $did; 395 + } 396 + 397 + return $this->pds_post("/xrpc/com.atproto.repo.putRecord", [ 398 + "repo" => $did, 399 + "collection" => $collection, 400 + "rkey" => $rkey, 401 + "record" => $record, 402 + ]); 403 + } 404 + 405 + /** 406 + * Delete a record from the PDS. 407 + * 408 + * @param string $collection The collection NSID. 409 + * @param string $rkey The record key. 410 + * @return array|\WP_Error 411 + */ 412 + public function delete_record(string $collection, string $rkey) 413 + { 414 + $did = $this->get_did(); 415 + 416 + if (is_wp_error($did)) { 417 + return $did; 418 + } 419 + 420 + return $this->pds_post("/xrpc/com.atproto.repo.deleteRecord", [ 421 + "repo" => $did, 422 + "collection" => $collection, 423 + "rkey" => $rkey, 424 + ]); 425 + } 426 + 427 + /** 428 + * Upload a blob to the PDS. 429 + * 430 + * @param string $file_path The local file path. 431 + * @param string $mime_type The MIME type of the file. 432 + * @return array|\WP_Error The blob reference or error. 433 + */ 434 + public function upload_blob(string $file_path, string $mime_type) 435 + { 436 + if (!file_exists($file_path)) { 437 + return new \WP_Error( 438 + "file_not_found", 439 + __("File not found.", "wireservice"), 440 + ); 441 + } 442 + 443 + $file_contents = file_get_contents($file_path); 444 + if ($file_contents === false) { 445 + return new \WP_Error( 446 + "file_read_error", 447 + __("Could not read file.", "wireservice"), 448 + ); 449 + } 450 + 451 + return $this->pds_request("POST", "/xrpc/com.atproto.repo.uploadBlob", [ 452 + "body" => $file_contents, 453 + "headers" => ["Content-Type" => $mime_type], 454 + "timeout" => 60, 455 + ]); 456 + } 457 + 458 + /** 459 + * Build a WP_Error from a failed response, or return the decoded body. 460 + * 461 + * @param int $status_code HTTP status code. 462 + * @param array|null $body Decoded response body. 463 + * @param string $error_code WP_Error code. 464 + * @param string $default_msg Fallback error message. 465 + * @return array|\WP_Error 466 + */ 467 + private function maybe_error( 468 + int $status_code, 469 + ?array $body, 470 + string $error_code, 471 + string $default_msg, 472 + ) { 473 + if ($status_code >= 400) { 474 + $error_message = $body["error"] 475 + ?? ($body["message"] ?? __($default_msg, "wireservice")); 476 + return new \WP_Error($error_code, $error_message, [ 477 + "status" => $status_code, 478 + ]); 479 + } 480 + 481 + return $body; 482 + } 483 + }
+691
includes/Admin.php
··· 1 + <?php 2 + declare(strict_types=1); 3 + /** 4 + * Admin functionality for Wireservice. 5 + * 6 + * @package Wireservice 7 + */ 8 + 9 + namespace Wireservice; 10 + 11 + class Admin 12 + { 13 + /** 14 + * Constructor. 15 + */ 16 + public function __construct( 17 + private ConnectionsManager $connections_manager, 18 + private API $api, 19 + private Publication $publication, 20 + ) {} 21 + 22 + /** 23 + * Initialize admin hooks. 24 + * 25 + * @return void 26 + */ 27 + public function init(): void 28 + { 29 + add_action("admin_menu", [$this, "add_admin_menu"]); 30 + add_action("admin_init", [$this, "register_settings"]); 31 + add_action("admin_enqueue_scripts", [$this, "enqueue_settings_assets"]); 32 + add_action("admin_post_wireservice_sync_publication", [ 33 + $this, 34 + "handle_sync_publication", 35 + ]); 36 + add_action("admin_post_wireservice_reset_data", [ 37 + $this, 38 + "handle_reset_data", 39 + ]); 40 + add_action("admin_post_wireservice_save_doc_settings", [ 41 + $this, 42 + "handle_save_doc_settings", 43 + ]); 44 + add_filter("plugin_action_links_wireservice/wireservice.php", [ 45 + $this, 46 + "add_settings_link", 47 + ]); 48 + } 49 + 50 + /** 51 + * Enqueue scripts for the settings page. 52 + * 53 + * @param string $hook_suffix The current admin page hook suffix. 54 + * @return void 55 + */ 56 + public function enqueue_settings_assets(string $hook_suffix): void 57 + { 58 + if ($hook_suffix !== "settings_page_wireservice") { 59 + return; 60 + } 61 + 62 + wp_enqueue_media(); 63 + wp_enqueue_style("wp-color-picker"); 64 + 65 + wp_enqueue_script( 66 + "wireservice-settings", 67 + WIRESERVICE_PLUGIN_URL . "assets/js/settings.js", 68 + ["jquery", "wp-color-picker"], 69 + WIRESERVICE_VERSION, 70 + true, 71 + ); 72 + } 73 + 74 + /** 75 + * Add admin menu page. 76 + * 77 + * @return void 78 + */ 79 + public function add_admin_menu(): void 80 + { 81 + add_options_page( 82 + __("Wireservice", "wireservice"), 83 + __("Wireservice", "wireservice"), 84 + "manage_options", 85 + "wireservice", 86 + [$this, "render_settings_page"], 87 + ); 88 + } 89 + 90 + /** 91 + * Register plugin settings. 92 + * 93 + * @return void 94 + */ 95 + public function register_settings(): void 96 + { 97 + register_setting("wireservice", "wireservice_connection", [ 98 + "type" => "object", 99 + "sanitize_callback" => [$this, "sanitize_connection"], 100 + "show_in_rest" => false, 101 + "default" => [], 102 + ]); 103 + 104 + register_setting("wireservice", "wireservice_client_id", [ 105 + "type" => "string", 106 + "sanitize_callback" => "sanitize_text_field", 107 + "show_in_rest" => false, 108 + "default" => "", 109 + ]); 110 + 111 + register_setting("wireservice", "wireservice_client_secret", [ 112 + "type" => "string", 113 + "sanitize_callback" => "sanitize_text_field", 114 + "show_in_rest" => false, // Don't expose secret via REST. 115 + "default" => "", 116 + ]); 117 + 118 + register_setting("wireservice", "wireservice_oauth_url", [ 119 + "type" => "string", 120 + "sanitize_callback" => "esc_url_raw", 121 + "show_in_rest" => false, 122 + "default" => "https://aip.wireservice.net", 123 + ]); 124 + 125 + register_setting("wireservice", "wireservice_pub_settings", [ 126 + "type" => "array", 127 + "sanitize_callback" => [$this, "sanitize_pub_settings"], 128 + "show_in_rest" => false, 129 + "default" => SourceOptions::PUB_DEFAULTS, 130 + ]); 131 + 132 + register_setting("wireservice", "wireservice_doc_settings", [ 133 + "type" => "array", 134 + "sanitize_callback" => [$this, "sanitize_doc_settings"], 135 + "show_in_rest" => false, 136 + "default" => SourceOptions::DOC_DEFAULTS, 137 + ]); 138 + } 139 + 140 + /** 141 + * Sanitize connection data. 142 + * 143 + * @param mixed $value The value to sanitize. 144 + * @return array 145 + */ 146 + public function sanitize_connection($value): array 147 + { 148 + if (!is_array($value)) { 149 + return []; 150 + } 151 + 152 + return [ 153 + "access_token" => isset($value["access_token"]) 154 + ? sanitize_text_field($value["access_token"]) 155 + : "", 156 + "refresh_token" => isset($value["refresh_token"]) 157 + ? sanitize_text_field($value["refresh_token"]) 158 + : "", 159 + "expires_at" => isset($value["expires_at"]) 160 + ? absint($value["expires_at"]) 161 + : 0, 162 + "handle" => isset($value["handle"]) 163 + ? sanitize_text_field($value["handle"]) 164 + : "", 165 + "did" => isset($value["did"]) ? sanitize_text_field($value["did"]) : "", 166 + ]; 167 + } 168 + 169 + /** 170 + * Sanitize publication settings. 171 + * 172 + * @param mixed $value The value to sanitize. 173 + * @return array 174 + */ 175 + public function sanitize_pub_settings($value): array 176 + { 177 + if (!is_array($value)) { 178 + return SourceOptions::PUB_DEFAULTS; 179 + } 180 + 181 + return [ 182 + "name_source" => isset($value["name_source"]) 183 + ? sanitize_text_field($value["name_source"]) 184 + : "wordpress_title", 185 + "description_source" => isset($value["description_source"]) 186 + ? sanitize_text_field($value["description_source"]) 187 + : "wordpress_tagline", 188 + "custom_name" => isset($value["custom_name"]) 189 + ? sanitize_text_field($value["custom_name"]) 190 + : "", 191 + "custom_description" => isset($value["custom_description"]) 192 + ? sanitize_textarea_field($value["custom_description"]) 193 + : "", 194 + "icon_source" => isset($value["icon_source"]) 195 + ? sanitize_text_field($value["icon_source"]) 196 + : "none", 197 + "custom_icon_id" => isset($value["custom_icon_id"]) 198 + ? absint($value["custom_icon_id"]) 199 + : 0, 200 + "theme_background" => isset($value["theme_background"]) 201 + ? sanitize_hex_color($value["theme_background"]) ?: "" 202 + : "", 203 + "theme_foreground" => isset($value["theme_foreground"]) 204 + ? sanitize_hex_color($value["theme_foreground"]) ?: "" 205 + : "", 206 + "theme_accent" => isset($value["theme_accent"]) 207 + ? sanitize_hex_color($value["theme_accent"]) ?: "" 208 + : "", 209 + "theme_accent_foreground" => isset($value["theme_accent_foreground"]) 210 + ? sanitize_hex_color($value["theme_accent_foreground"]) ?: "" 211 + : "", 212 + "show_in_discover" => isset($value["show_in_discover"]) 213 + ? sanitize_text_field($value["show_in_discover"]) 214 + : "", 215 + ]; 216 + } 217 + 218 + /** 219 + * Sanitize document settings. 220 + * 221 + * @param mixed $value The value to sanitize. 222 + * @return array 223 + */ 224 + public function sanitize_doc_settings($value): array 225 + { 226 + if (!is_array($value)) { 227 + return SourceOptions::DOC_DEFAULTS; 228 + } 229 + 230 + return [ 231 + "enabled" => isset($value["enabled"]) 232 + ? sanitize_text_field($value["enabled"]) 233 + : "0", 234 + "title_source" => isset($value["title_source"]) 235 + ? sanitize_text_field($value["title_source"]) 236 + : "wordpress_title", 237 + "description_source" => isset($value["description_source"]) 238 + ? sanitize_text_field($value["description_source"]) 239 + : "wordpress_excerpt", 240 + "image_source" => isset($value["image_source"]) 241 + ? sanitize_text_field($value["image_source"]) 242 + : "wordpress_featured", 243 + "include_content" => isset($value["include_content"]) 244 + ? sanitize_text_field($value["include_content"]) 245 + : "0", 246 + ]; 247 + } 248 + 249 + /** 250 + * Render the settings page. 251 + * 252 + * @return void 253 + */ 254 + public function render_settings_page(): void 255 + { 256 + if (!current_user_can("manage_options")) { 257 + return; 258 + } 259 + 260 + $connection = get_option("wireservice_connection", []); 261 + $is_connected = !empty($connection["access_token"]); 262 + 263 + // Connection data. 264 + $session = null; 265 + $authorize_url = ""; 266 + $oauth_url = ""; 267 + $client_error = ""; 268 + 269 + if ($is_connected) { 270 + $session = $this->api->get_session(); 271 + } else { 272 + $authorize_url = $this->connections_manager->get_authorize_url(); 273 + $oauth_url = get_option("wireservice_oauth_url"); 274 + $client_error = get_transient("wireservice_client_error"); 275 + } 276 + 277 + // Publication and document data (only needed when connected). 278 + $pub_uri = ""; 279 + $yoast_active = false; 280 + $name_source = "wordpress_title"; 281 + $desc_source = "wordpress_tagline"; 282 + $custom_name = ""; 283 + $custom_description = ""; 284 + $name_sources = []; 285 + $desc_sources = []; 286 + $doc_title_source = "wordpress_title"; 287 + $doc_desc_source = "wordpress_excerpt"; 288 + $doc_image_source = "wordpress_featured"; 289 + $doc_include_content = "0"; 290 + $doc_enabled = "0"; 291 + $doc_title_sources = []; 292 + $doc_desc_sources = []; 293 + $doc_image_sources = []; 294 + $icon_source = "none"; 295 + $custom_icon_id = 0; 296 + $icon_preview_url = ""; 297 + $icon_sources = []; 298 + $theme_background = ""; 299 + $theme_foreground = ""; 300 + $theme_accent = ""; 301 + $theme_accent_foreground = ""; 302 + $show_in_discover = ""; 303 + 304 + if ($is_connected) { 305 + $pub_uri = $this->publication->get_at_uri(); 306 + $yoast_active = Yoast::is_active(); 307 + 308 + $pub = SourceOptions::get_pub_settings(); 309 + $name_source = $pub["name_source"]; 310 + $desc_source = $pub["description_source"]; 311 + $custom_name = $pub["custom_name"]; 312 + $custom_description = $pub["custom_description"]; 313 + $icon_source = $pub["icon_source"]; 314 + $custom_icon_id = $pub["custom_icon_id"]; 315 + $theme_background = $pub["theme_background"]; 316 + $theme_foreground = $pub["theme_foreground"]; 317 + $theme_accent = $pub["theme_accent"]; 318 + $theme_accent_foreground = $pub["theme_accent_foreground"]; 319 + $show_in_discover = $pub["show_in_discover"]; 320 + 321 + if ($icon_source === "custom" && $custom_icon_id) { 322 + $icon_preview_url = wp_get_attachment_image_url( 323 + $custom_icon_id, 324 + [64, 64], 325 + ); 326 + } elseif ($icon_source === "wordpress_site_icon") { 327 + $icon_preview_url = get_site_icon_url(64); 328 + } 329 + 330 + $name_sources = SourceOptions::pub_name_sources($custom_name); 331 + $desc_sources = SourceOptions::pub_description_sources( 332 + $custom_description, 333 + ); 334 + $icon_sources = SourceOptions::pub_icon_sources(); 335 + 336 + $doc = SourceOptions::get_doc_settings(); 337 + $doc_title_source = $doc["title_source"]; 338 + $doc_desc_source = $doc["description_source"]; 339 + $doc_image_source = $doc["image_source"]; 340 + $doc_include_content = $doc["include_content"]; 341 + $doc_enabled = $doc["enabled"]; 342 + 343 + $doc_title_sources = SourceOptions::doc_title_sources(); 344 + $doc_desc_sources = SourceOptions::doc_description_sources(); 345 + $doc_image_sources = SourceOptions::doc_image_sources(); 346 + } 347 + 348 + include WIRESERVICE_PLUGIN_DIR . "templates/settings-page.php"; 349 + } 350 + 351 + /** 352 + * Add settings link to plugin actions. 353 + * 354 + * @param array $links Existing plugin action links. 355 + * @return array 356 + */ 357 + public function add_settings_link(array $links): array 358 + { 359 + $settings_link = sprintf( 360 + '<a href="%s">%s</a>', 361 + esc_url(admin_url("options-general.php?page=wireservice")), 362 + esc_html__("Settings", "wireservice"), 363 + ); 364 + array_unshift($links, $settings_link); 365 + return $links; 366 + } 367 + 368 + /** 369 + * Handle syncing the publication to ATProto. 370 + * 371 + * @return void 372 + */ 373 + public function handle_sync_publication(): void 374 + { 375 + if (!current_user_can("manage_options")) { 376 + wp_die( 377 + esc_html__("You do not have permission to do this.", "wireservice"), 378 + ); 379 + } 380 + 381 + check_admin_referer( 382 + "wireservice_sync_publication", 383 + "wireservice_pub_nonce", 384 + ); 385 + 386 + $pub = SourceOptions::get_pub_settings(); 387 + 388 + $pub["name_source"] = isset($_POST["wireservice_pub_name_source"]) 389 + ? sanitize_text_field(wp_unslash($_POST["wireservice_pub_name_source"])) 390 + : $pub["name_source"]; 391 + $pub["description_source"] = isset( 392 + $_POST["wireservice_pub_description_source"], 393 + ) 394 + ? sanitize_text_field( 395 + wp_unslash($_POST["wireservice_pub_description_source"]), 396 + ) 397 + : $pub["description_source"]; 398 + 399 + if (isset($_POST["wireservice_pub_custom_name"])) { 400 + $pub["custom_name"] = sanitize_text_field( 401 + wp_unslash($_POST["wireservice_pub_custom_name"]), 402 + ); 403 + } 404 + if (isset($_POST["wireservice_pub_custom_description"])) { 405 + $pub["custom_description"] = sanitize_textarea_field( 406 + wp_unslash($_POST["wireservice_pub_custom_description"]), 407 + ); 408 + } 409 + 410 + $pub["icon_source"] = isset($_POST["wireservice_pub_icon_source"]) 411 + ? sanitize_text_field( 412 + wp_unslash($_POST["wireservice_pub_icon_source"]), 413 + ) 414 + : $pub["icon_source"]; 415 + $pub["custom_icon_id"] = isset($_POST["wireservice_pub_custom_icon_id"]) 416 + ? absint($_POST["wireservice_pub_custom_icon_id"]) 417 + : $pub["custom_icon_id"]; 418 + 419 + $pub["theme_background"] = isset( 420 + $_POST["wireservice_pub_theme_background"], 421 + ) 422 + ? sanitize_hex_color( 423 + wp_unslash($_POST["wireservice_pub_theme_background"]), 424 + ) ?: "" 425 + : $pub["theme_background"]; 426 + $pub["theme_foreground"] = isset( 427 + $_POST["wireservice_pub_theme_foreground"], 428 + ) 429 + ? sanitize_hex_color( 430 + wp_unslash($_POST["wireservice_pub_theme_foreground"]), 431 + ) ?: "" 432 + : $pub["theme_foreground"]; 433 + $pub["theme_accent"] = isset($_POST["wireservice_pub_theme_accent"]) 434 + ? sanitize_hex_color( 435 + wp_unslash($_POST["wireservice_pub_theme_accent"]), 436 + ) ?: "" 437 + : $pub["theme_accent"]; 438 + $pub["theme_accent_foreground"] = isset( 439 + $_POST["wireservice_pub_theme_accent_foreground"], 440 + ) 441 + ? sanitize_hex_color( 442 + wp_unslash($_POST["wireservice_pub_theme_accent_foreground"]), 443 + ) ?: "" 444 + : $pub["theme_accent_foreground"]; 445 + 446 + $pub["show_in_discover"] = isset( 447 + $_POST["wireservice_pub_show_in_discover"], 448 + ) 449 + ? "1" 450 + : "0"; 451 + 452 + update_option("wireservice_pub_settings", $pub); 453 + 454 + $name_source = $pub["name_source"]; 455 + $desc_source = $pub["description_source"]; 456 + 457 + $name = $this->resolve_publication_name($name_source); 458 + $description = $this->resolve_publication_description($desc_source); 459 + 460 + $icon_attachment_id = $this->resolve_publication_icon( 461 + $pub["icon_source"], 462 + $pub["custom_icon_id"], 463 + ); 464 + 465 + $pub_data = [ 466 + "url" => isset($_POST["wireservice_pub_url"]) 467 + ? sanitize_url(wp_unslash($_POST["wireservice_pub_url"])) 468 + : home_url(), 469 + "name" => $name, 470 + "description" => $description, 471 + "icon_attachment_id" => $icon_attachment_id, 472 + "theme_background" => $pub["theme_background"], 473 + "theme_foreground" => $pub["theme_foreground"], 474 + "theme_accent" => $pub["theme_accent"], 475 + "theme_accent_foreground" => $pub["theme_accent_foreground"], 476 + "show_in_discover" => $pub["show_in_discover"], 477 + ]; 478 + $this->publication->save_publication_data($pub_data); 479 + 480 + $result = $this->publication->sync_to_atproto($pub_data); 481 + 482 + if (is_wp_error($result)) { 483 + $error_data = $result->get_error_data(); 484 + $debug_info = $result->get_error_message(); 485 + if ($error_data) { 486 + $debug_info .= " (Data: " . wp_json_encode($error_data) . ")"; 487 + } 488 + add_settings_error( 489 + "wireservice", 490 + "publication_sync_failed", 491 + sprintf( 492 + /* translators: %s: error message */ 493 + __("Failed to sync publication: %s", "wireservice"), 494 + $debug_info, 495 + ), 496 + "error", 497 + ); 498 + } else { 499 + add_settings_error( 500 + "wireservice", 501 + "publication_synced", 502 + __("Publication synced to AT Protocol.", "wireservice"), 503 + "success", 504 + ); 505 + } 506 + 507 + set_transient("settings_errors", get_settings_errors(), 30); 508 + 509 + wp_safe_redirect( 510 + admin_url("options-general.php?page=wireservice&settings-updated=true"), 511 + ); 512 + exit(); 513 + } 514 + 515 + /** 516 + * Handle resetting all plugin data. 517 + * 518 + * @return void 519 + */ 520 + public function handle_reset_data(): void 521 + { 522 + if (!current_user_can("manage_options")) { 523 + wp_die( 524 + esc_html__("You do not have permission to do this.", "wireservice"), 525 + ); 526 + } 527 + 528 + check_admin_referer("wireservice_reset_data", "wireservice_reset_nonce"); 529 + 530 + // Remove all plugin options. 531 + delete_option("wireservice_connection"); 532 + delete_option("wireservice_client_id"); 533 + delete_option("wireservice_client_secret"); 534 + delete_option("wireservice_oauth_url"); 535 + delete_option("wireservice_publication"); 536 + delete_option("wireservice_publication_uri"); 537 + delete_option("wireservice_pub_settings"); 538 + delete_option("wireservice_doc_settings"); 539 + 540 + // Remove transients. 541 + delete_transient("wireservice_code_verifier"); 542 + delete_transient("wireservice_oauth_state"); 543 + 544 + // Remove post meta for documents. 545 + global $wpdb; 546 + $wpdb->delete($wpdb->postmeta, ["meta_key" => "_wireservice_document_uri"]); 547 + $wpdb->delete($wpdb->postmeta, ["meta_key" => "_wireservice_title_source"]); 548 + $wpdb->delete($wpdb->postmeta, [ 549 + "meta_key" => "_wireservice_description_source", 550 + ]); 551 + $wpdb->delete($wpdb->postmeta, ["meta_key" => "_wireservice_image_source"]); 552 + $wpdb->delete($wpdb->postmeta, ["meta_key" => "_wireservice_custom_title"]); 553 + $wpdb->delete($wpdb->postmeta, [ 554 + "meta_key" => "_wireservice_custom_description", 555 + ]); 556 + $wpdb->delete($wpdb->postmeta, [ 557 + "meta_key" => "_wireservice_custom_image_id", 558 + ]); 559 + $wpdb->delete($wpdb->postmeta, [ 560 + "meta_key" => "_wireservice_include_content", 561 + ]); 562 + 563 + add_settings_error( 564 + "wireservice", 565 + "data_reset", 566 + __("All Wireservice data has been reset.", "wireservice"), 567 + "success", 568 + ); 569 + 570 + set_transient("settings_errors", get_settings_errors(), 30); 571 + 572 + wp_safe_redirect( 573 + admin_url("options-general.php?page=wireservice&settings-updated=true"), 574 + ); 575 + exit(); 576 + } 577 + 578 + /** 579 + * Resolve the publication name based on the selected source. 580 + * 581 + * @param string $source The source key. 582 + * @return string 583 + */ 584 + private function resolve_publication_name(string $source): string 585 + { 586 + $value = match ($source) { 587 + "yoast_organization" => Yoast::get_organization_name(), 588 + "yoast_website" => Yoast::get_website_name(), 589 + "custom" => SourceOptions::get_pub_settings()["custom_name"], 590 + default => get_bloginfo("name"), 591 + }; 592 + 593 + return $value ?: get_bloginfo("name"); 594 + } 595 + 596 + /** 597 + * Resolve the publication description based on the selected source. 598 + * 599 + * @param string $source The source key. 600 + * @return string 601 + */ 602 + private function resolve_publication_description(string $source): string 603 + { 604 + $value = match ($source) { 605 + "yoast_homepage" => Yoast::get_homepage_description(), 606 + "custom" => SourceOptions::get_pub_settings()["custom_description"], 607 + default => get_bloginfo("description"), 608 + }; 609 + 610 + return $value ?: get_bloginfo("description"); 611 + } 612 + 613 + /** 614 + * Resolve the publication icon attachment ID based on the selected source. 615 + * 616 + * @param string $source The source key. 617 + * @param int $custom_icon_id The custom icon attachment ID. 618 + * @return int The attachment ID, or 0 if none. 619 + */ 620 + private function resolve_publication_icon( 621 + string $source, 622 + int $custom_icon_id, 623 + ): int { 624 + return match ($source) { 625 + "wordpress_site_icon" => (int) get_option("site_icon", 0), 626 + "custom" => $custom_icon_id, 627 + default => 0, 628 + }; 629 + } 630 + 631 + /** 632 + * Handle saving document settings. 633 + * 634 + * @return void 635 + */ 636 + public function handle_save_doc_settings(): void 637 + { 638 + if (!current_user_can("manage_options")) { 639 + wp_die( 640 + esc_html__("You do not have permission to do this.", "wireservice"), 641 + ); 642 + } 643 + 644 + check_admin_referer( 645 + "wireservice_save_doc_settings", 646 + "wireservice_doc_nonce", 647 + ); 648 + 649 + $doc = SourceOptions::get_doc_settings(); 650 + 651 + $doc["enabled"] = isset($_POST["wireservice_doc_enabled"]) ? "1" : "0"; 652 + 653 + if (isset($_POST["wireservice_doc_title_source"])) { 654 + $doc["title_source"] = sanitize_text_field( 655 + wp_unslash($_POST["wireservice_doc_title_source"]), 656 + ); 657 + } 658 + 659 + if (isset($_POST["wireservice_doc_description_source"])) { 660 + $doc["description_source"] = sanitize_text_field( 661 + wp_unslash($_POST["wireservice_doc_description_source"]), 662 + ); 663 + } 664 + 665 + if (isset($_POST["wireservice_doc_image_source"])) { 666 + $doc["image_source"] = sanitize_text_field( 667 + wp_unslash($_POST["wireservice_doc_image_source"]), 668 + ); 669 + } 670 + 671 + $doc["include_content"] = isset($_POST["wireservice_doc_include_content"]) 672 + ? "1" 673 + : "0"; 674 + 675 + update_option("wireservice_doc_settings", $doc); 676 + 677 + add_settings_error( 678 + "wireservice", 679 + "doc_settings_saved", 680 + __("Document settings saved.", "wireservice"), 681 + "success", 682 + ); 683 + 684 + set_transient("settings_errors", get_settings_errors(), 30); 685 + 686 + wp_safe_redirect( 687 + admin_url("options-general.php?page=wireservice&settings-updated=true"), 688 + ); 689 + exit(); 690 + } 691 + }
+24
includes/AtUri.php
··· 1 + <?php 2 + declare(strict_types=1); 3 + /** 4 + * Utility for working with AT-URI strings. 5 + * 6 + * @package Wireservice 7 + */ 8 + 9 + namespace Wireservice; 10 + 11 + class AtUri 12 + { 13 + /** 14 + * Extract the rkey (last path segment) from an AT-URI. 15 + * 16 + * @param string $uri The AT-URI (e.g., at://did:plc:xxx/site.standard.document/rkey). 17 + * @return string The rkey. 18 + */ 19 + public static function get_rkey(string $uri): string 20 + { 21 + $parts = explode("/", $uri); 22 + return end($parts); 23 + } 24 + }
+488
includes/ConnectionsManager.php
··· 1 + <?php 2 + declare(strict_types=1); 3 + /** 4 + * Manages OAuth connections to the AT Protocol service. 5 + * 6 + * @package Wireservice 7 + */ 8 + 9 + namespace Wireservice; 10 + 11 + class ConnectionsManager 12 + { 13 + /** 14 + * OAuth service base URL. 15 + * 16 + * @var string 17 + */ 18 + private string $oauth_url; 19 + 20 + /** 21 + * Constructor. 22 + */ 23 + public function __construct() 24 + { 25 + $this->oauth_url = rtrim(get_option("wireservice_oauth_url", "https://aip.wireservice.net"), "/"); 26 + } 27 + 28 + /** 29 + * Initialize the connections manager. 30 + * 31 + * @return void 32 + */ 33 + public function init(): void 34 + { 35 + add_action("admin_post_wireservice_disconnect", [ 36 + $this, 37 + "handle_disconnect", 38 + ]); 39 + add_action("admin_init", [$this, "handle_oauth_callback"]); 40 + } 41 + 42 + /** 43 + * Get the OAuth authorization URL. 44 + * 45 + * @return string 46 + */ 47 + public function get_authorize_url(): string 48 + { 49 + if (empty($this->oauth_url)) { 50 + return ""; 51 + } 52 + 53 + $client_id = $this->get_or_create_client_id(); 54 + 55 + if (empty($client_id)) { 56 + return ""; 57 + } 58 + 59 + // Generate PKCE code verifier and challenge. 60 + $code_verifier = $this->generate_code_verifier(); 61 + $code_challenge = $this->generate_code_challenge($code_verifier); 62 + 63 + // Store verifier in transient for later use. 64 + set_transient("wireservice_code_verifier", $code_verifier, HOUR_IN_SECONDS); 65 + 66 + // Generate state for CSRF protection. 67 + $state = wp_generate_password(32, false); 68 + set_transient("wireservice_oauth_state", $state, HOUR_IN_SECONDS); 69 + 70 + $params = [ 71 + "client_id" => $client_id, 72 + "redirect_uri" => $this->get_redirect_uri(), 73 + "state" => $state, 74 + "code_challenge" => $code_challenge, 75 + "code_challenge_method" => "S256", 76 + "scope" => 77 + "atproto repo:site.standard.publication repo:site.standard.document blob:*/*", 78 + ]; 79 + 80 + return $this->oauth_url . "/oauth/authorize?" . http_build_query($params); 81 + } 82 + 83 + /** 84 + * Get or create the OAuth client ID. 85 + * 86 + * @return string 87 + */ 88 + private function get_or_create_client_id(): string 89 + { 90 + $client_id = get_option("wireservice_client_id"); 91 + 92 + if (!empty($client_id)) { 93 + return $client_id; 94 + } 95 + 96 + if (empty($this->oauth_url)) { 97 + return ""; 98 + } 99 + 100 + // Register a new client via dynamic client registration. 101 + $response = wp_remote_post($this->oauth_url . "/oauth/clients/register", [ 102 + "headers" => ["Content-Type" => "application/json"], 103 + "body" => wp_json_encode([ 104 + "redirect_uris" => [$this->get_redirect_uri()], 105 + "client_name" => "Wireservice", 106 + "grant_types" => ["authorization_code", "refresh_token"], 107 + "response_types" => ["code"], 108 + "token_endpoint_auth_method" => "none", 109 + ]), 110 + "timeout" => 30, 111 + ]); 112 + 113 + if (is_wp_error($response)) { 114 + set_transient( 115 + "wireservice_client_error", 116 + $response->get_error_message(), 117 + 60, 118 + ); 119 + return ""; 120 + } 121 + 122 + $status_code = wp_remote_retrieve_response_code($response); 123 + $body = json_decode(wp_remote_retrieve_body($response), true); 124 + 125 + if ($status_code >= 400 || empty($body["client_id"])) { 126 + $error_msg = $body["error"] ?? $body["message"] ?? "Unknown error"; 127 + $error_desc = $body["error_description"] ?? ""; 128 + $full_error = "HTTP $status_code: $error_msg"; 129 + if ($error_desc) { 130 + $full_error .= " - $error_desc"; 131 + } 132 + $full_error .= " (redirect_uri: " . $this->get_redirect_uri() . ")"; 133 + set_transient("wireservice_client_error", $full_error, 60); 134 + return ""; 135 + } 136 + 137 + // Clear any previous error. 138 + delete_transient("wireservice_client_error"); 139 + 140 + update_option("wireservice_client_id", $body["client_id"]); 141 + return $body["client_id"]; 142 + } 143 + 144 + /** 145 + * Get the OAuth redirect URI. 146 + * 147 + * @return string 148 + */ 149 + public function get_redirect_uri(): string 150 + { 151 + return admin_url("options-general.php?page=wireservice"); 152 + } 153 + 154 + /** 155 + * Generate a PKCE code verifier. 156 + * 157 + * @return string 158 + */ 159 + private function generate_code_verifier(): string 160 + { 161 + $random_bytes = random_bytes(32); 162 + return rtrim(strtr(base64_encode($random_bytes), "+/", "-_"), "="); 163 + } 164 + 165 + /** 166 + * Generate a PKCE code challenge from a verifier. 167 + * 168 + * @param string $verifier The code verifier. 169 + * @return string 170 + */ 171 + private function generate_code_challenge(string $verifier): string 172 + { 173 + $hash = hash("sha256", $verifier, true); 174 + return rtrim(strtr(base64_encode($hash), "+/", "-_"), "="); 175 + } 176 + 177 + /** 178 + * Handle the OAuth callback. 179 + * 180 + * @return void 181 + */ 182 + public function handle_oauth_callback(): void 183 + { 184 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended 185 + if (!isset($_GET["page"]) || "wireservice" !== $_GET["page"]) { 186 + return; 187 + } 188 + 189 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended 190 + if (!isset($_GET["code"]) || !isset($_GET["state"])) { 191 + return; 192 + } 193 + 194 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended 195 + $code = sanitize_text_field(wp_unslash($_GET["code"])); 196 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended 197 + $state = sanitize_text_field(wp_unslash($_GET["state"])); 198 + 199 + // Verify state. 200 + $stored_state = get_transient("wireservice_oauth_state"); 201 + if (!$stored_state || !hash_equals($stored_state, $state)) { 202 + add_settings_error( 203 + "wireservice", 204 + "invalid_state", 205 + __("Invalid OAuth state. Please try again.", "wireservice"), 206 + "error", 207 + ); 208 + return; 209 + } 210 + 211 + delete_transient("wireservice_oauth_state"); 212 + 213 + // Get the code verifier. 214 + $code_verifier = get_transient("wireservice_code_verifier"); 215 + if (!$code_verifier) { 216 + add_settings_error( 217 + "wireservice", 218 + "missing_verifier", 219 + __("OAuth session expired. Please try again.", "wireservice"), 220 + "error", 221 + ); 222 + return; 223 + } 224 + 225 + delete_transient("wireservice_code_verifier"); 226 + 227 + // Exchange code for tokens. 228 + $result = $this->exchange_code_for_tokens($code, $code_verifier); 229 + 230 + if (is_wp_error($result)) { 231 + add_settings_error( 232 + "wireservice", 233 + "token_exchange_failed", 234 + $result->get_error_message(), 235 + "error", 236 + ); 237 + return; 238 + } 239 + 240 + add_settings_error( 241 + "wireservice", 242 + "connected", 243 + __("Successfully connected to AT Protocol.", "wireservice"), 244 + "success", 245 + ); 246 + 247 + // Redirect to remove query params. 248 + wp_safe_redirect( 249 + admin_url("options-general.php?page=wireservice&connected=1"), 250 + ); 251 + exit(); 252 + } 253 + 254 + /** 255 + * Exchange an authorization code for tokens. 256 + * 257 + * @param string $code The authorization code. 258 + * @param string $code_verifier The PKCE code verifier. 259 + * @return true|\WP_Error 260 + */ 261 + private function exchange_code_for_tokens(string $code, string $code_verifier) 262 + { 263 + $client_id = get_option("wireservice_client_id"); 264 + 265 + if (empty($client_id)) { 266 + return new \WP_Error( 267 + "no_client_id", 268 + __("No OAuth client ID configured.", "wireservice"), 269 + ); 270 + } 271 + 272 + $response = wp_remote_post($this->oauth_url . "/oauth/token", [ 273 + "headers" => ["Content-Type" => "application/x-www-form-urlencoded"], 274 + "body" => [ 275 + "grant_type" => "authorization_code", 276 + "code" => $code, 277 + "client_id" => $client_id, 278 + "redirect_uri" => $this->get_redirect_uri(), 279 + "code_verifier" => $code_verifier, 280 + ], 281 + "timeout" => 30, 282 + ]); 283 + 284 + if (is_wp_error($response)) { 285 + return $response; 286 + } 287 + 288 + $status_code = wp_remote_retrieve_response_code($response); 289 + $body = json_decode(wp_remote_retrieve_body($response), true); 290 + 291 + if ($status_code !== 200) { 292 + $error_message = 293 + $body["error_description"] ?? 294 + ($body["error"] ?? __("Token exchange failed.", "wireservice")); 295 + return new \WP_Error("token_exchange_failed", $error_message); 296 + } 297 + 298 + if (empty($body["access_token"])) { 299 + return new \WP_Error( 300 + "no_access_token", 301 + __("No access token received.", "wireservice"), 302 + ); 303 + } 304 + 305 + // Fetch session info to get handle and DID. 306 + $session = $this->fetch_session($body["access_token"]); 307 + 308 + $refresh_token = $body["refresh_token"] ?? ""; 309 + 310 + $connection = [ 311 + "access_token" => Encryption::encrypt($body["access_token"]), 312 + "refresh_token" => !empty($refresh_token) 313 + ? Encryption::encrypt($refresh_token) 314 + : "", 315 + "expires_at" => time() + ($body["expires_in"] ?? 3600), 316 + "handle" => $session["handle"] ?? "", 317 + "did" => $session["did"] ?? "", 318 + ]; 319 + 320 + update_option("wireservice_connection", $connection); 321 + 322 + return true; 323 + } 324 + 325 + /** 326 + * Fetch session info from the API. 327 + * 328 + * @param string $access_token The access token. 329 + * @return array 330 + */ 331 + private function fetch_session(string $access_token): array 332 + { 333 + $response = wp_remote_get($this->oauth_url . "/api/atprotocol/session", [ 334 + "headers" => [ 335 + "Authorization" => "Bearer " . $access_token, 336 + ], 337 + "timeout" => 30, 338 + ]); 339 + 340 + if (is_wp_error($response)) { 341 + return []; 342 + } 343 + 344 + $body = json_decode(wp_remote_retrieve_body($response), true); 345 + 346 + return is_array($body) ? $body : []; 347 + } 348 + 349 + /** 350 + * Refresh the access token. 351 + * 352 + * @return true|\WP_Error 353 + */ 354 + public function refresh_token() 355 + { 356 + $connection = get_option("wireservice_connection", []); 357 + 358 + if (empty($connection["refresh_token"])) { 359 + return new \WP_Error( 360 + "no_refresh_token", 361 + __("No refresh token available.", "wireservice"), 362 + ); 363 + } 364 + 365 + // Decrypt the refresh token, falling back to raw value for unencrypted legacy data. 366 + $refresh_token = Encryption::decrypt($connection["refresh_token"]); 367 + if ($refresh_token === false) { 368 + $refresh_token = $connection["refresh_token"]; 369 + } 370 + 371 + $client_id = get_option("wireservice_client_id"); 372 + 373 + $response = wp_remote_post($this->oauth_url . "/oauth/token", [ 374 + "headers" => ["Content-Type" => "application/x-www-form-urlencoded"], 375 + "body" => [ 376 + "grant_type" => "refresh_token", 377 + "refresh_token" => $refresh_token, 378 + "client_id" => $client_id, 379 + ], 380 + "timeout" => 30, 381 + ]); 382 + 383 + if (is_wp_error($response)) { 384 + return $response; 385 + } 386 + 387 + $body = json_decode(wp_remote_retrieve_body($response), true); 388 + 389 + if (empty($body["access_token"])) { 390 + return new \WP_Error( 391 + "refresh_failed", 392 + __("Token refresh failed.", "wireservice"), 393 + ); 394 + } 395 + 396 + $connection["access_token"] = Encryption::encrypt($body["access_token"]); 397 + $new_refresh = $body["refresh_token"] ?? null; 398 + $connection["refresh_token"] = $new_refresh !== null 399 + ? Encryption::encrypt($new_refresh) 400 + : $connection["refresh_token"]; 401 + $connection["expires_at"] = time() + ($body["expires_in"] ?? 3600); 402 + 403 + update_option("wireservice_connection", $connection); 404 + 405 + return true; 406 + } 407 + 408 + /** 409 + * Get a valid access token, refreshing if necessary. 410 + * 411 + * @return string|\WP_Error 412 + */ 413 + public function get_access_token() 414 + { 415 + $connection = get_option("wireservice_connection", []); 416 + 417 + if (empty($connection["access_token"])) { 418 + return new \WP_Error( 419 + "not_connected", 420 + __("Not connected to AT Protocol.", "wireservice"), 421 + ); 422 + } 423 + 424 + // Check if token is expired (with 5 min buffer). 425 + if ( 426 + !empty($connection["expires_at"]) && 427 + $connection["expires_at"] < time() + 300 428 + ) { 429 + $result = $this->refresh_token(); 430 + if (is_wp_error($result)) { 431 + return $result; 432 + } 433 + $connection = get_option("wireservice_connection", []); 434 + } 435 + 436 + // Decrypt the access token, falling back to raw value for unencrypted legacy data. 437 + $access_token = Encryption::decrypt($connection["access_token"]); 438 + if ($access_token === false) { 439 + $access_token = $connection["access_token"]; 440 + } 441 + 442 + return $access_token; 443 + } 444 + 445 + /** 446 + * Handle disconnect request. 447 + * 448 + * @return void 449 + */ 450 + public function handle_disconnect(): void 451 + { 452 + if (!current_user_can("manage_options")) { 453 + wp_die( 454 + esc_html__("You do not have permission to do this.", "wireservice"), 455 + ); 456 + } 457 + 458 + check_admin_referer("wireservice_disconnect", "wireservice_nonce"); 459 + 460 + delete_option("wireservice_connection"); 461 + 462 + wp_safe_redirect( 463 + admin_url("options-general.php?page=wireservice&disconnected=1"), 464 + ); 465 + exit(); 466 + } 467 + 468 + /** 469 + * Check if connected to AT Protocol. 470 + * 471 + * @return bool 472 + */ 473 + public function is_connected(): bool 474 + { 475 + $connection = get_option("wireservice_connection", []); 476 + return !empty($connection["access_token"]); 477 + } 478 + 479 + /** 480 + * Get the current connection info. 481 + * 482 + * @return array 483 + */ 484 + public function get_connection(): array 485 + { 486 + return get_option("wireservice_connection", []); 487 + } 488 + }
+147
includes/DPoP.php
··· 1 + <?php 2 + declare(strict_types=1); 3 + /** 4 + * DPoP (Demonstration of Proof of Possession) support using oauth2-dpop library. 5 + * 6 + * @package Wireservice 7 + */ 8 + 9 + namespace Wireservice; 10 + 11 + use danielburger1337\OAuth2\DPoP\DPoPProofFactory; 12 + use danielburger1337\OAuth2\DPoP\Encoder\WebTokenFrameworkDPoPTokenEncoder; 13 + use danielburger1337\OAuth2\DPoP\Model\AccessTokenModel; 14 + use Jose\Component\Core\AlgorithmManager; 15 + use Jose\Component\Core\JWK; 16 + use Jose\Component\Signature\Algorithm\ES256; 17 + use Symfony\Component\Clock\NativeClock; 18 + 19 + class DPoP 20 + { 21 + /** 22 + * Supported algorithms for AT Protocol. 23 + * 24 + * @var array 25 + */ 26 + private const SUPPORTED_ALGORITHMS = ["ES256"]; 27 + 28 + /** 29 + * Cached factory instances keyed by JWK hash. 30 + * 31 + * @var array<string, DPoPProofFactory> 32 + */ 33 + private static array $factories = []; 34 + 35 + /** 36 + * Cached JWK thumbprints keyed by JWK hash. 37 + * 38 + * @var array<string, string> 39 + */ 40 + private static array $thumbprints = []; 41 + 42 + /** 43 + * Generate a DPoP proof JWT. 44 + * 45 + * @param array $jwk The JWK to sign with (must include private key 'd'). 46 + * @param string $method The HTTP method (GET, POST, etc.). 47 + * @param string $url The full URL being requested. 48 + * @param string|null $nonce Optional server-provided nonce. 49 + * @param string|null $access_token Optional access token for ath claim. 50 + * @return string|false The DPoP proof JWT or false on failure. 51 + */ 52 + public static function generate_proof( 53 + array $jwk, 54 + string $method, 55 + string $url, 56 + ?string $nonce = null, 57 + ?string $access_token = null, 58 + ): string|false { 59 + try { 60 + $factory = self::get_factory($jwk); 61 + 62 + // Store nonce if provided (for the library to pick up). 63 + if ($nonce !== null) { 64 + self::store_nonce($jwk, $url, $nonce); 65 + } 66 + 67 + // Create the proof. 68 + $bindTo = null; 69 + if ($access_token !== null) { 70 + $thumbprint = self::get_thumbprint($jwk); 71 + $bindTo = new AccessTokenModel($access_token, $thumbprint); 72 + } 73 + 74 + $proof = $factory->createProof( 75 + strtoupper($method), 76 + $url, 77 + self::SUPPORTED_ALGORITHMS, 78 + $bindTo, 79 + ); 80 + 81 + return $proof->proof; 82 + } catch (\Throwable $e) { 83 + error_log("DPoP proof generation failed: " . $e->getMessage()); 84 + return false; 85 + } 86 + } 87 + 88 + /** 89 + * Store a nonce received from a server response. 90 + * 91 + * @param array $jwk The JWK used in the request. 92 + * @param string $url The request URL. 93 + * @param string $nonce The nonce from the response header. 94 + */ 95 + public static function store_nonce(array $jwk, string $url, string $nonce): void 96 + { 97 + $factory = self::get_factory($jwk); 98 + $jwkInterface = $factory->getJwkToBind(self::SUPPORTED_ALGORITHMS); 99 + $factory->storeNextNonce($nonce, $jwkInterface, $url); 100 + } 101 + 102 + /** 103 + * Get or create a DPoPProofFactory for the given JWK. 104 + * 105 + * @param array $jwk The JWK array. 106 + * @return DPoPProofFactory The factory instance. 107 + */ 108 + private static function get_factory(array $jwk): DPoPProofFactory 109 + { 110 + $key = md5(wp_json_encode($jwk)); 111 + 112 + if (!isset(self::$factories[$key])) { 113 + $jwtJwk = new JWK($jwk); 114 + $algorithmManager = new AlgorithmManager([new ES256()]); 115 + $encoder = new WebTokenFrameworkDPoPTokenEncoder($jwtJwk, $algorithmManager); 116 + $nonceStorage = new NonceStorage(); 117 + $clock = new NativeClock(); 118 + 119 + self::$factories[$key] = new DPoPProofFactory( 120 + $clock, 121 + $encoder, 122 + $nonceStorage, 123 + ); 124 + } 125 + 126 + return self::$factories[$key]; 127 + } 128 + 129 + /** 130 + * Get the JWK thumbprint. 131 + * 132 + * @param array $jwk The JWK array. 133 + * @return string The thumbprint. 134 + */ 135 + private static function get_thumbprint(array $jwk): string 136 + { 137 + $key = md5(wp_json_encode($jwk)); 138 + 139 + if (!isset(self::$thumbprints[$key])) { 140 + $jwtJwk = new JWK($jwk); 141 + self::$thumbprints[$key] = $jwtJwk->thumbprint("sha256"); 142 + } 143 + 144 + return self::$thumbprints[$key]; 145 + } 146 + 147 + }
+397
includes/Document.php
··· 1 + <?php 2 + declare(strict_types=1); 3 + /** 4 + * Manages site.standard.document records for WordPress posts/pages. 5 + * 6 + * @package Wireservice 7 + */ 8 + 9 + namespace Wireservice; 10 + 11 + class Document 12 + { 13 + /** 14 + * The lexicon NSID for documents. 15 + * 16 + * @var string 17 + */ 18 + public const LEXICON = "site.standard.document"; 19 + 20 + /** 21 + * Post meta key for storing the AT-URI. 22 + * 23 + * @var string 24 + */ 25 + public const META_KEY_URI = "_wireservice_document_uri"; 26 + 27 + /** 28 + * Constructor. 29 + */ 30 + public function __construct( 31 + private API $api, 32 + private Publication $publication, 33 + ) {} 34 + 35 + /** 36 + * Build a document record from a WordPress post. 37 + * 38 + * @param \WP_Post $post The WordPress post. 39 + * @return array The document record. 40 + */ 41 + public function build_record(\WP_Post $post): array 42 + { 43 + $publication_uri = $this->publication->get_at_uri(); 44 + $publication_data = $this->publication->get_publication_data(); 45 + 46 + // Use AT-URI if available, otherwise fall back to publication URL. 47 + $site = $publication_uri ?: rtrim($publication_data["url"], "/"); 48 + 49 + $record = [ 50 + '$type' => self::LEXICON, 51 + "site" => $site, 52 + "title" => mb_substr($this->get_document_title($post), 0, 5000), 53 + "publishedAt" => get_the_date("c", $post), 54 + ]; 55 + 56 + // Add path (relative URL). 57 + $permalink = get_permalink($post); 58 + $home_url = home_url(); 59 + if (strpos($permalink, $home_url) === 0) { 60 + $path = substr($permalink, strlen($home_url)); 61 + if (!empty($path) && $path[0] !== "/") { 62 + $path = "/" . $path; 63 + } 64 + if (!empty($path)) { 65 + $record["path"] = $path; 66 + } 67 + } 68 + 69 + // Add description based on configured source. 70 + $description = $this->get_document_description($post); 71 + if (!empty($description)) { 72 + $record["description"] = mb_substr($description, 0, 30000); 73 + } 74 + 75 + // Add plain text content if enabled. 76 + if ($this->should_include_content($post)) { 77 + $content = $post->post_content; 78 + if (!empty($content)) { 79 + $text_content = wp_strip_all_tags(strip_shortcodes($content)); 80 + $text_content = html_entity_decode( 81 + $text_content, 82 + ENT_QUOTES, 83 + "UTF-8", 84 + ); 85 + $text_content = preg_replace("/\s+/", " ", $text_content); 86 + $text_content = trim($text_content); 87 + if (!empty($text_content)) { 88 + $record["textContent"] = $text_content; 89 + } 90 + } 91 + } 92 + 93 + // Add tags from post tags and categories. 94 + $tags = $this->get_post_tags($post); 95 + if (!empty($tags)) { 96 + $record["tags"] = $tags; 97 + } 98 + 99 + // Add cover image if configured. 100 + $cover_image = $this->get_document_cover_image($post); 101 + if ($cover_image) { 102 + $record["coverImage"] = $cover_image; 103 + } 104 + 105 + // Add updatedAt if the post was modified after publication. 106 + $published = strtotime($post->post_date_gmt); 107 + $modified = strtotime($post->post_modified_gmt); 108 + if ($modified > $published) { 109 + $record["updatedAt"] = get_the_modified_date("c", $post); 110 + } 111 + 112 + return $record; 113 + } 114 + 115 + /** 116 + * Get tags from a post (combines post tags and categories). 117 + * 118 + * @param \WP_Post $post The WordPress post. 119 + * @return array Array of tag strings. 120 + */ 121 + private function get_post_tags(\WP_Post $post): array 122 + { 123 + $tags = []; 124 + 125 + // Get post tags. 126 + $post_tags = get_the_tags($post->ID); 127 + if ($post_tags && !is_wp_error($post_tags)) { 128 + foreach ($post_tags as $tag) { 129 + $tags[] = mb_substr($tag->name, 0, 1280); 130 + } 131 + } 132 + 133 + // Get categories (excluding "Uncategorized"). 134 + $categories = get_the_category($post->ID); 135 + if ($categories && !is_wp_error($categories)) { 136 + foreach ($categories as $category) { 137 + if (strtolower($category->name) !== "uncategorized") { 138 + $tags[] = mb_substr($category->name, 0, 1280); 139 + } 140 + } 141 + } 142 + 143 + return array_unique($tags); 144 + } 145 + 146 + /** 147 + * Check whether full content should be included for this post. 148 + * 149 + * @param \WP_Post $post The WordPress post. 150 + * @return bool True if full content should be included. 151 + */ 152 + private function should_include_content(\WP_Post $post): bool 153 + { 154 + $override = get_post_meta($post->ID, "_wireservice_include_content", true); 155 + $doc = SourceOptions::get_doc_settings(); 156 + $value = $override !== "" ? $override : $doc["include_content"]; 157 + 158 + return $value === "1"; 159 + } 160 + 161 + /** 162 + * Get the document title based on configured source and per-post override. 163 + * 164 + * @param \WP_Post $post The WordPress post. 165 + * @return string The document title. 166 + */ 167 + private function get_document_title(\WP_Post $post): string 168 + { 169 + // Check for per-post override. 170 + $override = get_post_meta($post->ID, "_wireservice_title_source", true); 171 + $doc = SourceOptions::get_doc_settings(); 172 + $source = !empty($override) ? $override : $doc["title_source"]; 173 + 174 + $title = match ($source) { 175 + "yoast_title" => Yoast::get_post_title($post->ID), 176 + "yoast_social_title" => Yoast::get_post_social_title($post->ID), 177 + "yoast_x_title" => Yoast::get_post_x_title($post->ID), 178 + "custom" => get_post_meta($post->ID, "_wireservice_custom_title", true), 179 + default => get_the_title($post), 180 + }; 181 + 182 + return $title ?: get_the_title($post); 183 + } 184 + 185 + /** 186 + * Get the document description based on configured source and per-post override. 187 + * 188 + * @param \WP_Post $post The WordPress post. 189 + * @return string The document description. 190 + */ 191 + private function get_document_description(\WP_Post $post): string 192 + { 193 + // Check for per-post override. 194 + $override = get_post_meta($post->ID, "_wireservice_description_source", true); 195 + $doc = SourceOptions::get_doc_settings(); 196 + $source = !empty($override) ? $override : $doc["description_source"]; 197 + 198 + $description = match ($source) { 199 + "yoast_description" => Yoast::get_post_description($post->ID), 200 + "yoast_social_description" => Yoast::get_post_social_description($post->ID), 201 + "yoast_x_description" => Yoast::get_post_x_description($post->ID), 202 + "custom" => get_post_meta($post->ID, "_wireservice_custom_description", true), 203 + default => get_the_excerpt($post), 204 + }; 205 + 206 + return $description ?: ""; 207 + } 208 + 209 + /** 210 + * Get the document cover image blob reference based on configured source. 211 + * 212 + * @param \WP_Post $post The WordPress post. 213 + * @return array|null The blob reference or null. 214 + */ 215 + private function get_document_cover_image(\WP_Post $post): ?array 216 + { 217 + // Check for per-post override. 218 + $override = get_post_meta($post->ID, "_wireservice_image_source", true); 219 + $doc = SourceOptions::get_doc_settings(); 220 + $source = !empty($override) ? $override : $doc["image_source"]; 221 + 222 + if ($source === "none") { 223 + return null; 224 + } 225 + 226 + $attachment_id = match ($source) { 227 + "yoast_social_image" => Yoast::get_post_social_image_id($post->ID), 228 + "yoast_x_image" => Yoast::get_post_x_image_id($post->ID), 229 + "custom" => (int) get_post_meta($post->ID, "_wireservice_custom_image_id", true) ?: null, 230 + default => (int) get_post_thumbnail_id($post->ID) ?: null, 231 + }; 232 + 233 + if (empty($attachment_id)) { 234 + return null; 235 + } 236 + 237 + // Get the file path and MIME type. 238 + $file_path = get_attached_file($attachment_id); 239 + $mime_type = get_post_mime_type($attachment_id); 240 + 241 + if (empty($file_path) || !file_exists($file_path)) { 242 + return null; 243 + } 244 + 245 + // Check file size (must be less than 1MB). 246 + $file_size = filesize($file_path); 247 + 248 + if ($file_size > 1000000) { 249 + return null; 250 + } 251 + 252 + // Upload the blob. 253 + $blob_response = $this->api->upload_blob($file_path, $mime_type); 254 + 255 + if (is_wp_error($blob_response)) { 256 + return null; 257 + } 258 + 259 + if (empty($blob_response["blob"])) { 260 + return null; 261 + } 262 + 263 + return $blob_response["blob"]; 264 + } 265 + 266 + /** 267 + * Get the stored AT-URI for a post's document record. 268 + * 269 + * @param int $post_id The WordPress post ID. 270 + * @return string|null The AT-URI or null. 271 + */ 272 + public function get_at_uri(int $post_id): ?string 273 + { 274 + $uri = get_post_meta($post_id, self::META_KEY_URI, true); 275 + return !empty($uri) ? $uri : null; 276 + } 277 + 278 + /** 279 + * Save the AT-URI for a post's document record. 280 + * 281 + * @param int $post_id The WordPress post ID. 282 + * @param string $uri The AT-URI. 283 + * @return bool 284 + */ 285 + public function save_at_uri(int $post_id, string $uri): bool 286 + { 287 + return (bool) update_post_meta($post_id, self::META_KEY_URI, $uri); 288 + } 289 + 290 + /** 291 + * Delete the AT-URI for a post's document record. 292 + * 293 + * @param int $post_id The WordPress post ID. 294 + * @return bool 295 + */ 296 + public function delete_at_uri(int $post_id): bool 297 + { 298 + return delete_post_meta($post_id, self::META_KEY_URI); 299 + } 300 + 301 + /** 302 + * Sync a WordPress post to ATProto as a document. 303 + * 304 + * @param int|\WP_Post $post The post ID or WP_Post object. 305 + * @return array|\WP_Error The response or error. 306 + */ 307 + public function sync_to_atproto($post) 308 + { 309 + if (is_int($post)) { 310 + $post = get_post($post); 311 + } 312 + 313 + if (!$post || !($post instanceof \WP_Post)) { 314 + return new \WP_Error( 315 + "invalid_post", 316 + __("Invalid post.", "wireservice"), 317 + ); 318 + } 319 + 320 + // Only sync published posts. 321 + if ($post->post_status !== "publish") { 322 + return new \WP_Error( 323 + "not_published", 324 + __("Post is not published.", "wireservice"), 325 + ); 326 + } 327 + 328 + $record = $this->build_record($post); 329 + $existing_uri = $this->get_at_uri($post->ID); 330 + 331 + if ($existing_uri) { 332 + // Update existing record. 333 + $rkey = AtUri::get_rkey($existing_uri); 334 + $response = $this->api->put_record(self::LEXICON, $rkey, $record); 335 + } else { 336 + // Create new record. 337 + $response = $this->api->create_record(self::LEXICON, $record); 338 + 339 + if (!is_wp_error($response) && !empty($response["uri"])) { 340 + $this->save_at_uri($post->ID, $response["uri"]); 341 + } 342 + } 343 + 344 + return $response; 345 + } 346 + 347 + /** 348 + * Delete a document record from ATProto. 349 + * 350 + * @param int $post_id The WordPress post ID. 351 + * @return array|\WP_Error|null The response, error, or null if no record exists. 352 + */ 353 + public function delete_from_atproto(int $post_id) 354 + { 355 + $existing_uri = $this->get_at_uri($post_id); 356 + 357 + if (!$existing_uri) { 358 + return null; 359 + } 360 + 361 + $rkey = AtUri::get_rkey($existing_uri); 362 + $response = $this->api->delete_record(self::LEXICON, $rkey); 363 + 364 + if (!is_wp_error($response)) { 365 + $this->delete_at_uri($post_id); 366 + } 367 + 368 + return $response; 369 + } 370 + 371 + /** 372 + * Check if a post should be synced to ATProto. 373 + * 374 + * @param \WP_Post $post The WordPress post. 375 + * @return bool 376 + */ 377 + public function should_sync(\WP_Post $post): bool 378 + { 379 + // Only sync posts and pages by default. 380 + $allowed_types = apply_filters("wireservice_syncable_post_types", [ 381 + "post", 382 + "page", 383 + ]); 384 + 385 + if (!in_array($post->post_type, $allowed_types, true)) { 386 + return false; 387 + } 388 + 389 + // Only sync published content. 390 + if ($post->post_status !== "publish") { 391 + return false; 392 + } 393 + 394 + // Allow filtering. 395 + return apply_filters("wireservice_should_sync_post", true, $post); 396 + } 397 + }
+69
includes/Encryption.php
··· 1 + <?php 2 + declare(strict_types=1); 3 + /** 4 + * Token encryption at rest using libsodium. 5 + * 6 + * @package Wireservice 7 + */ 8 + 9 + namespace Wireservice; 10 + 11 + class Encryption 12 + { 13 + /** 14 + * Derive an encryption key from WordPress auth constants. 15 + * 16 + * @return string 32-byte key. 17 + */ 18 + private static function get_key(): string 19 + { 20 + return sodium_crypto_generichash( 21 + AUTH_KEY . AUTH_SALT, 22 + "", 23 + SODIUM_CRYPTO_SECRETBOX_KEYBYTES, 24 + ); 25 + } 26 + 27 + /** 28 + * Encrypt a plaintext string. 29 + * 30 + * @param string $plaintext The value to encrypt. 31 + * @return string Base64-encoded nonce + ciphertext. 32 + */ 33 + public static function encrypt(string $plaintext): string 34 + { 35 + $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); 36 + $ciphertext = sodium_crypto_secretbox($plaintext, $nonce, self::get_key()); 37 + 38 + return base64_encode($nonce . $ciphertext); 39 + } 40 + 41 + /** 42 + * Decrypt a previously encrypted string. 43 + * 44 + * Returns false if decryption fails, which also covers the case 45 + * where a plaintext (never-encrypted) value is passed in. 46 + * 47 + * @param string $encoded Base64-encoded nonce + ciphertext. 48 + * @return string|false The plaintext or false on failure. 49 + */ 50 + public static function decrypt(string $encoded): string|false 51 + { 52 + $decoded = base64_decode($encoded, true); 53 + 54 + if ($decoded === false) { 55 + return false; 56 + } 57 + 58 + $nonce_length = SODIUM_CRYPTO_SECRETBOX_NONCEBYTES; 59 + 60 + if (strlen($decoded) < $nonce_length + 1) { 61 + return false; 62 + } 63 + 64 + $nonce = substr($decoded, 0, $nonce_length); 65 + $ciphertext = substr($decoded, $nonce_length); 66 + 67 + return sodium_crypto_secretbox_open($ciphertext, $nonce, self::get_key()); 68 + } 69 + }
+242
includes/Endpoints/ConnectionsController.php
··· 1 + <?php 2 + declare(strict_types=1); 3 + /** 4 + * REST API controller for connections. 5 + * 6 + * @package Wireservice 7 + */ 8 + 9 + namespace Wireservice\Endpoints; 10 + 11 + use Wireservice\ConnectionsManager; 12 + use Wireservice\API; 13 + use WP_REST_Controller; 14 + use WP_REST_Server; 15 + use WP_REST_Request; 16 + use WP_REST_Response; 17 + use WP_Error; 18 + 19 + class ConnectionsController extends WP_REST_Controller { 20 + 21 + /** 22 + * Namespace for the REST API. 23 + * 24 + * @var string 25 + */ 26 + protected $namespace = 'wireservice/v1'; 27 + 28 + /** 29 + * Resource name. 30 + * 31 + * @var string 32 + */ 33 + protected $rest_base = 'connection'; 34 + 35 + /** 36 + * Constructor. 37 + */ 38 + public function __construct( 39 + private ConnectionsManager $connections_manager, 40 + private API $api, 41 + ) {} 42 + 43 + /** 44 + * Register routes. 45 + * 46 + * @return void 47 + */ 48 + public function register_routes(): void { 49 + register_rest_route( 50 + $this->namespace, 51 + '/' . $this->rest_base, 52 + array( 53 + array( 54 + 'methods' => WP_REST_Server::READABLE, 55 + 'callback' => array( $this, 'get_item' ), 56 + 'permission_callback' => array( $this, 'get_item_permissions_check' ), 57 + ), 58 + array( 59 + 'methods' => WP_REST_Server::DELETABLE, 60 + 'callback' => array( $this, 'delete_item' ), 61 + 'permission_callback' => array( $this, 'delete_item_permissions_check' ), 62 + ), 63 + 'schema' => array( $this, 'get_public_item_schema' ), 64 + ) 65 + ); 66 + 67 + register_rest_route( 68 + $this->namespace, 69 + '/' . $this->rest_base . '/authorize-url', 70 + array( 71 + array( 72 + 'methods' => WP_REST_Server::READABLE, 73 + 'callback' => array( $this, 'get_authorize_url' ), 74 + 'permission_callback' => array( $this, 'get_item_permissions_check' ), 75 + ), 76 + ) 77 + ); 78 + 79 + register_rest_route( 80 + $this->namespace, 81 + '/' . $this->rest_base . '/session', 82 + array( 83 + array( 84 + 'methods' => WP_REST_Server::READABLE, 85 + 'callback' => array( $this, 'get_session' ), 86 + 'permission_callback' => array( $this, 'get_item_permissions_check' ), 87 + ), 88 + ) 89 + ); 90 + } 91 + 92 + /** 93 + * Check if a given request has access to get connection. 94 + * 95 + * @param WP_REST_Request $request Full details about the request. 96 + * @return true|WP_Error 97 + */ 98 + public function get_item_permissions_check( $request ) { 99 + if ( ! current_user_can( 'manage_options' ) ) { 100 + return new WP_Error( 101 + 'rest_forbidden', 102 + __( 'You do not have permission to access this resource.', 'wireservice' ), 103 + array( 'status' => 403 ) 104 + ); 105 + } 106 + 107 + return true; 108 + } 109 + 110 + /** 111 + * Check if a given request has access to delete connection. 112 + * 113 + * @param WP_REST_Request $request Full details about the request. 114 + * @return true|WP_Error 115 + */ 116 + public function delete_item_permissions_check( $request ) { 117 + return $this->get_item_permissions_check( $request ); 118 + } 119 + 120 + /** 121 + * Get the connection status and info. 122 + * 123 + * @param WP_REST_Request $request Full details about the request. 124 + * @return WP_REST_Response|WP_Error 125 + */ 126 + public function get_item( $request ) { 127 + $connection = $this->connections_manager->get_connection(); 128 + 129 + $data = array( 130 + 'connected' => ! empty( $connection['access_token'] ), 131 + 'handle' => $connection['handle'] ?? null, 132 + 'did' => $connection['did'] ?? null, 133 + ); 134 + 135 + return rest_ensure_response( $data ); 136 + } 137 + 138 + /** 139 + * Delete the connection. 140 + * 141 + * @param WP_REST_Request $request Full details about the request. 142 + * @return WP_REST_Response|WP_Error 143 + */ 144 + public function delete_item( $request ) { 145 + delete_option( 'wireservice_connection' ); 146 + 147 + return rest_ensure_response( 148 + array( 149 + 'deleted' => true, 150 + ) 151 + ); 152 + } 153 + 154 + /** 155 + * Get the authorize URL. 156 + * 157 + * @param WP_REST_Request $request Full details about the request. 158 + * @return WP_REST_Response|WP_Error 159 + */ 160 + public function get_authorize_url( $request ) { 161 + $url = $this->connections_manager->get_authorize_url(); 162 + 163 + if ( empty( $url ) ) { 164 + return new WP_Error( 165 + 'authorize_url_failed', 166 + __( 'Failed to generate authorization URL.', 'wireservice' ), 167 + array( 'status' => 500 ) 168 + ); 169 + } 170 + 171 + return rest_ensure_response( 172 + array( 173 + 'url' => $url, 174 + ) 175 + ); 176 + } 177 + 178 + /** 179 + * Get session info from the API. 180 + * 181 + * @param WP_REST_Request $request Full details about the request. 182 + * @return WP_REST_Response|WP_Error 183 + */ 184 + public function get_session( $request ) { 185 + if ( ! $this->connections_manager->is_connected() ) { 186 + return new WP_Error( 187 + 'not_connected', 188 + __( 'Not connected to AT Protocol.', 'wireservice' ), 189 + array( 'status' => 400 ) 190 + ); 191 + } 192 + 193 + $session = $this->api->get_session(); 194 + 195 + if ( is_wp_error( $session ) ) { 196 + return $session; 197 + } 198 + 199 + // Only return non-sensitive session fields. 200 + return rest_ensure_response( array( 201 + 'handle' => $session['handle'] ?? null, 202 + 'did' => $session['did'] ?? null, 203 + 'pds_endpoint' => $session['pds_endpoint'] ?? null, 204 + ) ); 205 + } 206 + 207 + /** 208 + * Get the item schema. 209 + * 210 + * @return array 211 + */ 212 + public function get_item_schema(): array { 213 + if ( $this->schema ) { 214 + return $this->add_additional_fields_schema( $this->schema ); 215 + } 216 + 217 + $this->schema = array( 218 + '$schema' => 'http://json-schema.org/draft-04/schema#', 219 + 'title' => 'wireservice-connection', 220 + 'type' => 'object', 221 + 'properties' => array( 222 + 'connected' => array( 223 + 'description' => __( 'Whether connected to AT Protocol.', 'wireservice' ), 224 + 'type' => 'boolean', 225 + 'readonly' => true, 226 + ), 227 + 'handle' => array( 228 + 'description' => __( 'The connected account handle.', 'wireservice' ), 229 + 'type' => array( 'string', 'null' ), 230 + 'readonly' => true, 231 + ), 232 + 'did' => array( 233 + 'description' => __( 'The connected account DID.', 'wireservice' ), 234 + 'type' => array( 'string', 'null' ), 235 + 'readonly' => true, 236 + ), 237 + ), 238 + ); 239 + 240 + return $this->add_additional_fields_schema( $this->schema ); 241 + } 242 + }
+62
includes/NonceStorage.php
··· 1 + <?php 2 + declare(strict_types=1); 3 + /** 4 + * DPoP nonce storage using WordPress transients. 5 + * 6 + * @package Wireservice 7 + */ 8 + 9 + namespace Wireservice; 10 + 11 + use danielburger1337\OAuth2\DPoP\NonceStorage\NonceStorageInterface; 12 + 13 + class NonceStorage implements NonceStorageInterface 14 + { 15 + /** 16 + * Transient prefix. 17 + * 18 + * @var string 19 + */ 20 + private const PREFIX = "wireservice_dpop_nonce_"; 21 + 22 + /** 23 + * Nonce TTL in seconds. 24 + * 25 + * @var int 26 + */ 27 + private const TTL = 300; 28 + 29 + /** 30 + * Get the current DPoP-Nonce for an upstream server. 31 + * 32 + * @param string $key The storage key. 33 + * @return string|null The nonce or null. 34 + */ 35 + public function getCurrentNonce(string $key): ?string 36 + { 37 + $nonce = get_transient(self::PREFIX . $this->hashKey($key)); 38 + return $nonce !== false ? $nonce : null; 39 + } 40 + 41 + /** 42 + * Store a new DPoP-Nonce from an upstream server. 43 + * 44 + * @param string $key The storage key. 45 + * @param string $nonce The nonce to store. 46 + */ 47 + public function storeNextNonce(string $key, string $nonce): void 48 + { 49 + set_transient(self::PREFIX . $this->hashKey($key), $nonce, self::TTL); 50 + } 51 + 52 + /** 53 + * Hash the key to a safe transient name. 54 + * 55 + * @param string $key The storage key. 56 + * @return string The hashed key. 57 + */ 58 + private function hashKey(string $key): string 59 + { 60 + return substr(md5($key), 0, 32); 61 + } 62 + }
+255
includes/Publication.php
··· 1 + <?php 2 + declare(strict_types=1); 3 + /** 4 + * Manages site.standard.publication records. 5 + * 6 + * @package Wireservice 7 + */ 8 + 9 + namespace Wireservice; 10 + 11 + class Publication 12 + { 13 + /** 14 + * The lexicon NSID for publications. 15 + * 16 + * @var string 17 + */ 18 + public const LEXICON = "site.standard.publication"; 19 + 20 + /** 21 + * Constructor. 22 + */ 23 + public function __construct(private API $api) {} 24 + 25 + /** 26 + * Get the publication data from WordPress options. 27 + * 28 + * @return array 29 + */ 30 + public function get_publication_data(): array 31 + { 32 + $default = [ 33 + "url" => home_url(), 34 + "name" => get_bloginfo("name"), 35 + "description" => get_bloginfo("description"), 36 + "icon_attachment_id" => 0, 37 + "theme_background" => "", 38 + "theme_foreground" => "", 39 + "theme_accent" => "", 40 + "theme_accent_foreground" => "", 41 + "show_in_discover" => "", 42 + ]; 43 + $data = get_option("wireservice_publication", $default); 44 + 45 + return is_array($data) ? wp_parse_args($data, $default) : $default; 46 + } 47 + 48 + /** 49 + * Save publication data to WordPress options. 50 + * 51 + * @param array $data Publication data. 52 + * @return bool 53 + */ 54 + public function save_publication_data(array $data): bool 55 + { 56 + $sanitized = [ 57 + "url" => esc_url_raw($data["url"] ?? home_url()), 58 + "name" => sanitize_text_field($data["name"] ?? ""), 59 + "description" => sanitize_textarea_field($data["description"] ?? ""), 60 + "icon_attachment_id" => absint($data["icon_attachment_id"] ?? 0), 61 + "theme_background" => sanitize_hex_color($data["theme_background"] ?? "") ?: "", 62 + "theme_foreground" => sanitize_hex_color($data["theme_foreground"] ?? "") ?: "", 63 + "theme_accent" => sanitize_hex_color($data["theme_accent"] ?? "") ?: "", 64 + "theme_accent_foreground" => sanitize_hex_color($data["theme_accent_foreground"] ?? "") ?: "", 65 + "show_in_discover" => sanitize_text_field($data["show_in_discover"] ?? ""), 66 + ]; 67 + 68 + return update_option("wireservice_publication", $sanitized); 69 + } 70 + 71 + /** 72 + * Get the stored AT-URI for the publication record. 73 + * 74 + * @return string|null 75 + */ 76 + public function get_at_uri(): ?string 77 + { 78 + return get_option("wireservice_publication_uri", null); 79 + } 80 + 81 + /** 82 + * Save the AT-URI for the publication record. 83 + * 84 + * @param string $uri The AT-URI. 85 + * @return bool 86 + */ 87 + public function save_at_uri(string $uri): bool 88 + { 89 + return update_option("wireservice_publication_uri", $uri); 90 + } 91 + 92 + /** 93 + * Create or update the publication record on ATProto. 94 + * 95 + * @return array|\WP_Error The response or error. 96 + */ 97 + public function sync_to_atproto(?array $data = null) 98 + { 99 + if ($data === null) { 100 + $data = $this->get_publication_data(); 101 + } 102 + $existing_uri = $this->get_at_uri(); 103 + 104 + $record = [ 105 + '$type' => self::LEXICON, 106 + "url" => rtrim($data["url"], "/"), 107 + "name" => mb_substr($data["name"], 0, 5000), 108 + ]; 109 + 110 + if (!empty($data["description"])) { 111 + $record["description"] = mb_substr($data["description"], 0, 30000); 112 + } 113 + 114 + // Add icon blob if an attachment is configured. 115 + $icon_blob = $this->upload_icon($data["icon_attachment_id"] ?? 0); 116 + if ($icon_blob) { 117 + $record["icon"] = $icon_blob; 118 + } 119 + 120 + // Add basicTheme if all 4 colors are set. 121 + $theme = $this->build_basic_theme($data); 122 + if ($theme) { 123 + $record["basicTheme"] = $theme; 124 + } 125 + 126 + // Add preferences if showInDiscover has been explicitly set. 127 + if ($data["show_in_discover"] !== "") { 128 + $record["preferences"] = [ 129 + "showInDiscover" => $data["show_in_discover"] === "1", 130 + ]; 131 + } 132 + 133 + if ($existing_uri) { 134 + $rkey = AtUri::get_rkey($existing_uri); 135 + $response = $this->api->put_record(self::LEXICON, $rkey, $record); 136 + } else { 137 + $response = $this->api->create_record(self::LEXICON, $record); 138 + 139 + if (!is_wp_error($response) && !empty($response["uri"])) { 140 + $this->save_at_uri($response["uri"]); 141 + } 142 + } 143 + 144 + return $response; 145 + } 146 + 147 + /** 148 + * Upload an icon image as a blob. 149 + * 150 + * @param int $attachment_id The WordPress attachment ID. 151 + * @return array|null The blob reference or null. 152 + */ 153 + private function upload_icon(int $attachment_id): ?array 154 + { 155 + if (empty($attachment_id)) { 156 + return null; 157 + } 158 + 159 + $file_path = get_attached_file($attachment_id); 160 + $mime_type = get_post_mime_type($attachment_id); 161 + 162 + if (empty($file_path) || !file_exists($file_path)) { 163 + return null; 164 + } 165 + 166 + // Icons must be less than 1MB. 167 + if (filesize($file_path) > 1000000) { 168 + return null; 169 + } 170 + 171 + $blob_response = $this->api->upload_blob($file_path, $mime_type); 172 + 173 + if (is_wp_error($blob_response) || empty($blob_response["blob"])) { 174 + return null; 175 + } 176 + 177 + return $blob_response["blob"]; 178 + } 179 + 180 + /** 181 + * Build a basicTheme record from hex color values. 182 + * 183 + * Returns null if any of the 4 required colors is missing. 184 + * 185 + * @param array $data Publication data with theme_* keys. 186 + * @return array|null The basicTheme record or null. 187 + */ 188 + private function build_basic_theme(array $data): ?array 189 + { 190 + $keys = [ 191 + "background" => "theme_background", 192 + "foreground" => "theme_foreground", 193 + "accent" => "theme_accent", 194 + "accentForeground" => "theme_accent_foreground", 195 + ]; 196 + 197 + $colors = []; 198 + foreach ($keys as $field => $data_key) { 199 + $hex = $data[$data_key] ?? ""; 200 + if (empty($hex)) { 201 + return null; 202 + } 203 + $colors[$field] = self::hex_to_rgb($hex); 204 + } 205 + 206 + return [ 207 + '$type' => "site.standard.theme.basic", 208 + "background" => $colors["background"], 209 + "foreground" => $colors["foreground"], 210 + "accent" => $colors["accent"], 211 + "accentForeground" => $colors["accentForeground"], 212 + ]; 213 + } 214 + 215 + /** 216 + * Convert a hex color string to an RGB array. 217 + * 218 + * @param string $hex Hex color (e.g. "#ff0000"). 219 + * @return array RGB array with $type, r, g, b keys. 220 + */ 221 + private static function hex_to_rgb(string $hex): array 222 + { 223 + $hex = ltrim($hex, "#"); 224 + return [ 225 + '$type' => "site.standard.theme.color#rgb", 226 + "r" => (int) hexdec(substr($hex, 0, 2)), 227 + "g" => (int) hexdec(substr($hex, 2, 2)), 228 + "b" => (int) hexdec(substr($hex, 4, 2)), 229 + ]; 230 + } 231 + 232 + /** 233 + * Delete the publication record from ATProto. 234 + * 235 + * @return array|\WP_Error|null The response, error, or null if no record exists. 236 + */ 237 + public function delete_from_atproto() 238 + { 239 + $existing_uri = $this->get_at_uri(); 240 + 241 + if (!$existing_uri) { 242 + return null; 243 + } 244 + 245 + $rkey = AtUri::get_rkey($existing_uri); 246 + $response = $this->api->delete_record(self::LEXICON, $rkey); 247 + 248 + if (!is_wp_error($response)) { 249 + delete_option("wireservice_publication_uri"); 250 + } 251 + 252 + return $response; 253 + } 254 + 255 + }
+456
includes/Setup.php
··· 1 + <?php 2 + declare(strict_types=1); 3 + /** 4 + * Plugin setup and initialization. 5 + * 6 + * @package Wireservice 7 + */ 8 + 9 + namespace Wireservice; 10 + 11 + class Setup 12 + { 13 + private ConnectionsManager $connections_manager; 14 + private API $api; 15 + private Publication $publication; 16 + private Document $document; 17 + 18 + /** 19 + * Initialize the plugin. 20 + * 21 + * @return void 22 + */ 23 + public function init(): void 24 + { 25 + $this->connections_manager = new ConnectionsManager(); 26 + $this->api = new API($this->connections_manager); 27 + $this->publication = new Publication($this->api); 28 + $this->document = new Document($this->api, $this->publication); 29 + $this->register_hooks(); 30 + } 31 + 32 + /** 33 + * Register all hooks and initialize components. 34 + * 35 + * @return void 36 + */ 37 + private function register_hooks(): void 38 + { 39 + $admin = new Admin( 40 + $this->connections_manager, 41 + $this->api, 42 + $this->publication, 43 + ); 44 + $admin->init(); 45 + 46 + // Initialize connections manager. 47 + $this->connections_manager->init(); 48 + 49 + // Register REST API endpoints. 50 + add_action("rest_api_init", [$this, "register_rest_routes"]); 51 + 52 + // Register .well-known endpoint. 53 + add_action("init", [$this, "register_well_known_rewrite"]); 54 + add_action("template_redirect", [$this, "handle_well_known_request"]); 55 + add_filter("query_vars", [$this, "add_query_vars"]); 56 + 57 + // Document sync hooks. 58 + // Use wp_after_insert_post (WP 5.6+) to ensure all meta (including Yoast) is saved first. 59 + add_action("wp_after_insert_post", [$this, "maybe_sync_document"], 10, 2); 60 + add_action("before_delete_post", [$this, "maybe_delete_document"]); 61 + add_action( 62 + "transition_post_status", 63 + [$this, "handle_status_transition"], 64 + 10, 65 + 3, 66 + ); 67 + 68 + // Add verification link to document head. 69 + add_action("wp_head", [$this, "output_document_verification_link"]); 70 + 71 + // Add meta box for per-post overrides. 72 + add_action("add_meta_boxes", [$this, "add_document_meta_box"]); 73 + add_action("save_post", [$this, "save_document_meta_box"], 10, 2); 74 + add_action("admin_enqueue_scripts", [$this, "enqueue_meta_box_assets"]); 75 + } 76 + 77 + /** 78 + * Register REST API routes. 79 + * 80 + * @return void 81 + */ 82 + public function register_rest_routes(): void 83 + { 84 + $connections_controller = new Endpoints\ConnectionsController( 85 + $this->connections_manager, 86 + $this->api, 87 + ); 88 + $connections_controller->register_routes(); 89 + } 90 + 91 + /** 92 + * Register rewrite rule for .well-known/site.standard.publication. 93 + * 94 + * For non-root sites, appends the site path per the standard.site spec: 95 + * .well-known/site.standard.publication/path/to/site 96 + * 97 + * @return void 98 + */ 99 + public function register_well_known_rewrite(): void 100 + { 101 + $path = wp_parse_url(home_url(), PHP_URL_PATH); 102 + $suffix = ''; 103 + 104 + if (!empty($path) && $path !== '/') { 105 + $suffix = '/' . trim($path, '/'); 106 + } 107 + 108 + add_rewrite_rule( 109 + '^\.well-known/site\.standard\.publication' . preg_quote($suffix) . '$', 110 + "index.php?wireservice_well_known=publication", 111 + "top", 112 + ); 113 + } 114 + 115 + /** 116 + * Add custom query vars. 117 + * 118 + * @param array $vars Existing query vars. 119 + * @return array Modified query vars. 120 + */ 121 + public function add_query_vars(array $vars): array 122 + { 123 + $vars[] = "wireservice_well_known"; 124 + return $vars; 125 + } 126 + 127 + /** 128 + * Handle .well-known request. 129 + * 130 + * @return void 131 + */ 132 + public function handle_well_known_request(): void 133 + { 134 + $well_known = get_query_var("wireservice_well_known"); 135 + 136 + if ("publication" !== $well_known) { 137 + return; 138 + } 139 + 140 + $at_uri = $this->publication->get_at_uri(); 141 + 142 + if (empty($at_uri)) { 143 + status_header(404); 144 + echo "Publication not found"; 145 + exit(); 146 + } 147 + 148 + status_header(200); 149 + header("Content-Type: text/plain; charset=utf-8"); 150 + header("Access-Control-Allow-Origin: *"); 151 + echo esc_html($at_uri); 152 + exit(); 153 + } 154 + 155 + /** 156 + * Maybe sync a document when a post is saved. 157 + * 158 + * @param int $post_id The post ID. 159 + * @param \WP_Post $post The post object. 160 + * @return void 161 + */ 162 + public function maybe_sync_document(int $post_id, \WP_Post $post): void 163 + { 164 + // Bail if autosave or revision. 165 + if (defined("DOING_AUTOSAVE") && DOING_AUTOSAVE) { 166 + return; 167 + } 168 + 169 + if (wp_is_post_revision($post_id)) { 170 + return; 171 + } 172 + 173 + // Bail if not connected. 174 + if (!$this->connections_manager->is_connected()) { 175 + return; 176 + } 177 + 178 + if (!$this->document->should_sync($post)) { 179 + return; 180 + } 181 + 182 + $this->document->sync_to_atproto($post); 183 + } 184 + 185 + /** 186 + * Maybe delete a document when a post is deleted. 187 + * 188 + * @param int $post_id The post ID. 189 + * @return void 190 + */ 191 + public function maybe_delete_document(int $post_id): void 192 + { 193 + // Bail if not connected. 194 + if (!$this->connections_manager->is_connected()) { 195 + return; 196 + } 197 + 198 + $this->document->delete_from_atproto($post_id); 199 + } 200 + 201 + /** 202 + * Handle post status transitions (e.g., publish to draft). 203 + * 204 + * @param string $new_status New post status. 205 + * @param string $old_status Old post status. 206 + * @param \WP_Post $post The post object. 207 + * @return void 208 + */ 209 + public function handle_status_transition( 210 + string $new_status, 211 + string $old_status, 212 + \WP_Post $post, 213 + ): void { 214 + // Bail if not connected. 215 + if (!$this->connections_manager->is_connected()) { 216 + return; 217 + } 218 + 219 + if ($old_status === "publish" && $new_status !== "publish") { 220 + $this->document->delete_from_atproto($post->ID); 221 + } 222 + } 223 + 224 + /** 225 + * Output the document verification link tag in the head. 226 + * 227 + * @return void 228 + */ 229 + public function output_document_verification_link(): void 230 + { 231 + if (!is_singular()) { 232 + return; 233 + } 234 + 235 + $post = get_queried_object(); 236 + 237 + if (!$post instanceof \WP_Post) { 238 + return; 239 + } 240 + 241 + $at_uri = $this->document->get_at_uri($post->ID); 242 + 243 + if (empty($at_uri)) { 244 + return; 245 + } 246 + 247 + echo '<link rel="site.standard.document" href="' . 248 + esc_attr($at_uri) . 249 + '">' . 250 + "\n"; 251 + } 252 + 253 + /** 254 + * Enqueue scripts for the document meta box. 255 + * 256 + * @param string $hook_suffix The current admin page hook suffix. 257 + * @return void 258 + */ 259 + public function enqueue_meta_box_assets(string $hook_suffix): void 260 + { 261 + if (!in_array($hook_suffix, ["post.php", "post-new.php"], true)) { 262 + return; 263 + } 264 + 265 + if (!$this->connections_manager->is_connected()) { 266 + return; 267 + } 268 + 269 + global $post_type; 270 + $syncable_types = apply_filters("wireservice_syncable_post_types", [ 271 + "post", 272 + "page", 273 + ]); 274 + if (!in_array($post_type, $syncable_types, true)) { 275 + return; 276 + } 277 + 278 + wp_enqueue_media(); 279 + 280 + wp_enqueue_script( 281 + "wireservice-meta-box", 282 + WIRESERVICE_PLUGIN_URL . "assets/js/meta-box.js", 283 + ["jquery"], 284 + WIRESERVICE_VERSION, 285 + true, 286 + ); 287 + 288 + wp_localize_script("wireservice-meta-box", "wireserviceMetaBox", [ 289 + "selectImageTitle" => __("Select Cover Image", "wireservice"), 290 + "useImageButton" => __("Use this image", "wireservice"), 291 + ]); 292 + } 293 + 294 + /** 295 + * Add the document settings meta box. 296 + * 297 + * @return void 298 + */ 299 + public function add_document_meta_box(): void 300 + { 301 + if (!$this->connections_manager->is_connected()) { 302 + return; 303 + } 304 + 305 + $post_types = apply_filters("wireservice_syncable_post_types", [ 306 + "post", 307 + "page", 308 + ]); 309 + 310 + foreach ($post_types as $post_type) { 311 + add_meta_box( 312 + "wireservice_document", 313 + __("Wireservice", "wireservice"), 314 + [$this, "render_document_meta_box"], 315 + $post_type, 316 + "side", 317 + "default", 318 + ); 319 + } 320 + } 321 + 322 + /** 323 + * Render the document settings meta box. 324 + * 325 + * @param \WP_Post $post The post object. 326 + * @return void 327 + */ 328 + public function render_document_meta_box(\WP_Post $post): void 329 + { 330 + wp_nonce_field("wireservice_document_meta", "wireservice_document_nonce"); 331 + 332 + $title_source = get_post_meta($post->ID, "_wireservice_title_source", true); 333 + $desc_source = get_post_meta( 334 + $post->ID, 335 + "_wireservice_description_source", 336 + true, 337 + ); 338 + $custom_title = get_post_meta($post->ID, "_wireservice_custom_title", true); 339 + $custom_description = get_post_meta( 340 + $post->ID, 341 + "_wireservice_custom_description", 342 + true, 343 + ); 344 + $custom_image_id = get_post_meta( 345 + $post->ID, 346 + "_wireservice_custom_image_id", 347 + true, 348 + ); 349 + $image_source = get_post_meta($post->ID, "_wireservice_image_source", true); 350 + $include_content = get_post_meta( 351 + $post->ID, 352 + "_wireservice_include_content", 353 + true, 354 + ); 355 + 356 + $doc = SourceOptions::get_doc_settings(); 357 + 358 + $title_sources = SourceOptions::meta_box_title_sources( 359 + $doc["title_source"], 360 + ); 361 + $desc_sources = SourceOptions::meta_box_description_sources( 362 + $doc["description_source"], 363 + ); 364 + $image_sources = SourceOptions::meta_box_image_sources( 365 + $doc["image_source"], 366 + ); 367 + 368 + $global_label = $doc["include_content"] === "1" 369 + ? __("On", "wireservice") 370 + : __("Off", "wireservice"); 371 + 372 + $at_uri = $this->document->get_at_uri($post->ID); 373 + 374 + include WIRESERVICE_PLUGIN_DIR . "templates/document-meta-box.php"; 375 + } 376 + 377 + /** 378 + * Save the document meta box settings. 379 + * 380 + * @param int $post_id The post ID. 381 + * @param \WP_Post $post The post object. 382 + * @return void 383 + */ 384 + public function save_document_meta_box(int $post_id, \WP_Post $post): void 385 + { 386 + if ( 387 + !isset($_POST["wireservice_document_nonce"]) || 388 + !wp_verify_nonce( 389 + sanitize_text_field(wp_unslash($_POST["wireservice_document_nonce"])), 390 + "wireservice_document_meta", 391 + ) 392 + ) { 393 + return; 394 + } 395 + 396 + if (defined("DOING_AUTOSAVE") && DOING_AUTOSAVE) { 397 + return; 398 + } 399 + 400 + if (!current_user_can("edit_post", $post_id)) { 401 + return; 402 + } 403 + 404 + $fields = [ 405 + ["wireservice_title_source", "_wireservice_title_source", "sanitize_text_field"], 406 + ["wireservice_description_source", "_wireservice_description_source", "sanitize_text_field"], 407 + ["wireservice_image_source", "_wireservice_image_source", "sanitize_text_field"], 408 + ["wireservice_custom_title", "_wireservice_custom_title", "sanitize_text_field"], 409 + ["wireservice_custom_description", "_wireservice_custom_description", "sanitize_textarea_field"], 410 + ["wireservice_custom_image_id", "_wireservice_custom_image_id", "absint"], 411 + ]; 412 + 413 + foreach ($fields as [$post_key, $meta_key, $sanitizer]) { 414 + $this->save_meta_field($post_id, $post_key, $meta_key, $sanitizer); 415 + } 416 + 417 + $this->save_meta_field( 418 + $post_id, 419 + "wireservice_include_content", 420 + "_wireservice_include_content", 421 + "sanitize_text_field", 422 + allow_falsy: true, 423 + ); 424 + } 425 + 426 + /** 427 + * Save a single meta box field from POST data. 428 + * 429 + * @param int $post_id The post ID. 430 + * @param string $post_key The $_POST key. 431 + * @param string $meta_key The post meta key. 432 + * @param callable $sanitizer Sanitization function. 433 + * @param bool $allow_falsy Whether to store falsy values like "0". 434 + * @return void 435 + */ 436 + private function save_meta_field( 437 + int $post_id, 438 + string $post_key, 439 + string $meta_key, 440 + callable $sanitizer, 441 + bool $allow_falsy = false, 442 + ): void { 443 + if (!isset($_POST[$post_key])) { 444 + return; 445 + } 446 + 447 + $value = $sanitizer(wp_unslash($_POST[$post_key])); 448 + $is_empty = $allow_falsy ? $value === "" : empty($value); 449 + 450 + if ($is_empty) { 451 + delete_post_meta($post_id, $meta_key); 452 + } else { 453 + update_post_meta($post_id, $meta_key, $value); 454 + } 455 + } 456 + }
+328
includes/SourceOptions.php
··· 1 + <?php 2 + declare(strict_types=1); 3 + /** 4 + * Centralized source option building and grouped settings accessors. 5 + * 6 + * @package Wireservice 7 + */ 8 + 9 + namespace Wireservice; 10 + 11 + class SourceOptions 12 + { 13 + /** 14 + * Default values for publication settings. 15 + * 16 + * @var array 17 + */ 18 + public const PUB_DEFAULTS = [ 19 + "name_source" => "wordpress_title", 20 + "description_source" => "wordpress_tagline", 21 + "custom_name" => "", 22 + "custom_description" => "", 23 + "icon_source" => "none", 24 + "custom_icon_id" => 0, 25 + "theme_background" => "", 26 + "theme_foreground" => "", 27 + "theme_accent" => "", 28 + "theme_accent_foreground" => "", 29 + "show_in_discover" => "", 30 + ]; 31 + 32 + /** 33 + * Default values for document settings. 34 + * 35 + * @var array 36 + */ 37 + public const DOC_DEFAULTS = [ 38 + "enabled" => "0", 39 + "title_source" => "wordpress_title", 40 + "description_source" => "wordpress_excerpt", 41 + "image_source" => "wordpress_featured", 42 + "include_content" => "0", 43 + ]; 44 + 45 + /** 46 + * Get publication settings merged with defaults. 47 + * 48 + * @return array 49 + */ 50 + public static function get_pub_settings(): array 51 + { 52 + return wp_parse_args( 53 + get_option("wireservice_pub_settings", []), 54 + self::PUB_DEFAULTS, 55 + ); 56 + } 57 + 58 + /** 59 + * Get document settings merged with defaults. 60 + * 61 + * @return array 62 + */ 63 + public static function get_doc_settings(): array 64 + { 65 + return wp_parse_args( 66 + get_option("wireservice_doc_settings", []), 67 + self::DOC_DEFAULTS, 68 + ); 69 + } 70 + 71 + /** 72 + * Get publication name source options for the settings page. 73 + * 74 + * @param string $custom_name The current custom name value. 75 + * @return array 76 + */ 77 + public static function pub_name_sources(string $custom_name = ""): array 78 + { 79 + $sources = [ 80 + "wordpress_title" => [ 81 + "label" => __("WordPress Site Title", "wireservice"), 82 + "value" => get_bloginfo("name"), 83 + ], 84 + ]; 85 + 86 + if (Yoast::is_active()) { 87 + $yoast_org = Yoast::get_organization_name(); 88 + if ($yoast_org) { 89 + $sources["yoast_organization"] = [ 90 + "label" => __("Yoast Organization Name", "wireservice"), 91 + "value" => $yoast_org, 92 + ]; 93 + } 94 + $yoast_site = Yoast::get_website_name(); 95 + if ($yoast_site) { 96 + $sources["yoast_website"] = [ 97 + "label" => __("Yoast Website Name", "wireservice"), 98 + "value" => $yoast_site, 99 + ]; 100 + } 101 + } 102 + 103 + $sources["custom"] = [ 104 + "label" => __("Custom", "wireservice"), 105 + "value" => $custom_name, 106 + ]; 107 + 108 + return $sources; 109 + } 110 + 111 + /** 112 + * Get publication description source options for the settings page. 113 + * 114 + * @param string $custom_description The current custom description value. 115 + * @return array 116 + */ 117 + public static function pub_description_sources( 118 + string $custom_description = "", 119 + ): array { 120 + $sources = [ 121 + "wordpress_tagline" => [ 122 + "label" => __("WordPress Tagline", "wireservice"), 123 + "value" => get_bloginfo("description"), 124 + ], 125 + ]; 126 + 127 + if (Yoast::is_active()) { 128 + $yoast_desc = Yoast::get_homepage_description(); 129 + if ($yoast_desc) { 130 + $sources["yoast_homepage"] = [ 131 + "label" => __( 132 + "Yoast Homepage Meta Description", 133 + "wireservice", 134 + ), 135 + "value" => $yoast_desc, 136 + ]; 137 + } 138 + } 139 + 140 + $sources["custom"] = [ 141 + "label" => __("Custom", "wireservice"), 142 + "value" => $custom_description, 143 + ]; 144 + 145 + return $sources; 146 + } 147 + 148 + /** 149 + * Get publication icon source options for the settings page. 150 + * 151 + * @return array 152 + */ 153 + public static function pub_icon_sources(): array 154 + { 155 + $sources = [ 156 + "none" => [ 157 + "label" => __("None (no icon)", "wireservice"), 158 + "value" => "", 159 + ], 160 + ]; 161 + 162 + $site_icon_url = get_site_icon_url(256); 163 + if ($site_icon_url) { 164 + $sources["wordpress_site_icon"] = [ 165 + "label" => __("WordPress Site Icon", "wireservice"), 166 + "value" => $site_icon_url, 167 + ]; 168 + } 169 + 170 + $sources["custom"] = [ 171 + "label" => __("Custom", "wireservice"), 172 + "value" => "", 173 + ]; 174 + 175 + return $sources; 176 + } 177 + 178 + /** 179 + * Get document title source options. 180 + * 181 + * @return array 182 + */ 183 + public static function doc_title_sources(): array 184 + { 185 + $sources = [ 186 + "wordpress_title" => __("WordPress Post Title", "wireservice"), 187 + ]; 188 + 189 + if (Yoast::is_active()) { 190 + $sources["yoast_title"] = __("Yoast SEO Title", "wireservice"); 191 + $sources["yoast_social_title"] = __( 192 + "Yoast Social Title", 193 + "wireservice", 194 + ); 195 + $sources["yoast_x_title"] = __("Yoast X Title", "wireservice"); 196 + } 197 + 198 + return $sources; 199 + } 200 + 201 + /** 202 + * Get document description source options. 203 + * 204 + * @return array 205 + */ 206 + public static function doc_description_sources(): array 207 + { 208 + $sources = [ 209 + "wordpress_excerpt" => __("WordPress Excerpt", "wireservice"), 210 + ]; 211 + 212 + if (Yoast::is_active()) { 213 + $sources["yoast_description"] = __( 214 + "Yoast Meta Description", 215 + "wireservice", 216 + ); 217 + $sources["yoast_social_description"] = __( 218 + "Yoast Social Description", 219 + "wireservice", 220 + ); 221 + $sources["yoast_x_description"] = __( 222 + "Yoast X Description", 223 + "wireservice", 224 + ); 225 + } 226 + 227 + return $sources; 228 + } 229 + 230 + /** 231 + * Get document cover image source options. 232 + * 233 + * @return array 234 + */ 235 + public static function doc_image_sources(): array 236 + { 237 + $sources = [ 238 + "none" => __("None (no cover image)", "wireservice"), 239 + "wordpress_featured" => __( 240 + "WordPress Featured Image", 241 + "wireservice", 242 + ), 243 + ]; 244 + 245 + if (Yoast::is_active()) { 246 + $sources["yoast_social_image"] = __( 247 + "Yoast Social Image", 248 + "wireservice", 249 + ); 250 + $sources["yoast_x_image"] = __("Yoast X Image", "wireservice"); 251 + } 252 + 253 + return $sources; 254 + } 255 + 256 + /** 257 + * Get document title source options for the meta box (with "Use global" option). 258 + * 259 + * @param string $global_source The current global title source key. 260 + * @return array 261 + */ 262 + public static function meta_box_title_sources(string $global_source): array 263 + { 264 + return self::build_meta_box_sources( 265 + self::doc_title_sources(), 266 + $global_source, 267 + __("WordPress Post Title", "wireservice"), 268 + ); 269 + } 270 + 271 + /** 272 + * Get document description source options for the meta box (with "Use global" option). 273 + * 274 + * @param string $global_source The current global description source key. 275 + * @return array 276 + */ 277 + public static function meta_box_description_sources( 278 + string $global_source, 279 + ): array { 280 + return self::build_meta_box_sources( 281 + self::doc_description_sources(), 282 + $global_source, 283 + __("WordPress Excerpt", "wireservice"), 284 + ); 285 + } 286 + 287 + /** 288 + * Get document image source options for the meta box (with "Use global" option). 289 + * 290 + * @param string $global_source The current global image source key. 291 + * @return array 292 + */ 293 + public static function meta_box_image_sources( 294 + string $global_source, 295 + ): array { 296 + return self::build_meta_box_sources( 297 + self::doc_image_sources(), 298 + $global_source, 299 + __("WordPress Featured Image", "wireservice"), 300 + ); 301 + } 302 + 303 + /** 304 + * Build meta box source options with a "Use global setting" prefix. 305 + * 306 + * @param array $base Base source options from a doc_*_sources() method. 307 + * @param string $global_source The current global source key. 308 + * @param string $fallback_label Label to use if $global_source is not in $base. 309 + * @return array 310 + */ 311 + private static function build_meta_box_sources( 312 + array $base, 313 + string $global_source, 314 + string $fallback_label, 315 + ): array { 316 + $base["custom"] = __("Custom", "wireservice"); 317 + 318 + $global_label = $base[$global_source] ?? $fallback_label; 319 + 320 + return [ 321 + "" => sprintf( 322 + /* translators: %s: source name */ 323 + __("Use global setting (%s)", "wireservice"), 324 + $global_label, 325 + ), 326 + ] + $base; 327 + } 328 + }
+312
includes/Yoast.php
··· 1 + <?php 2 + declare(strict_types=1); 3 + /** 4 + * Yoast SEO integration helper. 5 + * 6 + * @package Wireservice 7 + */ 8 + 9 + namespace Wireservice; 10 + 11 + class Yoast 12 + { 13 + /** 14 + * Check if Yoast SEO is active and available. 15 + * 16 + * @return bool 17 + */ 18 + public static function is_active(): bool 19 + { 20 + return function_exists("YoastSEO") || 21 + class_exists("WPSEO_Options") || 22 + defined("WPSEO_VERSION"); 23 + } 24 + 25 + /** 26 + * Get the organization/company name from Yoast settings. 27 + * 28 + * @return string|null 29 + */ 30 + public static function get_organization_name(): ?string 31 + { 32 + if (!self::is_active()) { 33 + return null; 34 + } 35 + 36 + $options = get_option("wpseo_titles", []); 37 + 38 + if (($options["company_or_person"] ?? "") === "company") { 39 + $name = $options["company_name"] ?? ""; 40 + if (!empty($name)) { 41 + return $name; 42 + } 43 + } 44 + 45 + return null; 46 + } 47 + 48 + /** 49 + * Get the website name from Yoast settings. 50 + * 51 + * @return string|null 52 + */ 53 + public static function get_website_name(): ?string 54 + { 55 + if (!self::is_active()) { 56 + return null; 57 + } 58 + 59 + $options = get_option("wpseo_titles", []); 60 + $name = $options["website_name"] ?? ""; 61 + 62 + return !empty($name) ? $name : null; 63 + } 64 + 65 + /** 66 + * Get the homepage meta description from Yoast. 67 + * 68 + * @return string|null 69 + */ 70 + public static function get_homepage_description(): ?string 71 + { 72 + if (!self::is_active()) { 73 + return null; 74 + } 75 + 76 + // Try Surfaces API for front page. 77 + if (function_exists("YoastSEO")) { 78 + try { 79 + $front_page_id = get_option("page_on_front"); 80 + if ($front_page_id) { 81 + $meta = \YoastSEO()->meta->for_post((int) $front_page_id); 82 + if ($meta && !empty($meta->description)) { 83 + return $meta->description; 84 + } 85 + } 86 + } catch (\Exception $e) { 87 + // Fall through. 88 + } 89 + } 90 + 91 + // Check wpseo_titles for homepage description. 92 + $options = get_option("wpseo_titles", []); 93 + $desc = $options["metadesc-home-wpseo"] ?? ""; 94 + 95 + return !empty($desc) ? $desc : null; 96 + } 97 + 98 + /** 99 + * Get the SEO title for a post. 100 + * 101 + * @param int $post_id The post ID. 102 + * @return string|null 103 + */ 104 + public static function get_post_title(int $post_id): ?string 105 + { 106 + if (!self::is_active()) { 107 + return null; 108 + } 109 + 110 + // Try Surfaces API (Yoast 14.0+). 111 + if (function_exists("YoastSEO")) { 112 + try { 113 + $meta = \YoastSEO()->meta->for_post($post_id); 114 + if ($meta && !empty($meta->title)) { 115 + // Strip site name suffix if present. 116 + $title = $meta->title; 117 + $site_name = get_bloginfo("name"); 118 + $separators = [" - ", " | ", " – ", " — ", " • "]; 119 + foreach ($separators as $sep) { 120 + if (str_ends_with($title, $sep . $site_name)) { 121 + $title = substr($title, 0, -strlen($sep . $site_name)); 122 + break; 123 + } 124 + } 125 + return trim($title); 126 + } 127 + } catch (\Exception $e) { 128 + // Fall through. 129 + } 130 + } 131 + 132 + // Fall back to post meta. 133 + $meta_title = get_post_meta($post_id, "_yoast_wpseo_title", true); 134 + if (!empty($meta_title)) { 135 + $meta_title = str_replace( 136 + ["%%title%%", "%%sitename%%", "%%sep%%"], 137 + [get_the_title($post_id), get_bloginfo("name"), "-"], 138 + $meta_title, 139 + ); 140 + return trim($meta_title); 141 + } 142 + 143 + return null; 144 + } 145 + 146 + /** 147 + * Get a Yoast Surfaces API property with post meta fallback. 148 + * 149 + * @param int $post_id The post ID. 150 + * @param string $property The Surfaces API property name. 151 + * @param string $meta_key The post meta key fallback. 152 + * @return string|null 153 + */ 154 + private static function get_surfaces_value( 155 + int $post_id, 156 + string $property, 157 + string $meta_key, 158 + ): ?string { 159 + if (!self::is_active()) { 160 + return null; 161 + } 162 + 163 + if (function_exists("YoastSEO")) { 164 + try { 165 + $meta = \YoastSEO()->meta->for_post($post_id); 166 + if ($meta && !empty($meta->$property)) { 167 + return $meta->$property; 168 + } 169 + } catch (\Exception $e) { 170 + // Fall through. 171 + } 172 + } 173 + 174 + $value = get_post_meta($post_id, $meta_key, true); 175 + return !empty($value) ? $value : null; 176 + } 177 + 178 + /** 179 + * Get the meta description for a post. 180 + * 181 + * @param int $post_id The post ID. 182 + * @return string|null 183 + */ 184 + public static function get_post_description(int $post_id): ?string 185 + { 186 + return self::get_surfaces_value($post_id, "description", "_yoast_wpseo_metadesc"); 187 + } 188 + 189 + /** 190 + * Get the Open Graph (social) title for a post. 191 + * 192 + * @param int $post_id The post ID. 193 + * @return string|null 194 + */ 195 + public static function get_post_social_title(int $post_id): ?string 196 + { 197 + return self::get_surfaces_value($post_id, "open_graph_title", "_yoast_wpseo_opengraph-title"); 198 + } 199 + 200 + /** 201 + * Get the Open Graph (social) description for a post. 202 + * 203 + * @param int $post_id The post ID. 204 + * @return string|null 205 + */ 206 + public static function get_post_social_description(int $post_id): ?string 207 + { 208 + return self::get_surfaces_value($post_id, "open_graph_description", "_yoast_wpseo_opengraph-description"); 209 + } 210 + 211 + /** 212 + * Get the X (Twitter) title for a post. 213 + * 214 + * @param int $post_id The post ID. 215 + * @return string|null 216 + */ 217 + public static function get_post_x_title(int $post_id): ?string 218 + { 219 + return self::get_surfaces_value($post_id, "twitter_title", "_yoast_wpseo_twitter-title"); 220 + } 221 + 222 + /** 223 + * Get the X (Twitter) description for a post. 224 + * 225 + * @param int $post_id The post ID. 226 + * @return string|null 227 + */ 228 + public static function get_post_x_description(int $post_id): ?string 229 + { 230 + return self::get_surfaces_value($post_id, "twitter_description", "_yoast_wpseo_twitter-description"); 231 + } 232 + 233 + /** 234 + * Get the Open Graph (social) image attachment ID for a post. 235 + * 236 + * @param int $post_id The post ID. 237 + * @return int|null The attachment ID or null. 238 + */ 239 + public static function get_post_social_image_id(int $post_id): ?int 240 + { 241 + if (!self::is_active()) { 242 + return null; 243 + } 244 + 245 + // Try Surfaces API (Yoast 14.0+). 246 + if (function_exists("YoastSEO")) { 247 + try { 248 + $meta = \YoastSEO()->meta->for_post($post_id); 249 + if ($meta && !empty($meta->open_graph_images)) { 250 + $images = $meta->open_graph_images; 251 + if (is_array($images) && !empty($images[0]["id"])) { 252 + return (int) $images[0]["id"]; 253 + } 254 + // If we have a URL but no ID, try to find the attachment. 255 + if (is_array($images) && !empty($images[0]["url"])) { 256 + $attachment_id = attachment_url_to_postid($images[0]["url"]); 257 + if ($attachment_id) { 258 + return $attachment_id; 259 + } 260 + } 261 + } 262 + } catch (\Exception $e) { 263 + // Fall through. 264 + } 265 + } 266 + 267 + // Fall back to post meta. 268 + $image_id = get_post_meta($post_id, "_yoast_wpseo_opengraph-image-id", true); 269 + if (!empty($image_id)) { 270 + return (int) $image_id; 271 + } 272 + 273 + return null; 274 + } 275 + 276 + /** 277 + * Get the X (Twitter) image attachment ID for a post. 278 + * 279 + * @param int $post_id The post ID. 280 + * @return int|null The attachment ID or null. 281 + */ 282 + public static function get_post_x_image_id(int $post_id): ?int 283 + { 284 + if (!self::is_active()) { 285 + return null; 286 + } 287 + 288 + // Try Surfaces API (Yoast 14.0+). 289 + if (function_exists("YoastSEO")) { 290 + try { 291 + $meta = \YoastSEO()->meta->for_post($post_id); 292 + if ($meta && !empty($meta->twitter_image)) { 293 + // twitter_image is a URL, try to find the attachment. 294 + $attachment_id = attachment_url_to_postid($meta->twitter_image); 295 + if ($attachment_id) { 296 + return $attachment_id; 297 + } 298 + } 299 + } catch (\Exception $e) { 300 + // Fall through. 301 + } 302 + } 303 + 304 + // Fall back to post meta. 305 + $image_id = get_post_meta($post_id, "_yoast_wpseo_twitter-image-id", true); 306 + if (!empty($image_id)) { 307 + return (int) $image_id; 308 + } 309 + 310 + return null; 311 + } 312 + }
+131
templates/document-meta-box.php
··· 1 + <?php 2 + /** 3 + * Document settings meta box template for Wireservice. 4 + * 5 + * @package Wireservice 6 + * 7 + * @var string $title_source 8 + * @var string $custom_title 9 + * @var array $title_sources 10 + * @var string $desc_source 11 + * @var string $custom_description 12 + * @var array $desc_sources 13 + * @var string $image_source 14 + * @var string $custom_image_id 15 + * @var array $image_sources 16 + * @var string $include_content 17 + * @var string $global_label 18 + * @var string $at_uri 19 + */ 20 + 21 + defined('ABSPATH') || exit; 22 + ?> 23 + <p> 24 + <label for="wireservice_title_source"> 25 + <strong><?php esc_html_e("Title Source", "wireservice"); ?></strong> 26 + </label><br> 27 + <select name="wireservice_title_source" id="wireservice_title_source" style="width: 100%;"> 28 + <?php foreach ($title_sources as $key => $label): ?> 29 + <option value="<?php echo esc_attr($key); ?>" <?php selected($title_source, $key); ?>> 30 + <?php echo esc_html($label); ?> 31 + </option> 32 + <?php endforeach; ?> 33 + </select> 34 + <div id="wireservice-custom-title-field" style="display:none; margin-top: 8px;"> 35 + <label for="wireservice_custom_title"> 36 + <?php esc_html_e("Custom Title", "wireservice"); ?> 37 + </label><br> 38 + <input type="text" 39 + name="wireservice_custom_title" 40 + id="wireservice_custom_title" 41 + value="<?php echo esc_attr($custom_title); ?>" 42 + style="width: 100%;" 43 + /> 44 + </div> 45 + </p> 46 + <p> 47 + <label for="wireservice_description_source"> 48 + <strong><?php esc_html_e("Description Source", "wireservice"); ?></strong> 49 + </label><br> 50 + <select name="wireservice_description_source" id="wireservice_description_source" style="width: 100%;"> 51 + <?php foreach ($desc_sources as $key => $label): ?> 52 + <option value="<?php echo esc_attr($key); ?>" <?php selected($desc_source, $key); ?>> 53 + <?php echo esc_html($label); ?> 54 + </option> 55 + <?php endforeach; ?> 56 + </select> 57 + <div id="wireservice-custom-description-field" style="display:none; margin-top: 8px;"> 58 + <label for="wireservice_custom_description"> 59 + <?php esc_html_e("Custom Description", "wireservice"); ?> 60 + </label><br> 61 + <textarea name="wireservice_custom_description" 62 + id="wireservice_custom_description" 63 + style="width: 100%;" 64 + rows="3" 65 + ><?php echo esc_textarea($custom_description); ?></textarea> 66 + </div> 67 + </p> 68 + <p> 69 + <label for="wireservice_image_source"> 70 + <strong><?php esc_html_e("Cover Image Source", "wireservice"); ?></strong> 71 + </label><br> 72 + <select name="wireservice_image_source" id="wireservice_image_source" style="width: 100%;"> 73 + <?php foreach ($image_sources as $key => $label): ?> 74 + <option value="<?php echo esc_attr($key); ?>" <?php selected($image_source, $key); ?>> 75 + <?php echo esc_html($label); ?> 76 + </option> 77 + <?php endforeach; ?> 78 + </select> 79 + <div id="wireservice-custom-image-field" style="display:none; margin-top: 8px;"> 80 + <div id="wireservice-custom-image-preview"> 81 + <?php if (!empty($custom_image_id)): 82 + $thumb = wp_get_attachment_image_url($custom_image_id, "thumbnail"); 83 + ?> 84 + <img src="<?php echo esc_url($thumb); ?>" 85 + style="max-width:150px;height:auto;display:block;margin-bottom:8px;" /> 86 + <?php endif; ?> 87 + </div> 88 + <input type="hidden" 89 + name="wireservice_custom_image_id" 90 + id="wireservice_custom_image_id" 91 + value="<?php echo esc_attr($custom_image_id); ?>" /> 92 + <button type="button" 93 + class="button" 94 + id="wireservice-select-image"> 95 + <?php esc_html_e("Select Image", "wireservice"); ?> 96 + </button> 97 + <button type="button" 98 + class="button" 99 + id="wireservice-remove-image" 100 + style="<?php echo empty($custom_image_id) ? 'display:none;' : ''; ?>"> 101 + <?php esc_html_e("Remove Image", "wireservice"); ?> 102 + </button> 103 + <p class="description"><?php esc_html_e("Images must be less than 1MB.", "wireservice"); ?></p> 104 + </div> 105 + </p> 106 + <p> 107 + <label for="wireservice_include_content"> 108 + <strong><?php esc_html_e("Include Full Content", "wireservice"); ?></strong> 109 + </label><br> 110 + <select name="wireservice_include_content" id="wireservice_include_content" style="width: 100%;"> 111 + <option value="" <?php selected($include_content, ""); ?>> 112 + <?php echo esc_html(sprintf( 113 + /* translators: %s: On or Off */ 114 + __("Use global setting (%s)", "wireservice"), 115 + $global_label, 116 + )); ?> 117 + </option> 118 + <option value="1" <?php selected($include_content, "1"); ?>> 119 + <?php esc_html_e("Include full content", "wireservice"); ?> 120 + </option> 121 + <option value="0" <?php selected($include_content, "0"); ?>> 122 + <?php esc_html_e("Don't include full content", "wireservice"); ?> 123 + </option> 124 + </select> 125 + </p> 126 + <?php if ($at_uri): ?> 127 + <p> 128 + <strong><?php esc_html_e("AT-URI", "wireservice"); ?></strong><br> 129 + <code style="font-size: 11px; word-break: break-all;"><?php echo esc_html($at_uri); ?></code> 130 + </p> 131 + <?php endif; ?>
+473
templates/settings-page.php
··· 1 + <?php 2 + /** 3 + * Settings page template for Wireservice. 4 + * 5 + * @package Wireservice 6 + * 7 + * @var array $connection 8 + * @var bool $is_connected 9 + * @var array|\WP_Error|null $session 10 + * @var string $authorize_url 11 + * @var string $oauth_url 12 + * @var string $client_error 13 + * @var array $name_sources 14 + * @var array $desc_sources 15 + * @var string $name_source 16 + * @var string $desc_source 17 + * @var string $custom_name 18 + * @var string $custom_description 19 + * @var string $pub_uri 20 + * @var bool $yoast_active 21 + * @var string $icon_source 22 + * @var int $custom_icon_id 23 + * @var string $icon_preview_url 24 + * @var array $icon_sources 25 + * @var string $theme_background 26 + * @var string $theme_foreground 27 + * @var string $theme_accent 28 + * @var string $theme_accent_foreground 29 + * @var string $show_in_discover 30 + * @var array $doc_title_sources 31 + * @var array $doc_desc_sources 32 + * @var array $doc_image_sources 33 + * @var string $doc_title_source 34 + * @var string $doc_desc_source 35 + * @var string $doc_image_source 36 + * @var string $doc_include_content 37 + * @var string $doc_enabled 38 + */ 39 + 40 + defined('ABSPATH') || exit; 41 + ?> 42 + <div class="wrap"> 43 + <h1><?php echo esc_html(get_admin_page_title()); ?></h1> 44 + 45 + <div class="wireservice-settings"> 46 + <h2><?php esc_html_e("AT Protocol Connection", "wireservice"); ?></h2> 47 + 48 + <?php if ($is_connected): ?> 49 + <div class="wireservice-connection-status connected"> 50 + <?php if (!is_wp_error($session)): ?> 51 + <div class="wireservice-profile"> 52 + <div class="wireservice-profile-info"> 53 + <strong class="wireservice-display-name">Connected to @<?php echo esc_html($session["handle"]); ?></strong> 54 + </div> 55 + </div> 56 + <?php 57 + /* translators: %s: user handle */ 58 + else: ?> 59 + <p> 60 + <?php printf( 61 + esc_html__("Connected as: %s", "wireservice"), 62 + "<strong>" . esc_html($connection["handle"] ?? "Unknown") . "</strong>", 63 + ); ?> 64 + </p> 65 + <p class="wireservice-error"><?php echo esc_html($session->get_error_message()); ?></p> 66 + <?php endif; ?> 67 + <form method="post" action="<?php echo esc_url(admin_url("admin-post.php")); ?>"> 68 + <?php wp_nonce_field("wireservice_disconnect", "wireservice_nonce"); ?> 69 + <input type="hidden" name="action" value="wireservice_disconnect"> 70 + <button type="submit" class="button button-secondary"> 71 + <?php esc_html_e("Disconnect", "wireservice"); ?> 72 + </button> 73 + </form> 74 + </div> 75 + <?php else: ?> 76 + <div class="wireservice-connection-status disconnected"> 77 + <p><?php esc_html_e("Not connected to AT Protocol.", "wireservice"); ?></p> 78 + <?php if (!empty($authorize_url)): ?> 79 + <a href="<?php echo esc_url($authorize_url); ?>" class="button button-primary"> 80 + <?php esc_html_e("Connect Account", "wireservice"); ?> 81 + </a> 82 + <?php else: ?> 83 + <?php if (empty($oauth_url)): ?> 84 + <p class="wireservice-error"> 85 + <?php esc_html_e("Please configure the OAuth Service URL in settings below.", "wireservice"); ?> 86 + </p> 87 + <?php elseif ($client_error): ?> 88 + <p class="wireservice-error"> 89 + <?php printf( 90 + /* translators: %s: error message */ 91 + esc_html__("Failed to register with OAuth service: %s", "wireservice"), 92 + esc_html($client_error), 93 + ); ?> 94 + </p> 95 + <?php else: ?> 96 + <p class="wireservice-error"> 97 + <?php esc_html_e("Unable to connect to OAuth service.", "wireservice"); ?> 98 + </p> 99 + <?php endif; ?> 100 + <?php endif; ?> 101 + </div> 102 + <?php endif; ?> 103 + 104 + <?php if ($is_connected): ?> 105 + <hr> 106 + 107 + <h2><?php esc_html_e("Publication", "wireservice"); ?></h2> 108 + <p class="description"><?php esc_html_e("Configure your site's publication record on AT Protocol. This represents your WordPress site in the ATmosphere.", "wireservice"); ?></p> 109 + 110 + <form method="post" action="<?php echo esc_url(admin_url("admin-post.php")); ?>"> 111 + <?php wp_nonce_field("wireservice_sync_publication", "wireservice_pub_nonce"); ?> 112 + <input type="hidden" name="action" value="wireservice_sync_publication"> 113 + 114 + <table class="form-table"> 115 + <tr> 116 + <th scope="row"> 117 + <label for="wireservice_pub_url"><?php esc_html_e("Site URL", "wireservice"); ?></label> 118 + </th> 119 + <td> 120 + <input type="url" name="wireservice_pub_url" id="wireservice_pub_url" class="regular-text" value="<?php echo esc_attr(home_url()); ?>"> 121 + <p class="description"><?php esc_html_e("The base URL of your publication.", "wireservice"); ?></p> 122 + </td> 123 + </tr> 124 + <tr> 125 + <th scope="row"> 126 + <label for="wireservice_pub_name_source"><?php esc_html_e("Name", "wireservice"); ?></label> 127 + </th> 128 + <td> 129 + <select name="wireservice_pub_name_source" id="wireservice_pub_name_source" class="regular-text"> 130 + <?php foreach ($name_sources as $key => $source): ?> 131 + <option value="<?php echo esc_attr($key); ?>" data-value="<?php echo esc_attr($source["value"]); ?>" <?php selected($name_source, $key); ?>> 132 + <?php echo esc_html($source["label"]); ?> 133 + </option> 134 + <?php endforeach; ?> 135 + </select> 136 + <p class="description" id="wireservice-pub-name-current-value"> 137 + <?php esc_html_e("Current value:", "wireservice"); ?> 138 + <strong class="wireservice-preview-text"><?php echo esc_html( 139 + $name_sources[$name_source]["value"] ?? $name_sources["wordpress_title"]["value"], 140 + ); ?></strong> 141 + </p> 142 + <div id="wireservice-pub-custom-name-field" style="display:none; margin-top: 8px;"> 143 + <input type="text" 144 + name="wireservice_pub_custom_name" 145 + id="wireservice_pub_custom_name" 146 + class="regular-text" 147 + value="<?php echo esc_attr($custom_name); ?>" 148 + /> 149 + </div> 150 + </td> 151 + </tr> 152 + <tr> 153 + <th scope="row"> 154 + <label for="wireservice_pub_description_source"><?php esc_html_e("Description", "wireservice"); ?></label> 155 + </th> 156 + <td> 157 + <select name="wireservice_pub_description_source" id="wireservice_pub_description_source" class="regular-text"> 158 + <?php foreach ($desc_sources as $key => $source): ?> 159 + <option value="<?php echo esc_attr($key); ?>" data-value="<?php echo esc_attr($source["value"]); ?>" <?php selected($desc_source, $key); ?>> 160 + <?php echo esc_html($source["label"]); ?> 161 + </option> 162 + <?php endforeach; ?> 163 + </select> 164 + <p class="description" id="wireservice-pub-desc-current-value"> 165 + <?php esc_html_e("Current value:", "wireservice"); ?> 166 + <em class="wireservice-preview-text"><?php 167 + $desc_value = $desc_sources[$desc_source]["value"] ?? $desc_sources["wordpress_tagline"]["value"]; 168 + echo esc_html(mb_substr($desc_value, 0, 100) . (mb_strlen($desc_value) > 100 ? "..." : "")); 169 + ?></em> 170 + </p> 171 + <div id="wireservice-pub-custom-desc-field" style="display:none; margin-top: 8px;"> 172 + <textarea name="wireservice_pub_custom_description" 173 + id="wireservice_pub_custom_description" 174 + class="large-text" 175 + rows="3" 176 + ><?php echo esc_textarea($custom_description); ?></textarea> 177 + </div> 178 + </td> 179 + </tr> 180 + <tr> 181 + <th scope="row"> 182 + <label for="wireservice_pub_icon_source"><?php esc_html_e("Icon", "wireservice"); ?></label> 183 + </th> 184 + <td> 185 + <select name="wireservice_pub_icon_source" id="wireservice_pub_icon_source" class="regular-text"> 186 + <?php foreach ($icon_sources as $key => $source): ?> 187 + <option value="<?php echo esc_attr($key); ?>" <?php selected($icon_source, $key); ?>> 188 + <?php echo esc_html($source["label"]); ?> 189 + </option> 190 + <?php endforeach; ?> 191 + </select> 192 + <p class="description"><?php esc_html_e("Square image to identify your publication. Should be at least 256×256 and less than 1MB.", "wireservice"); ?></p> 193 + <div id="wireservice-pub-icon-preview" style="margin-top: 8px;<?php echo empty($icon_preview_url) ? ' display:none;' : ''; ?>"> 194 + <?php if ($icon_preview_url): ?> 195 + <img src="<?php echo esc_url($icon_preview_url); ?>" alt="" style="width: 64px; height: 64px; object-fit: cover; border-radius: 4px;"> 196 + <?php endif; ?> 197 + </div> 198 + <div id="wireservice-pub-custom-icon-field" style="display:none; margin-top: 8px;"> 199 + <input type="hidden" name="wireservice_pub_custom_icon_id" id="wireservice_pub_custom_icon_id" value="<?php echo esc_attr($custom_icon_id); ?>"> 200 + <button type="button" class="button" id="wireservice-pub-icon-upload"><?php esc_html_e("Select Image", "wireservice"); ?></button> 201 + <button type="button" class="button" id="wireservice-pub-icon-remove" style="<?php echo empty($custom_icon_id) ? 'display:none;' : ''; ?>"><?php esc_html_e("Remove", "wireservice"); ?></button> 202 + <div id="wireservice-pub-custom-icon-preview" style="margin-top: 8px;"> 203 + <?php if ($custom_icon_id): ?> 204 + <?php $custom_preview = wp_get_attachment_image_url($custom_icon_id, [64, 64]); ?> 205 + <?php if ($custom_preview): ?> 206 + <img src="<?php echo esc_url($custom_preview); ?>" alt="" style="width: 64px; height: 64px; object-fit: cover; border-radius: 4px;"> 207 + <?php endif; ?> 208 + <?php endif; ?> 209 + </div> 210 + </div> 211 + </td> 212 + </tr> 213 + <tr> 214 + <th scope="row" colspan="2"> 215 + <h3 style="margin: 0;"><?php esc_html_e("Theme", "wireservice"); ?></h3> 216 + <p class="description" style="font-weight: normal;"><?php esc_html_e("Set theme colors for your publication. All four colors are required for the theme to be included.", "wireservice"); ?></p> 217 + </th> 218 + </tr> 219 + <tr> 220 + <th scope="row"> 221 + <label for="wireservice_pub_theme_background"><?php esc_html_e("Background", "wireservice"); ?></label> 222 + </th> 223 + <td> 224 + <input type="text" name="wireservice_pub_theme_background" id="wireservice_pub_theme_background" class="wireservice-color-picker" value="<?php echo esc_attr($theme_background); ?>" data-default-color=""> 225 + </td> 226 + </tr> 227 + <tr> 228 + <th scope="row"> 229 + <label for="wireservice_pub_theme_foreground"><?php esc_html_e("Foreground", "wireservice"); ?></label> 230 + </th> 231 + <td> 232 + <input type="text" name="wireservice_pub_theme_foreground" id="wireservice_pub_theme_foreground" class="wireservice-color-picker" value="<?php echo esc_attr($theme_foreground); ?>" data-default-color=""> 233 + </td> 234 + </tr> 235 + <tr> 236 + <th scope="row"> 237 + <label for="wireservice_pub_theme_accent"><?php esc_html_e("Accent", "wireservice"); ?></label> 238 + </th> 239 + <td> 240 + <input type="text" name="wireservice_pub_theme_accent" id="wireservice_pub_theme_accent" class="wireservice-color-picker" value="<?php echo esc_attr($theme_accent); ?>" data-default-color=""> 241 + </td> 242 + </tr> 243 + <tr> 244 + <th scope="row"> 245 + <label for="wireservice_pub_theme_accent_foreground"><?php esc_html_e("Accent Foreground", "wireservice"); ?></label> 246 + </th> 247 + <td> 248 + <input type="text" name="wireservice_pub_theme_accent_foreground" id="wireservice_pub_theme_accent_foreground" class="wireservice-color-picker" value="<?php echo esc_attr($theme_accent_foreground); ?>" data-default-color=""> 249 + </td> 250 + </tr> 251 + <tr> 252 + <th scope="row"><?php esc_html_e("Discovery", "wireservice"); ?></th> 253 + <td> 254 + <label for="wireservice_pub_show_in_discover"> 255 + <input type="checkbox" 256 + name="wireservice_pub_show_in_discover" 257 + id="wireservice_pub_show_in_discover" 258 + value="1" 259 + <?php checked($show_in_discover, "1"); ?> /> 260 + <?php esc_html_e("Show this publication in discovery feeds on AT Protocol apps", "wireservice"); ?> 261 + </label> 262 + </td> 263 + </tr> 264 + <?php if ($pub_uri): ?> 265 + <tr> 266 + <th scope="row"><?php esc_html_e("AT-URI", "wireservice"); ?></th> 267 + <td> 268 + <code><?php echo esc_html($pub_uri); ?></code> 269 + <p class="description"><?php esc_html_e("Your publication's identifier on AT Protocol.", "wireservice"); ?></p> 270 + </td> 271 + </tr> 272 + <?php endif; ?> 273 + </table> 274 + 275 + <?php submit_button( 276 + $pub_uri 277 + ? __("Update Publication", "wireservice") 278 + : __("Create Publication", "wireservice"), 279 + ); ?> 280 + </form> 281 + 282 + <hr> 283 + 284 + <h2><?php esc_html_e("Document Settings", "wireservice"); ?></h2> 285 + <p class="description"><?php esc_html_e("Configure how document metadata is sourced when posts are synced to AT Protocol.", "wireservice"); ?></p> 286 + 287 + <form method="post" action="<?php echo esc_url(admin_url("admin-post.php")); ?>"> 288 + <?php wp_nonce_field("wireservice_save_doc_settings", "wireservice_doc_nonce"); ?> 289 + <input type="hidden" name="action" value="wireservice_save_doc_settings"> 290 + <table class="form-table"> 291 + <tr> 292 + <th scope="row"><?php esc_html_e("Enable by default", "wireservice"); ?></th> 293 + <td> 294 + <label for="wireservice_doc_enabled"> 295 + <input type="checkbox" 296 + name="wireservice_doc_enabled" 297 + id="wireservice_doc_enabled" 298 + value="1" 299 + <?php checked($doc_enabled, "1"); ?> /> 300 + <?php esc_html_e("Automatically publish new posts to AT Protocol", "wireservice"); ?> 301 + </label> 302 + </td> 303 + </tr> 304 + <tr> 305 + <th scope="row"> 306 + <label for="wireservice_doc_title_source"><?php esc_html_e("Document Title Source", "wireservice"); ?></label> 307 + </th> 308 + <td> 309 + <select name="wireservice_doc_title_source" id="wireservice_doc_title_source"> 310 + <?php foreach ($doc_title_sources as $key => $label): ?> 311 + <option value="<?php echo esc_attr($key); ?>" <?php selected($doc_title_source, $key); ?>> 312 + <?php echo esc_html($label); ?> 313 + </option> 314 + <?php endforeach; ?> 315 + </select> 316 + <p class="description"><?php esc_html_e("Where to get the title for document records.", "wireservice"); ?></p> 317 + </td> 318 + </tr> 319 + <tr> 320 + <th scope="row"> 321 + <label for="wireservice_doc_description_source"><?php esc_html_e("Document Description Source", "wireservice"); ?></label> 322 + </th> 323 + <td> 324 + <select name="wireservice_doc_description_source" id="wireservice_doc_description_source"> 325 + <?php foreach ($doc_desc_sources as $key => $label): ?> 326 + <option value="<?php echo esc_attr($key); ?>" <?php selected($doc_desc_source, $key); ?>> 327 + <?php echo esc_html($label); ?> 328 + </option> 329 + <?php endforeach; ?> 330 + </select> 331 + <p class="description"><?php esc_html_e("Where to get the description for document records.", "wireservice"); ?></p> 332 + </td> 333 + </tr> 334 + <tr> 335 + <th scope="row"> 336 + <label for="wireservice_doc_image_source"><?php esc_html_e("Cover Image Source", "wireservice"); ?></label> 337 + </th> 338 + <td> 339 + <select name="wireservice_doc_image_source" id="wireservice_doc_image_source"> 340 + <?php foreach ($doc_image_sources as $key => $label): ?> 341 + <option value="<?php echo esc_attr($key); ?>" <?php selected($doc_image_source, $key); ?>> 342 + <?php echo esc_html($label); ?> 343 + </option> 344 + <?php endforeach; ?> 345 + </select> 346 + <p class="description"><?php esc_html_e("Where to get the cover image for document records. Images must be less than 1MB.", "wireservice"); ?></p> 347 + </td> 348 + </tr> 349 + <tr> 350 + <th scope="row"><?php esc_html_e("Full Content", "wireservice"); ?></th> 351 + <td> 352 + <label for="wireservice_doc_include_content"> 353 + <input type="checkbox" 354 + name="wireservice_doc_include_content" 355 + id="wireservice_doc_include_content" 356 + value="1" 357 + <?php checked($doc_include_content, "1"); ?> /> 358 + <?php esc_html_e("Include full post text content in document records", "wireservice"); ?> 359 + </label> 360 + <p class="description"><?php esc_html_e("When enabled, the plain text of each post will be synced in the textContent field.", "wireservice"); ?></p> 361 + </td> 362 + </tr> 363 + </table> 364 + <?php submit_button(__("Save Document Settings", "wireservice")); ?> 365 + </form> 366 + <?php endif; ?> 367 + 368 + <hr> 369 + 370 + <details class="wireservice-advanced-settings"> 371 + <summary><?php esc_html_e("Advanced Settings", "wireservice"); ?></summary> 372 + <form method="post" action="options.php"> 373 + <?php settings_fields("wireservice"); ?> 374 + <table class="form-table"> 375 + <tr> 376 + <th scope="row"> 377 + <label for="wireservice_oauth_url"> 378 + <?php esc_html_e("OAuth Service URL", "wireservice"); ?> 379 + </label> 380 + </th> 381 + <td> 382 + <input type="url" name="wireservice_oauth_url" id="wireservice_oauth_url" class="regular-text" value="<?php echo esc_attr(get_option("wireservice_oauth_url")); ?>" placeholder="https://example.com"> 383 + <p class="description"> 384 + <?php esc_html_e("The URL of the OAuth service for AT Protocol authentication.", "wireservice"); ?> 385 + </p> 386 + </td> 387 + </tr> 388 + </table> 389 + <?php submit_button(); ?> 390 + </form> 391 + </details> 392 + 393 + <hr> 394 + 395 + <h2><?php esc_html_e("Reset Plugin Data", "wireservice"); ?></h2> 396 + <p class="description"><?php esc_html_e("This will remove all plugin settings, stored connections, and document sync data from the database. This action cannot be undone.", "wireservice"); ?></p> 397 + <form method="post" action="<?php echo esc_url(admin_url("admin-post.php")); ?>" onsubmit="return confirm('<?php echo esc_js(__("Are you sure you want to reset all Wireservice data? This cannot be undone.", "wireservice")); ?>');"> 398 + <?php wp_nonce_field("wireservice_reset_data", "wireservice_reset_nonce"); ?> 399 + <input type="hidden" name="action" value="wireservice_reset_data"> 400 + <button type="submit" class="button button-secondary" style="color: #dc3545;"> 401 + <?php esc_html_e("Reset All Data", "wireservice"); ?> 402 + </button> 403 + </form> 404 + </div> 405 + </div> 406 + <style> 407 + .wireservice-settings { 408 + max-width: 800px; 409 + } 410 + .wireservice-connection-status { 411 + padding: 15px; 412 + border-radius: 4px; 413 + margin: 15px 0; 414 + } 415 + .wireservice-connection-status.connected { 416 + background: #d4edda; 417 + border: 1px solid #c3e6cb; 418 + } 419 + .wireservice-connection-status.disconnected { 420 + background: #f8f9fa; 421 + border: 1px solid #dee2e6; 422 + } 423 + .wireservice-error { 424 + color: #dc3545; 425 + font-weight: 500; 426 + } 427 + .wireservice-profile { 428 + display: flex; 429 + gap: 15px; 430 + align-items: flex-start; 431 + margin-bottom: 15px; 432 + } 433 + .wireservice-avatar { 434 + width: 64px; 435 + height: 64px; 436 + border-radius: 50%; 437 + object-fit: cover; 438 + } 439 + .wireservice-profile-info { 440 + flex: 1; 441 + } 442 + .wireservice-display-name { 443 + display: block; 444 + font-size: 16px; 445 + margin-bottom: 2px; 446 + } 447 + .wireservice-handle { 448 + color: #666; 449 + font-size: 14px; 450 + } 451 + .wireservice-bio { 452 + margin: 8px 0; 453 + font-size: 14px; 454 + color: #333; 455 + } 456 + .wireservice-stats { 457 + display: flex; 458 + gap: 15px; 459 + font-size: 13px; 460 + color: #666; 461 + margin: 8px 0 0; 462 + } 463 + .wireservice-advanced-settings summary { 464 + cursor: pointer; 465 + font-size: 14px; 466 + font-weight: 600; 467 + color: #50575e; 468 + padding: 4px 0; 469 + } 470 + .wireservice-advanced-settings summary:hover { 471 + color: #1d2327; 472 + } 473 + </style>
+39
uninstall.php
··· 1 + <?php 2 + /** 3 + * Fired when the plugin is uninstalled. 4 + * 5 + * @package Wireservice 6 + */ 7 + 8 + if (!defined("WP_UNINSTALL_PLUGIN")) { 9 + die(); 10 + } 11 + 12 + // Remove plugin options. 13 + delete_option("wireservice_connection"); 14 + delete_option("wireservice_client_id"); 15 + delete_option("wireservice_client_secret"); 16 + delete_option("wireservice_oauth_url"); 17 + delete_option("wireservice_publication"); 18 + delete_option("wireservice_publication_uri"); 19 + delete_option("wireservice_db_version"); 20 + delete_option("wireservice_pub_settings"); 21 + delete_option("wireservice_doc_settings"); 22 + 23 + // Remove transients. 24 + delete_transient("wireservice_code_verifier"); 25 + delete_transient("wireservice_oauth_state"); 26 + 27 + // Remove post meta for documents. 28 + global $wpdb; 29 + $wpdb->delete($wpdb->postmeta, ["meta_key" => "_wireservice_document_uri"]); 30 + $wpdb->delete($wpdb->postmeta, ["meta_key" => "_wireservice_title_source"]); 31 + $wpdb->delete($wpdb->postmeta, ["meta_key" => "_wireservice_description_source"]); 32 + $wpdb->delete($wpdb->postmeta, ["meta_key" => "_wireservice_image_source"]); 33 + $wpdb->delete($wpdb->postmeta, ["meta_key" => "_wireservice_custom_title"]); 34 + $wpdb->delete($wpdb->postmeta, ["meta_key" => "_wireservice_custom_description"]); 35 + $wpdb->delete($wpdb->postmeta, ["meta_key" => "_wireservice_custom_image_id"]); 36 + $wpdb->delete($wpdb->postmeta, ["meta_key" => "_wireservice_include_content"]); 37 + 38 + // Remove any custom tables. 39 + $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}wireservice_logs");
+67
wireservice.php
··· 1 + <?php 2 + declare(strict_types=1); 3 + /** 4 + * Plugin Name: Wireservice 5 + * Plugin URI: https://example.com/wireservice 6 + * Description: A WordPress plugin for publishing posts to the AT Protocol based on the standard.site lexicon. 7 + * Version: 1.1.0 8 + * Author: Tyler Fisher 9 + * Author URI: https://example.com 10 + * License: AGPL-3.0+ 11 + * License URI: https://www.gnu.org/licenses/agpl-3.0.html 12 + * Text Domain: wireservice 13 + * Domain Path: /languages 14 + */ 15 + 16 + // If this file is called directly, abort. 17 + if (!defined("WPINC")) { 18 + die(); 19 + } 20 + 21 + // Plugin version. 22 + define("WIRESERVICE_VERSION", "1.1.0"); 23 + 24 + // Plugin directory path. 25 + define("WIRESERVICE_PLUGIN_DIR", plugin_dir_path(__FILE__)); 26 + 27 + // Plugin directory URL. 28 + define("WIRESERVICE_PLUGIN_URL", plugin_dir_url(__FILE__)); 29 + 30 + // Load Composer autoloader. 31 + if (file_exists(WIRESERVICE_PLUGIN_DIR . "vendor/autoload.php")) { 32 + require_once WIRESERVICE_PLUGIN_DIR . "vendor/autoload.php"; 33 + } 34 + 35 + /** 36 + * Code that runs during plugin activation. 37 + */ 38 + function wireservice_activate() 39 + { 40 + // Register rewrite rules. 41 + $setup = new Wireservice\Setup(); 42 + $setup->register_well_known_rewrite(); 43 + 44 + // Flush rewrite rules. 45 + flush_rewrite_rules(); 46 + } 47 + register_activation_hook(__FILE__, "wireservice_activate"); 48 + 49 + /** 50 + * Code that runs during plugin deactivation. 51 + */ 52 + function wireservice_deactivate() 53 + { 54 + // Flush rewrite rules to remove our custom rules. 55 + flush_rewrite_rules(); 56 + } 57 + register_deactivation_hook(__FILE__, "wireservice_deactivate"); 58 + 59 + /** 60 + * Initialize the plugin. 61 + */ 62 + function wireservice_init() 63 + { 64 + $setup = new Wireservice\Setup(); 65 + $setup->init(); 66 + } 67 + add_action("plugins_loaded", "wireservice_init");