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 / rest / class-proxy-controller.php

class-proxy-controller.php in ActivityPub trunk, at includes/rest/class-proxy-controller.php

286 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 * Proxy Controller file.
4 *
5 * Implements the proxyUrl endpoint for C2S clients to fetch remote ActivityPub objects.
6 *
7 * @package Activitypub
8 * @see https://www.w3.org/wiki/ActivityPub/Primer/proxyUrl_endpoint
9 */
10
11 namespace Activitypub\Rest;
12
13 use Activitypub\Collection\Remote_Actors;
14 use Activitypub\Http;
15 use Activitypub\OAuth\Scope;
16 use Activitypub\Webfinger;
17
18 /**
19 * Proxy Controller.
20 *
21 * Provides a bridge between C2S OAuth authentication and S2S HTTP Signature authentication.
22 * Allows C2S clients to fetch remote ActivityPub objects through their home server.
23 */
24 class Proxy_Controller extends \WP_REST_Controller {
25 use Event_Stream;
26 use Verification;
27
28 /**
29 * The namespace of this controller's route.
30 *
31 * @var string
32 */
33 protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
34
35 /**
36 * The base of this controller's route.
37 *
38 * @var string
39 */
40 protected $rest_base = 'proxy';
41
42 /**
43 * Register routes.
44 */
45 public function register_routes() {
46 \register_rest_route(
47 $this->namespace,
48 '/' . $this->rest_base,
49 array(
50 array(
51 'methods' => \WP_REST_Server::CREATABLE,
52 'callback' => array( $this, 'create_item' ),
53
54 /*
55 * The Basic Profile puts `proxyUrl` under a read scope. The POST carries the
56 * target URL, and what this persists is a local cache of the remote object
57 * plus a rate-limit transient, not content attributed to the actor, so it is
58 * not a write in the sense `write` grants.
59 */
60 'permission_callback' => function ( $request ) {
61 return $this->verify_authentication( $request, Scope::READ );
62 },
63 'args' => array(
64 'id' => array(
65 'description' => 'The remote ActivityPub object to fetch: an HTTPS URL or an acct identifier (`user@host`, `@user@host`, or `acct:user@host`).',
66 'type' => 'string',
67 'required' => true,
68 'sanitize_callback' => array( $this, 'sanitize_url' ),
69 'validate_callback' => array( $this, 'validate_url' ),
70 ),
71 ),
72 ),
73 'schema' => array( $this, 'get_item_schema' ),
74 )
75 );
76
77 \register_rest_route(
78 $this->namespace,
79 '/' . $this->rest_base . '/stream',
80 array(
81 array(
82 'methods' => \WP_REST_Server::READABLE,
83 'callback' => array( $this, 'get_stream' ),
84 'permission_callback' => array( $this, 'get_stream_permissions_check' ),
85 'args' => array(
86 'id' => array(
87 'description' => 'The remote actor identifier (URL or WebFinger acct) whose eventStream to proxy.',
88 'type' => 'string',
89 'required' => true,
90 'sanitize_callback' => array( $this, 'sanitize_url' ),
91 'validate_callback' => array( $this, 'validate_url' ),
92 ),
93 ),
94 ),
95 )
96 );
97 }
98
99 /**
100 * Sanitize the `id` parameter.
101 *
102 * Accepts either an HTTPS URL or an acct identifier (`user@host`,
103 * `@user@host`, or `acct:user@host`). Acct identifiers are returned
104 * as-is; URLs are run through `sanitize_url()`. Matches the dual-shape
105 * contract of `Remote_Actors::fetch_by_various()`.
106 *
107 * @see https://developer.wordpress.org/reference/functions/sanitize_url/
108 *
109 * @param string $url The urlencoded URL or acct identifier to sanitize.
110 * @return string The sanitized value.
111 */
112 public function sanitize_url( $url ) {
113 $decoded = \urldecode( $url );
114
115 if ( Webfinger::is_acct( $decoded ) ) {
116 return $decoded;
117 }
118
119 return \sanitize_url( $decoded );
120 }
121
122 /**
123 * Validate the `id` parameter.
124 *
125 * Accepts either an HTTPS URL (validated via `wp_http_validate_url()`,
126 * which blocks local/private IPs and restricts ports) or an acct
127 * identifier in any of the forms accepted by `Webfinger::is_acct()`:
128 * `user@host`, `@user@host`, or `acct:user@host`. Matches the
129 * dual-shape contract of `Remote_Actors::fetch_by_various()`.
130 *
131 * @see https://developer.wordpress.org/reference/functions/wp_http_validate_url/
132 *
133 * @param string $url The URL or acct identifier to validate.
134 * @return bool True if valid, false otherwise.
135 */
136 public function validate_url( $url ) {
137 $decoded_url = \urldecode( $url );
138
139 if ( Webfinger::is_acct( $decoded_url ) ) {
140 return true;
141 }
142
143 // Must be HTTPS.
144 if ( 'https' !== \wp_parse_url( $decoded_url, PHP_URL_SCHEME ) ) {
145 return false;
146 }
147
148 // Use WordPress built-in validation (blocks local IPs, restricts ports).
149 return (bool) \wp_http_validate_url( $decoded_url );
150 }
151
152 /**
153 * Fetch a remote ActivityPub object via the proxy.
154 *
155 * @see https://www.w3.org/wiki/ActivityPub/Primer/proxyUrl_endpoint
156 *
157 * @param \WP_REST_Request $request Full details about the request.
158 * @return \WP_REST_Response|\WP_Error Response object on success, WP_Error on failure.
159 */
160 public function create_item( $request ) {
161 // Rate-limit proxy requests (max 30 per minute per user).
162 $user_id = \get_current_user_id();
163 $transient_key = 'ap_proxy_' . $user_id;
164 $count = (int) \get_transient( $transient_key );
165
166 if ( $count >= 30 ) {
167 return new \WP_Error(
168 'activitypub_rate_limit',
169 \__( 'Too many proxy requests. Please try again later.', 'activitypub' ),
170 array( 'status' => 429 )
171 );
172 }
173
174 \set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS );
175
176 $url = $request->get_param( 'id' );
177
178 // Try to fetch as an actor first using Remote_Actors which handles caching.
179 $post = Remote_Actors::fetch_by_various( $url );
180
181 if ( ! \is_wp_error( $post ) ) {
182 $actor = Remote_Actors::get_actor( $post );
183
184 if ( ! \is_wp_error( $actor ) ) {
185 $response = new \WP_REST_Response( $actor->to_array(), 200 );
186 $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
187
188 return $response;
189 }
190 }
191
192 /*
193 * Fall back to fetching as a generic object. Actors are already resolved and
194 * cached above via fetch_by_various(), so this path only proxies the object.
195 */
196 $object = Http::get_remote_object( $url );
197
198 if ( \is_wp_error( $object ) ) {
199 /*
200 * Pass on a status the remote actually returned, so a caller can tell a missing object
201 * from an unreachable host. `Http::get()` uses the response code as the error code;
202 * this method's own rejections use a string code and stay a 502, because a document we
203 * refused is not the caller's request being wrong.
204 */
205 $code = $object->get_error_code();
206 $status = \is_numeric( $code ) ? (int) $code : 0;
207
208 return new \WP_Error(
209 'activitypub_fetch_failed',
210 \__( 'Failed to fetch the remote object.', 'activitypub' ),
211 array( 'status' => $status ?: 502 )
212 );
213 }
214
215 $response = new \WP_REST_Response( $object, 200 );
216 $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) );
217
218 return $response;
219 }
220
221 /**
222 * Get the schema for the proxy endpoint.
223 *
224 * @return array Schema array.
225 */
226 public function get_item_schema() {
227 return array(
228 '$schema' => 'http://json-schema.org/draft-04/schema#',
229 'title' => 'proxy',
230 'type' => 'object',
231 'properties' => array(
232 'id' => array(
233 'description' => \__( 'The URI of the remote ActivityPub object.', 'activitypub' ),
234 'type' => 'string',
235 'format' => 'uri',
236 'context' => array( 'view' ),
237 ),
238 ),
239 );
240 }
241
242 /**
243 * Proxy a remote eventStream.
244 *
245 * Fetches the remote object to discover its eventStream URL,
246 * then opens a streaming connection and relays SSE events.
247 *
248 * @param \WP_REST_Request $request Full details about the request.
249 *
250 * @return \WP_Error|void WP_Error on failure, exits on success.
251 */
252 public function get_stream( $request ) {
253 $remote_id = $request->get_param( 'id' );
254
255 $object = Http::get_remote_object( $remote_id );
256
257 if ( \is_wp_error( $object ) ) {
258 return new \WP_Error(
259 'activitypub_proxy_fetch_failed',
260 \__( 'Failed to fetch the remote object.', 'activitypub' ),
261 array( 'status' => 502 )
262 );
263 }
264
265 $stream_url = isset( $object['eventStream'] ) ? $object['eventStream'] : null;
266
267 if ( ! $stream_url ) {
268 return new \WP_Error(
269 'activitypub_no_event_stream',
270 \__( 'The remote object does not advertise an eventStream.', 'activitypub' ),
271 array( 'status' => 404 )
272 );
273 }
274
275 if ( ! $this->validate_url( $stream_url ) ) {
276 return new \WP_Error(
277 'activitypub_invalid_event_stream',
278 \__( 'The remote eventStream URL is not valid.', 'activitypub' ),
279 array( 'status' => 400 )
280 );
281 }
282
283 $this->relay_remote_stream( $stream_url );
284 }
285 }
286