Sync your WordPress posts to standard.site records on your PDS
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}