PluginProbe
ActivityPub / trunk
ActivityPub vtrunk
9.3.1 9.3.0 9.2.2 9.2.1 9.2.0 9.1.0 9.0.2 9.0.1 9.0.0 8.3.0 8.2.1 8.2.0 8.1.1 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.2.0 1.3.0 2.0.0 2.0.1 2.1.0 2.1.1 All 160 releases
activitypub / includes / class-application.php

class-application.php in ActivityPub trunk, at includes/class-application.php

341 lines 8.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Application class file.
4 *
5 * @package Activitypub
6 */
7
8 namespace Activitypub;
9
10 /**
11 * ActivityPub Application Class.
12 *
13 * The Application is not a real actor in the plugin's internal sense —
14 * it cannot be followed, addressed, or interacted with. It exists only as:
15 * 1. A JSON-LD document at /wp-json/activitypub/1.0/application
16 * 2. A signing identity for outbound HTTP GET requests
17 *
18 * This class provides static utility methods for the Application actor,
19 * primarily key management for HTTP Signatures.
20 *
21 * @since 9.1.0
22 */
23 class Application {
24 /**
25 * The option key for the Application key pair.
26 *
27 * @var string
28 */
29 const KEYPAIR_OPTION_KEY = 'activitypub_application_keypair';
30
31 /**
32 * The preferred username for the Application actor.
33 *
34 * @var string
35 */
36 const USERNAME = 'application';
37
38 /**
39 * Initialize the class, registering WordPress hooks.
40 *
41 * @since 9.1.0
42 */
43 public static function init() {
44 /*
45 * Priority 2: must run after Integration\Webfinger::add_pseudo_user_discovery (priority 1),
46 * which returns WP_Error for 'application' since it is not in the Actors collection.
47 */
48 \add_filter( 'webfinger_data', array( self::class, 'add_webfinger_discovery' ), 2, 2 );
49 }
50
51 /**
52 * WebFinger discovery filter callback.
53 *
54 * @since 9.1.0
55 *
56 * @param array $jrd The jrd array.
57 * @param string $uri The WebFinger resource.
58 *
59 * @return array The jrd array or Application WebFinger data.
60 */
61 public static function add_webfinger_discovery( $jrd, $uri ) {
62 /*
63 * Respect a profile already resolved at an earlier priority — for example
64 * on sites whose blog identifier was set to "application" before the name
65 * was reserved for the Application actor.
66 */
67 if ( $jrd && ! \is_wp_error( $jrd ) ) {
68 return $jrd;
69 }
70
71 $data = self::get_webfinger_data( $uri );
72
73 if ( $data ) {
74 return $data;
75 }
76
77 return $jrd;
78 }
79
80 /**
81 * Returns the Application actor ID (URL).
82 *
83 * @since 9.1.0
84 *
85 * @return string The Application ID.
86 */
87 public static function get_id() {
88 return get_rest_url_by_path( 'application' );
89 }
90
91 /**
92 * Returns the pretty URL for the Application actor.
93 *
94 * @since 9.1.0
95 *
96 * @return string The Application URL.
97 */
98 public static function get_url() {
99 return self::get_id();
100 }
101
102 /**
103 * Returns the WebFinger identifier for the Application.
104 *
105 * @since 9.1.0
106 *
107 * @return string The WebFinger identifier (e.g. application@example.com).
108 */
109 public static function get_webfinger() {
110 return self::USERNAME . '@' . home_host();
111 }
112
113 /**
114 * Returns the icon for the Application.
115 *
116 * @since 9.1.0
117 *
118 * @return string[] The icon array with 'type' and 'url'.
119 */
120 public static function get_icon() {
121 return site_icon();
122 }
123
124 /**
125 * Returns the published date of the Application.
126 *
127 * @since 9.1.0
128 *
129 * @return string The published date in RFC3339 format.
130 */
131 public static function get_published() {
132 $first_post = new \WP_Query(
133 array(
134 'orderby' => 'date',
135 'order' => 'ASC',
136 'posts_per_page' => 1,
137 'no_found_rows' => true,
138 'ignore_sticky_posts' => true,
139 'update_post_meta_cache' => false,
140 'update_post_term_cache' => false,
141 )
142 );
143
144 $time = false;
145
146 if ( ! empty( $first_post->posts[0] ) ) {
147 $time = \strtotime( $first_post->posts[0]->post_date_gmt );
148 }
149
150 if ( false === $time ) {
151 $time = \time();
152 }
153
154 return \gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, $time );
155 }
156
157 /**
158 * Returns the key ID for HTTP signatures.
159 *
160 * @since 9.1.0
161 *
162 * @return string The key ID.
163 */
164 public static function get_key_id() {
165 return self::get_id() . '#main-key';
166 }
167
168 /**
169 * Returns the public key PEM for the Application.
170 *
171 * @since 9.1.0
172 *
173 * @return string|null The public key PEM.
174 */
175 public static function get_public_key() {
176 $key_pair = self::get_keypair();
177 return $key_pair['public_key'];
178 }
179
180 /**
181 * Returns the private key for the Application.
182 *
183 * @since 9.1.0
184 *
185 * @return string|null The private key.
186 */
187 public static function get_private_key() {
188 $key_pair = self::get_keypair();
189 return $key_pair['private_key'];
190 }
191
192 /**
193 * Returns the key pair for the Application.
194 *
195 * @since 9.1.0
196 *
197 * @return array The key pair with 'public_key' and 'private_key'.
198 */
199 public static function get_keypair() {
200 return Signature::get_key_pair(
201 self::KEYPAIR_OPTION_KEY,
202 function () {
203 return self::check_legacy_key_pair();
204 }
205 );
206 }
207
208 /**
209 * Checks for legacy key pair options.
210 *
211 * @since 9.1.0
212 *
213 * @return array|false The key pair or false.
214 */
215 private static function check_legacy_key_pair() {
216 /*
217 * Generic actor key pair option (array form) used for the former application
218 * user (ID -1). Checked here so the key survives even if get_keypair() runs
219 * before migrate_application_keypair_option() has had a chance to rename it.
220 */
221 $key_pair = \get_option( 'activitypub_keypair_for_-1' );
222
223 if ( \is_array( $key_pair ) && ! empty( $key_pair['public_key'] ) && ! empty( $key_pair['private_key'] ) ) {
224 return array(
225 'private_key' => $key_pair['private_key'],
226 'public_key' => $key_pair['public_key'],
227 );
228 }
229
230 // Even older separate key options.
231 $public_key = \get_option( 'activitypub_application_user_public_key' );
232 $private_key = \get_option( 'activitypub_application_user_private_key' );
233
234 if ( ! empty( $public_key ) && \is_string( $public_key ) && ! empty( $private_key ) && \is_string( $private_key ) ) {
235 return array(
236 'private_key' => $private_key,
237 'public_key' => $public_key,
238 );
239 }
240
241 return false;
242 }
243
244 /**
245 * Check if the URI matches the Application actor and return WebFinger data.
246 *
247 * @since 9.1.0
248 *
249 * Handles the following URI formats:
250 * - acct:application@example.com / application@example.com
251 * - http(s)://example.com/@application
252 * - http(s)://example.com/wp-json/activitypub/1.0/application
253 *
254 * @param string $uri The WebFinger resource URI.
255 *
256 * @return array|false The WebFinger profile data or false if not the Application.
257 */
258 public static function get_webfinger_data( $uri ) {
259 if ( ! self::is_application_resource( $uri ) ) {
260 return false;
261 }
262
263 $application_id = self::get_id();
264
265 return array(
266 'subject' => sprintf( 'acct:%s', self::get_webfinger() ),
267 'aliases' => array( $application_id ),
268 'links' => array(
269 array(
270 'rel' => 'self',
271 'type' => 'application/activity+json',
272 'href' => $application_id,
273 'properties' => array(
274 'https://www.w3.org/ns/activitystreams#type' => 'Application',
275 ),
276 ),
277 ),
278 );
279 }
280
281 /**
282 * Check if a URI refers to the Application actor.
283 *
284 * @since 9.1.0
285 *
286 * @param string $uri The URI to check.
287 *
288 * @return bool True if the URI refers to the Application.
289 */
290 public static function is_application_resource( $uri ) {
291 $identifier_and_host = Webfinger::get_identifier_and_host( $uri );
292
293 if ( \is_wp_error( $identifier_and_host ) ) {
294 return false;
295 }
296
297 list( $identifier, $host ) = $identifier_and_host;
298
299 /*
300 * The resource must point at this site, or at its pre-migration host. Both sides are folded:
301 * the requested host is whatever the caller typed, and the stored ones are whatever an admin did.
302 */
303 $host = normalize_host( fold_host( $host ) );
304
305 // A host of `.` or `[]` folds away to nothing, which would otherwise match an unset old host.
306 if ( '' === $host ) {
307 return false;
308 }
309
310 if ( normalize_host( fold_host( home_host() ) ) !== $host && normalize_host( fold_host( \get_option( 'activitypub_old_host' ) ) ) !== $host ) {
311 return false;
312 }
313
314 // URL forms: the REST actor ID or the pretty /@application profile path.
315 if ( false !== \strpos( $identifier, '://' ) ) {
316 /*
317 * Fold the scheme and authority, which are case-insensitive, before comparing. The path
318 * keeps its case. Without this the host guard above accepts a spelling that this then
319 * rejects.
320 */
321 $identifier = \preg_replace_callback(
322 '#^[a-zA-Z][a-zA-Z0-9+.\-]*://[^/?\#]+#',
323 static function ( $authority ) {
324 return \strtolower( $authority[0] );
325 },
326 $identifier
327 );
328
329 $identifier = normalize_url( $identifier );
330
331 return normalize_url( self::get_id() ) === $identifier
332 || normalize_url( \home_url( '/@' . self::USERNAME ) ) === $identifier;
333 }
334
335 // acct form: application@host.
336 $username = \strstr( \str_replace( 'acct:', '', $identifier ), '@', true );
337
338 return self::USERNAME === $username;
339 }
340 }
341