Sync your WordPress posts to standard.site records on your PDS
at main 541 lines 13 kB view raw
1<?php 2declare(strict_types=1); 3/** 4 * API client for communicating with the OAuth service and PDS. 5 * 6 * @package Wireservice 7 */ 8 9namespace Wireservice; 10 11if (! defined('ABSPATH')) { 12 exit; 13} 14 15class API 16{ 17 /** 18 * OAuth service base URL. 19 * 20 * @var string 21 */ 22 private string $oauth_url; 23 24 /** 25 * Cached session data. 26 * 27 * @var array|null 28 */ 29 private ?array $session = null; 30 31 /** 32 * Cached PDS credentials. 33 * 34 * @var array|null 35 */ 36 private ?array $pds_credentials = null; 37 38 /** 39 * Constructor. 40 */ 41 public function __construct(private ConnectionsManager $connections_manager) 42 { 43 $this->oauth_url = rtrim(get_option("wireservice_oauth_url", "https://aip.wireservice.net"), "/"); 44 } 45 46 /** 47 * Make an authenticated request to the OAuth service. 48 * 49 * @param string $method HTTP method. 50 * @param string $endpoint API endpoint. 51 * @param array $args Request arguments. 52 * @return array|\WP_Error 53 */ 54 public function request(string $method, string $endpoint, array $args = []) 55 { 56 $access_token = $this->connections_manager->get_access_token(); 57 58 if (is_wp_error($access_token)) { 59 return $access_token; 60 } 61 62 if (empty($this->oauth_url)) { 63 return new \WP_Error( 64 "no_oauth_url", 65 __("OAuth service URL is not configured.", "wireservice"), 66 ); 67 } 68 69 $url = $this->oauth_url . $endpoint; 70 71 $default_args = [ 72 "method" => $method, 73 "headers" => [ 74 "Authorization" => "Bearer " . $access_token, 75 "Content-Type" => "application/json", 76 ], 77 "timeout" => 30, 78 ]; 79 80 $args = wp_parse_args($args, $default_args); 81 82 if (!empty($args["body"]) && is_array($args["body"])) { 83 $args["body"] = wp_json_encode($args["body"]); 84 } 85 86 $response = wp_remote_request($url, $args); 87 88 if (is_wp_error($response)) { 89 return $response; 90 } 91 92 $status_code = wp_remote_retrieve_response_code($response); 93 $body = json_decode(wp_remote_retrieve_body($response), true); 94 95 return $this->maybe_error($status_code, $body, "api_error", __("API request failed.", "wireservice")); 96 } 97 98 /** 99 * Make an authenticated request to the user's PDS. 100 * 101 * @param string $method HTTP method. 102 * @param string $endpoint API endpoint (e.g., /xrpc/com.atproto.repo.createRecord). 103 * @param array $args Request arguments. 104 * @param string $nonce Optional DPoP nonce from a previous request. 105 * @return array|\WP_Error 106 */ 107 public function pds_request( 108 string $method, 109 string $endpoint, 110 array $args = [], 111 ?string $nonce = null, 112 ) { 113 $original_args = $args; 114 115 $credentials = $this->get_pds_credentials(); 116 if (is_wp_error($credentials)) { 117 return $credentials; 118 } 119 120 $pds_endpoint = $credentials["pds_endpoint"]; 121 $pds_token = $credentials["pds_token"]; 122 $dpop_jwk = $credentials["dpop_jwk"]; 123 124 $url = rtrim($pds_endpoint, "/") . $endpoint; 125 126 $content_type = $args["headers"]["Content-Type"] ?? "application/json"; 127 unset($args["headers"]); 128 129 $headers = [ 130 "Authorization" => "DPoP " . $pds_token, 131 "Content-Type" => $content_type, 132 ]; 133 134 if (!empty($dpop_jwk)) { 135 $dpop_proof = $this->generate_dpop_header($dpop_jwk, $method, $url, $pds_token, $nonce); 136 if ($dpop_proof) { 137 $headers["DPoP"] = $dpop_proof; 138 } 139 } 140 141 $default_args = [ 142 "method" => $method, 143 "headers" => $headers, 144 "timeout" => 30, 145 ]; 146 147 $args = wp_parse_args($args, $default_args); 148 149 if (!empty($args["body"]) && is_array($args["body"])) { 150 $args["body"] = wp_json_encode($args["body"]); 151 } 152 153 $response = wp_remote_request($url, $args); 154 155 if (is_wp_error($response)) { 156 return $response; 157 } 158 159 $status_code = wp_remote_retrieve_response_code($response); 160 $body = json_decode(wp_remote_retrieve_body($response), true); 161 162 $response_nonce = $this->store_response_nonce($response, $dpop_jwk, $url); 163 164 if ($this->is_dpop_nonce_error($status_code, $body) && $response_nonce && $nonce === null) { 165 return $this->pds_request($method, $endpoint, $original_args, $response_nonce); 166 } 167 168 return $this->maybe_error($status_code, $body, "pds_error", __("PDS request failed.", "wireservice")); 169 } 170 171 /** 172 * Make a GET request to the OAuth service. 173 * 174 * @param string $endpoint API endpoint. 175 * @param array $params Query parameters. 176 * @return array|\WP_Error 177 */ 178 public function get(string $endpoint, array $params = []) 179 { 180 if (!empty($params)) { 181 $endpoint .= "?" . http_build_query($params); 182 } 183 184 return $this->request("GET", $endpoint); 185 } 186 187 /** 188 * Make a POST request to the OAuth service. 189 * 190 * @param string $endpoint API endpoint. 191 * @param array $body Request body. 192 * @return array|\WP_Error 193 */ 194 public function post(string $endpoint, array $body = []) 195 { 196 return $this->request("POST", $endpoint, ["body" => $body]); 197 } 198 199 /** 200 * Make a GET request to the PDS. 201 * 202 * @param string $endpoint API endpoint. 203 * @param array $params Query parameters. 204 * @return array|\WP_Error 205 */ 206 public function pds_get(string $endpoint, array $params = []) 207 { 208 if (!empty($params)) { 209 $endpoint .= "?" . http_build_query($params); 210 } 211 212 return $this->pds_request("GET", $endpoint); 213 } 214 215 /** 216 * Make a POST request to the PDS. 217 * 218 * @param string $endpoint API endpoint. 219 * @param array $body Request body. 220 * @return array|\WP_Error 221 */ 222 public function pds_post(string $endpoint, array $body = []) 223 { 224 return $this->pds_request("POST", $endpoint, ["body" => $body]); 225 } 226 227 /** 228 * Get session information from the OAuth service. 229 * 230 * @return array|\WP_Error 231 */ 232 public function get_session() 233 { 234 if ($this->session !== null) { 235 return $this->session; 236 } 237 238 $session = $this->get("/api/atprotocol/session"); 239 240 if (!is_wp_error($session)) { 241 $this->session = $session; 242 } 243 244 return $session; 245 } 246 247 /** 248 * Get the user's DID from the session. 249 * 250 * @return string|\WP_Error 251 */ 252 public function get_did() 253 { 254 $session = $this->get_session(); 255 256 if (is_wp_error($session)) { 257 return $session; 258 } 259 260 return $session["did"] ?? 261 new \WP_Error("no_did", __("DID not found in session.", "wireservice")); 262 } 263 264 /** 265 * Get PDS credentials from session. 266 * 267 * @return array|\WP_Error Array with pds_endpoint, pds_token, dpop_jwk or error. 268 */ 269 private function get_pds_credentials() 270 { 271 if ($this->pds_credentials !== null) { 272 return $this->pds_credentials; 273 } 274 275 $session = $this->get_session(); 276 277 if (is_wp_error($session)) { 278 return $session; 279 } 280 281 $pds_endpoint = $session["pds_endpoint"] ?? null; 282 $pds_token = $session["access_token"] ?? null; 283 $dpop_jwk = $session["dpop_jwk"] ?? null; 284 285 if (empty($pds_endpoint) || empty($pds_token)) { 286 return new \WP_Error( 287 "no_pds", 288 __("PDS endpoint or token not available.", "wireservice"), 289 ); 290 } 291 292 $this->pds_credentials = [ 293 "pds_endpoint" => $pds_endpoint, 294 "pds_token" => $pds_token, 295 "dpop_jwk" => $dpop_jwk, 296 ]; 297 298 return $this->pds_credentials; 299 } 300 301 /** 302 * Generate a DPoP proof header value. 303 * 304 * @param array $dpop_jwk The DPoP JWK. 305 * @param string $method HTTP method. 306 * @param string $url Full request URL. 307 * @param string $pds_token Access token. 308 * @param string|null $nonce Optional nonce. 309 * @return string|null The DPoP proof or null. 310 */ 311 private function generate_dpop_header( 312 array $dpop_jwk, 313 string $method, 314 string $url, 315 string $pds_token, 316 ?string $nonce = null, 317 ): ?string { 318 $proof = DPoP::generate_proof($dpop_jwk, $method, $url, $nonce, $pds_token); 319 return $proof !== false ? $proof : null; 320 } 321 322 /** 323 * Store DPoP nonce from response if present. 324 * 325 * @param array|object $response WordPress HTTP response. 326 * @param array|null $dpop_jwk The DPoP JWK. 327 * @param string $url Request URL. 328 * @return string|null The nonce if present. 329 */ 330 private function store_response_nonce($response, ?array $dpop_jwk, string $url): ?string 331 { 332 $nonce = wp_remote_retrieve_header($response, "dpop-nonce"); 333 if ($nonce && !empty($dpop_jwk)) { 334 DPoP::store_nonce($dpop_jwk, $url, $nonce); 335 } 336 return $nonce ?: null; 337 } 338 339 /** 340 * Check if response indicates a DPoP nonce error. 341 * 342 * @param int $status_code HTTP status code. 343 * @param array $body Response body. 344 * @return bool True if nonce error. 345 */ 346 private function is_dpop_nonce_error(int $status_code, ?array $body): bool 347 { 348 return in_array($status_code, [400, 401], true) && 349 isset($body["error"]) && 350 $body["error"] === "use_dpop_nonce"; 351 } 352 353 /** 354 * Create a record on the PDS. 355 * 356 * @param string $collection The collection NSID. 357 * @param array $record The record data. 358 * @param string $rkey Optional record key. 359 * @return array|\WP_Error 360 */ 361 public function create_record( 362 string $collection, 363 array $record, 364 ?string $rkey = null, 365 ) { 366 $did = $this->get_did(); 367 368 if (is_wp_error($did)) { 369 return $did; 370 } 371 372 $body = [ 373 "repo" => $did, 374 "collection" => $collection, 375 "record" => $record, 376 ]; 377 378 if ($rkey !== null) { 379 $body["rkey"] = $rkey; 380 } 381 382 return $this->pds_post("/xrpc/com.atproto.repo.createRecord", $body); 383 } 384 385 /** 386 * Update a record on the PDS. 387 * 388 * @param string $collection The collection NSID. 389 * @param string $rkey The record key. 390 * @param array $record The record data. 391 * @return array|\WP_Error 392 */ 393 public function put_record(string $collection, string $rkey, array $record) 394 { 395 $did = $this->get_did(); 396 397 if (is_wp_error($did)) { 398 return $did; 399 } 400 401 return $this->pds_post("/xrpc/com.atproto.repo.putRecord", [ 402 "repo" => $did, 403 "collection" => $collection, 404 "rkey" => $rkey, 405 "record" => $record, 406 ]); 407 } 408 409 /** 410 * Delete a record from the PDS. 411 * 412 * @param string $collection The collection NSID. 413 * @param string $rkey The record key. 414 * @return array|\WP_Error 415 */ 416 public function delete_record(string $collection, string $rkey) 417 { 418 $did = $this->get_did(); 419 420 if (is_wp_error($did)) { 421 return $did; 422 } 423 424 return $this->pds_post("/xrpc/com.atproto.repo.deleteRecord", [ 425 "repo" => $did, 426 "collection" => $collection, 427 "rkey" => $rkey, 428 ]); 429 } 430 431 /** 432 * Get a single record from the PDS. 433 * 434 * @param string $collection The collection NSID. 435 * @param string $rkey The record key. 436 * @return array|\WP_Error 437 */ 438 public function get_record(string $collection, string $rkey) 439 { 440 $did = $this->get_did(); 441 442 if (is_wp_error($did)) { 443 return $did; 444 } 445 446 return $this->pds_get("/xrpc/com.atproto.repo.getRecord", [ 447 "repo" => $did, 448 "collection" => $collection, 449 "rkey" => $rkey, 450 ]); 451 } 452 453 /** 454 * List records in a collection from the PDS. 455 * 456 * @param string $collection The collection NSID. 457 * @param int $limit Max records to return (default 50, max 100). 458 * @param string|null $cursor Pagination cursor. 459 * @return array|\WP_Error 460 */ 461 public function list_records( 462 string $collection, 463 int $limit = 50, 464 ?string $cursor = null, 465 ) { 466 $did = $this->get_did(); 467 468 if (is_wp_error($did)) { 469 return $did; 470 } 471 472 $params = [ 473 "repo" => $did, 474 "collection" => $collection, 475 "limit" => $limit, 476 ]; 477 478 if ($cursor !== null) { 479 $params["cursor"] = $cursor; 480 } 481 482 return $this->pds_get("/xrpc/com.atproto.repo.listRecords", $params); 483 } 484 485 /** 486 * Upload a blob to the PDS. 487 * 488 * @param string $file_path The local file path. 489 * @param string $mime_type The MIME type of the file. 490 * @return array|\WP_Error The blob reference or error. 491 */ 492 public function upload_blob(string $file_path, string $mime_type) 493 { 494 if (!file_exists($file_path)) { 495 return new \WP_Error( 496 "file_not_found", 497 __("File not found.", "wireservice"), 498 ); 499 } 500 501 $file_contents = file_get_contents($file_path); 502 if ($file_contents === false) { 503 return new \WP_Error( 504 "file_read_error", 505 __("Could not read file.", "wireservice"), 506 ); 507 } 508 509 return $this->pds_request("POST", "/xrpc/com.atproto.repo.uploadBlob", [ 510 "body" => $file_contents, 511 "headers" => ["Content-Type" => $mime_type], 512 "timeout" => 60, 513 ]); 514 } 515 516 /** 517 * Build a WP_Error from a failed response, or return the decoded body. 518 * 519 * @param int $status_code HTTP status code. 520 * @param array|null $body Decoded response body. 521 * @param string $error_code WP_Error code. 522 * @param string $default_msg Fallback error message. 523 * @return array|\WP_Error 524 */ 525 private function maybe_error( 526 int $status_code, 527 ?array $body, 528 string $error_code, 529 string $default_msg, 530 ) { 531 if ($status_code >= 400) { 532 $error_message = $body["error"] 533 ?? ($body["message"] ?? $default_msg); 534 return new \WP_Error($error_code, $error_message, [ 535 "status" => $status_code, 536 ]); 537 } 538 539 return $body; 540 } 541}