diff --git a/.github/changelog/c2s-media-upload b/.github/changelog/c2s-media-upload new file mode 100644 index 0000000000..ea6c7b4a5d --- /dev/null +++ b/.github/changelog/c2s-media-upload @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Allow third-party apps to upload images, audio, and video to your site via the standard ActivityPub media upload endpoint. diff --git a/FEDERATION.md b/FEDERATION.md index d0c2b4ff78..191ca74277 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -239,6 +239,12 @@ When the ActivityPub API option is enabled, the plugin exposes OAuth 2.0 endpoin The loopback allowance from RFC 8252 applies *only* to redirect URI matching. Reserved-but-not-loopback addresses (`0.0.0.0`, link-local `169.254.0.0/16`, RFC1918 private ranges, etc.) are not treated as loopback. CIMD metadata URLs must use `https://`, and the metadata host is resolved and validated against private/reserved ranges before any fetch. Loopback CIMD origins are not supported, even on dev installs. +### Media Upload + +Implements the W3C SocialCG `uploadMedia` endpoint at `POST /actors/{user_id}/uploadMedia`. Accepts `multipart/form-data` with a required `file` part and an optional `object` JSON-LD shell, plus a Pleroma-compatible `description` form field as a synonym for `object.name` (alt text). Returns `201 Created` with a `Location` header pointing at the new attachment's AP id and the bare `Image`/`Audio`/`Video` object as the body. Requires the `upload` OAuth scope. The endpoint is advertised on `User` and `Blog` actors via `endpoints.uploadMedia` and is gated behind the "ActivityPub API" site setting. + +References: [W3C SocialCG wiki — MediaUpload](https://www.w3.org/wiki/SocialCG/ActivityPub/MediaUpload), [Pleroma `uploadMedia` extension](https://docs-develop.pleroma.social/backend/development/ap_extensions/#uploadmedia). + ## Additional documentation - Plugin Documentation: [docs/readme.md](docs/readme.md) diff --git a/activitypub.php b/activitypub.php index 2e52970626..eb1b53d3e3 100644 --- a/activitypub.php +++ b/activitypub.php @@ -68,6 +68,7 @@ function rest_init() { ( new Rest\OAuth\Authorization_Controller() )->register_routes(); ( new Rest\OAuth\Clients_Controller() )->register_routes(); ( new Rest\OAuth\Token_Controller() )->register_routes(); + ( new Rest\Media_Controller() )->register_routes(); } ( new Rest\Outbox_Controller() )->register_routes(); ( new Rest\Post_Controller() )->register_routes(); diff --git a/includes/class-attachments.php b/includes/class-attachments.php index 0bfe805ad2..229ee645dd 100644 --- a/includes/class-attachments.php +++ b/includes/class-attachments.php @@ -380,12 +380,16 @@ private static function is_allowed_local_path( $file_path ) { * Uses WordPress image editor to resize large images and convert them * to WebP format for better compression while maintaining quality. * + * @since 1.0.0 + * * @param string $file_path Path to the image file. * @param int $max_dimension Maximum width/height in pixels. * - * @return string The optimized file path. + * @return string The optimized file path. If the source was already an + * optimal image (or not an image at all), the original + * path is returned unchanged. */ - private static function optimize_image( $file_path, $max_dimension ) { + public static function optimize_image( $file_path, $max_dimension ) { // Check if it's an image. $mime_type = \wp_check_filetype( $file_path )['type'] ?? ''; if ( ! $mime_type || ! \str_starts_with( $mime_type, 'image/' ) ) { diff --git a/includes/functions.php b/includes/functions.php index 6eb951fa25..1618a00315 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -30,6 +30,33 @@ function get_object_id( $wp_object ) { return null; } +/** + * Return the canonical ActivityPub URL for a WordPress attachment. + * + * The URL points at the plugin's media REST endpoint, which serves the + * AP-JSON representation of the attachment. Used as the `id` of an + * uploaded media object and as the value of the `Location` header on + * a successful `uploadMedia` response. + * + * @since unreleased + * + * @param int $attachment_id The WordPress attachment ID. + * @return string|false The canonical URL, or false if $attachment_id is not an attachment. + */ +function get_attachment_ap_id( $attachment_id ) { + $attachment_id = (int) $attachment_id; + + if ( $attachment_id <= 0 ) { + return false; + } + + if ( 'attachment' !== \get_post_type( $attachment_id ) ) { + return false; + } + + return get_rest_url_by_path( 'media/' . $attachment_id ); +} + /** * Convert a string from camelCase to snake_case. * diff --git a/includes/model/class-blog.php b/includes/model/class-blog.php index c13f193052..e69ded3013 100644 --- a/includes/model/class-blog.php +++ b/includes/model/class-blog.php @@ -419,7 +419,7 @@ public function get_following() { * @return string[]|null The endpoints. */ public function get_endpoints() { - return array( + $endpoints = array( 'sharedInbox' => get_rest_url_by_path( 'inbox' ), 'oauthAuthorizationEndpoint' => get_rest_url_by_path( 'oauth/authorize' ), 'oauthTokenEndpoint' => get_rest_url_by_path( 'oauth/token' ), @@ -427,6 +427,12 @@ public function get_endpoints() { 'proxyUrl' => get_rest_url_by_path( 'proxy' ), 'proxyEventStream' => get_rest_url_by_path( 'proxy/stream' ), ); + + if ( \get_option( 'activitypub_api', false ) ) { + $endpoints['uploadMedia'] = get_rest_url_by_path( sprintf( 'actors/%d/uploadMedia', $this->get__id() ) ); + } + + return $endpoints; } /** diff --git a/includes/model/class-user.php b/includes/model/class-user.php index 199e73218a..f9a4261af3 100644 --- a/includes/model/class-user.php +++ b/includes/model/class-user.php @@ -328,7 +328,7 @@ public function get_featured_tags() { * @return string[]|null The endpoints. */ public function get_endpoints() { - return array( + $endpoints = array( 'sharedInbox' => get_rest_url_by_path( 'inbox' ), 'oauthAuthorizationEndpoint' => get_rest_url_by_path( 'oauth/authorize' ), 'oauthTokenEndpoint' => get_rest_url_by_path( 'oauth/token' ), @@ -336,6 +336,12 @@ public function get_endpoints() { 'proxyUrl' => get_rest_url_by_path( 'proxy' ), 'proxyEventStream' => get_rest_url_by_path( 'proxy/stream' ), ); + + if ( \get_option( 'activitypub_api', false ) ) { + $endpoints['uploadMedia'] = get_rest_url_by_path( sprintf( 'actors/%d/uploadMedia', $this->get__id() ) ); + } + + return $endpoints; } /** diff --git a/includes/oauth/class-scope.php b/includes/oauth/class-scope.php index 01fb36f632..8f653bf586 100644 --- a/includes/oauth/class-scope.php +++ b/includes/oauth/class-scope.php @@ -38,6 +38,13 @@ class Scope { */ const PROFILE = 'profile'; + /** + * Upload access scope - upload media via the uploadMedia endpoint. + * + * @since unreleased + */ + const UPLOAD = 'upload'; + /** * All available scopes. * @@ -49,6 +56,7 @@ class Scope { self::FOLLOW, self::PUSH, self::PROFILE, + self::UPLOAD, ); /** @@ -79,6 +87,7 @@ class Scope { self::FOLLOW => 'Manage following relationships', self::PUSH => 'Subscribe to real-time event streams', self::PROFILE => 'Edit actor profile', + self::UPLOAD => 'Upload media files', ); /** diff --git a/includes/rest/class-media-controller.php b/includes/rest/class-media-controller.php new file mode 100644 index 0000000000..22271ce8bc --- /dev/null +++ b/includes/rest/class-media-controller.php @@ -0,0 +1,358 @@ +namespace, + '/(?:users|actors)/(?P[-]?\d+)/uploadMedia', + array( + 'args' => array( + 'user_id' => array( + 'description' => 'The ID of the user or actor.', + 'type' => 'integer', + 'validate_callback' => array( $this, 'validate_user_id' ), + ), + ), + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'upload_item' ), + 'permission_callback' => array( $this, 'upload_permissions_check' ), + ), + ) + ); + + \register_rest_route( + $this->namespace, + '/media/(?P\d+)', + array( + 'args' => array( + 'attachment_id' => array( + 'description' => 'The ID of the WordPress attachment.', + 'type' => 'integer', + 'minimum' => 1, + 'required' => true, + ), + ), + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_item' ), + 'permission_callback' => '__return_true', + ), + ) + ); + } + + /** + * Validate that a user_id resolves to a known actor. + * + * Mirrors `Outbox_Controller::validate_user_id()` so callers cannot + * upload on behalf of an actor that the current actor-mode doesn't allow. + * + * @param mixed $value The value to validate. + * @return bool|\WP_Error True if valid, WP_Error otherwise. + */ + public function validate_user_id( $value ) { + $actor = Actors::get_by_id( $value ); + if ( \is_wp_error( $actor ) ) { + return $actor; + } + return true; + } + + /** + * Permission callback for the upload route. + * + * Requires the `upload` OAuth scope (in addition to a valid Bearer token) + * and that the authenticated user matches the `user_id` in the URL. + * + * @param \WP_REST_Request $request The request object. + * @return bool|\WP_Error True if authorized, WP_Error otherwise. + */ + public function upload_permissions_check( $request ) { + $result = OAuth_Server::check_oauth_permission( $request, Scope::UPLOAD ); + if ( true !== $result ) { + return $result; + } + + $owner_check = $this->verify_owner( $request ); + if ( true !== $owner_check ) { + return $owner_check; + } + + if ( ! \current_user_can( 'upload_files' ) ) { + return new \WP_Error( + 'activitypub_cannot_upload', + \__( 'You do not have permission to upload media.', 'activitypub' ), + array( 'status' => 403 ) + ); + } + + return true; + } + + /** + * GET /media/{attachment_id} — return the AP representation of an attachment. + * + * @param \WP_REST_Request $request The request object. + * @return \WP_REST_Response|\WP_Error Response or error. + */ + public function get_item( $request ) { + $attachment_id = (int) $request->get_param( 'attachment_id' ); + $object = $this->build_attachment_object( $attachment_id ); + + if ( \is_wp_error( $object ) ) { + return $object; + } + + $response = new \WP_REST_Response( $object, 200 ); + $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); + return $response; + } + + /** + * POST /actors/{user_id}/uploadMedia — accept a multipart upload. + * + * Accepts the W3C-wiki shape (`object` JSON + `file` binary) and the + * Pleroma shape (just `file`). Always returns the bare media object; + * never auto-wraps in a Create. Per the wiki spec the endpoint is not + * the outbox, so the client is responsible for any follow-up publish. + * + * @since unreleased + * + * @param \WP_REST_Request $request The request object. + * @return \WP_REST_Response|\WP_Error Response or error. + */ + public function upload_item( $request ) { + $files = $request->get_file_params(); + $file = isset( $files['file'] ) ? $files['file'] : null; + + if ( empty( $file ) || empty( $file['tmp_name'] ) ) { + return new \WP_Error( + 'activitypub_missing_file', + \__( 'A "file" part is required.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + + // Optional `object` part: a JSON-LD shell that supplies name (alt text), etc. + $shell = array(); + $raw_object = $request->get_param( 'object' ); + if ( ! empty( $raw_object ) ) { + $decoded = \json_decode( $raw_object, true ); + if ( ! \is_array( $decoded ) ) { + return new \WP_Error( + 'activitypub_invalid_object', + \__( 'The "object" part must be valid JSON.', 'activitypub' ), + array( 'status' => 400 ) + ); + } + $shell = $decoded; + } + + // Pleroma-style: a top-level `description` form field is a synonym for `object.name`. + if ( empty( $shell['name'] ) ) { + $description = $request->get_param( 'description' ); + if ( ! empty( $description ) && \is_string( $description ) ) { + $shell['name'] = $description; + } + } + + // Run through wp_handle_upload to apply WP's MIME validation and upload_mimes filter. + if ( ! \function_exists( 'wp_handle_upload' ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + $overrides = array( + 'test_form' => false, + 'action' => 'activitypub_upload_media', + ); + + $uploaded = \wp_handle_upload( $file, $overrides ); + + if ( isset( $uploaded['error'] ) ) { + return new \WP_Error( + 'activitypub_upload_failed', + \sanitize_text_field( (string) $uploaded['error'] ), + array( 'status' => 400 ) + ); + } + + $top_level = \strtok( (string) $uploaded['type'], '/' ); + if ( ! \in_array( $top_level, self::ALLOWED_TOP_LEVEL_TYPES, true ) ) { + \wp_delete_file( $uploaded['file'] ); + return new \WP_Error( + 'activitypub_unsupported_media_type', + \__( 'Unsupported media type.', 'activitypub' ), + array( 'status' => 415 ) + ); + } + + // Apply the same image-optimization the import path uses + // (resize + WebP conversion when supported). + $optimized = Attachments::optimize_image( $uploaded['file'], Attachments::MAX_IMAGE_DIMENSION ); + if ( $optimized !== $uploaded['file'] ) { + $uploaded['file'] = $optimized; + $uploaded['type'] = \wp_check_filetype( $optimized )['type'] ?? $uploaded['type']; + } + + // Insert the file as a media library attachment. + $user_id = (int) $request->get_param( 'user_id' ); + // For the blog actor (user_id = 0) fall back to the acting user so post_author is never 0. + $author = $user_id > 0 ? $user_id : \get_current_user_id(); + $title = isset( $shell['name'] ) ? \sanitize_text_field( $shell['name'] ) : \wp_basename( $uploaded['file'] ); + $attachment = array( + 'post_mime_type' => $uploaded['type'], + 'post_title' => $title, + 'post_content' => '', + 'post_status' => 'inherit', + 'post_author' => $author, + ); + + $attachment_id = \wp_insert_attachment( $attachment, $uploaded['file'] ); + + if ( \is_wp_error( $attachment_id ) || 0 === $attachment_id ) { + \wp_delete_file( $uploaded['file'] ); + return new \WP_Error( + 'activitypub_attachment_insert_failed', + \__( 'Failed to register uploaded file as a media library attachment.', 'activitypub' ), + array( 'status' => 500 ) + ); + } + + // Generate metadata (dimensions, intermediate sizes, etc.). + if ( ! \function_exists( 'wp_generate_attachment_metadata' ) ) { + require_once ABSPATH . 'wp-admin/includes/image.php'; + } + \wp_update_attachment_metadata( + $attachment_id, + \wp_generate_attachment_metadata( $attachment_id, $uploaded['file'] ) + ); + + // If the shell carries a name (typically alt text for images), store it. + if ( ! empty( $shell['name'] ) && 'image' === $top_level ) { + \update_post_meta( $attachment_id, '_wp_attachment_image_alt', \sanitize_text_field( $shell['name'] ) ); + } + + $object = $this->build_attachment_object( $attachment_id ); + + if ( \is_wp_error( $object ) ) { + \wp_delete_attachment( $attachment_id, true ); + return $object; + } + + $response = new \WP_REST_Response( $object, 201 ); + $response->header( 'Location', $object['id'] ); + $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); + return $response; + } + + /** + * Build the AP object for a given attachment. + * + * @param int $attachment_id The WordPress attachment ID. + * @return array|\WP_Error AP object on success, error otherwise. + */ + protected function build_attachment_object( $attachment_id ) { + if ( 'attachment' !== \get_post_type( $attachment_id ) ) { + return new \WP_Error( + 'activitypub_attachment_not_found', + \__( 'Attachment not found.', 'activitypub' ), + array( 'status' => 404 ) + ); + } + + $mime_type = (string) \get_post_mime_type( $attachment_id ); + $top_level = \strtok( $mime_type, '/' ); + + if ( ! \in_array( $top_level, self::ALLOWED_TOP_LEVEL_TYPES, true ) ) { + return new \WP_Error( + 'activitypub_unsupported_media_type', + \__( 'Unsupported media type.', 'activitypub' ), + array( 'status' => 415 ) + ); + } + + $alt = \get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ); + + /* + * Reuse the existing transformer (protected method; use anonymous-class shim). + * Known smell acknowledged in plan — leave as-is until Task 4 or later refactor. + */ + $transformer = new class( null ) extends Base { + /** + * Expose the protected transform_attachment method. + * + * @param array $media The media array with 'id' and optional 'alt'. + * @return array The ActivityStreams attachment array. + */ + public function expose_transform_attachment( $media ) { + return $this->transform_attachment( $media ); + } + }; + + $object = $transformer->expose_transform_attachment( + array( + 'id' => $attachment_id, + 'alt' => $alt, + ) + ); + + if ( empty( $object ) || ! isset( $object['type'] ) ) { + return new \WP_Error( + 'activitypub_attachment_transform_failed', + \__( 'Could not build ActivityPub object for attachment.', 'activitypub' ), + array( 'status' => 500 ) + ); + } + + $object['@context'] = 'https://www.w3.org/ns/activitystreams'; + $object['id'] = get_attachment_ap_id( $attachment_id ); + + return $object; + } +} diff --git a/tests/phpunit/tests/includes/functions/class-test-get-attachment-ap-id.php b/tests/phpunit/tests/includes/functions/class-test-get-attachment-ap-id.php new file mode 100644 index 0000000000..e30d87f669 --- /dev/null +++ b/tests/phpunit/tests/includes/functions/class-test-get-attachment-ap-id.php @@ -0,0 +1,75 @@ +attachment->create_object( + 'image.jpg', + 0, + array( + 'post_mime_type' => 'image/jpeg', + 'post_type' => 'attachment', + ) + ); + + $id = \Activitypub\get_attachment_ap_id( $attachment_id ); + + $this->assertNotEmpty( $id ); + $this->assertStringContainsString( '/activitypub/1.0/media/' . $attachment_id, $id ); + } + + /** + * Test that an invalid attachment ID returns false. + */ + public function test_returns_false_for_missing_attachment() { + $this->assertFalse( \Activitypub\get_attachment_ap_id( 999999999 ) ); + } + + /** + * Test that a non-attachment post returns false. + */ + public function test_returns_false_for_non_attachment_post() { + $post_id = self::factory()->post->create(); + $this->assertFalse( \Activitypub\get_attachment_ap_id( $post_id ) ); + } + + /** + * Test that non-positive IDs return false. + * + * @dataProvider data_non_positive_ids + * + * @param int $invalid_id The non-positive ID under test. + */ + public function test_returns_false_for_non_positive_id( $invalid_id ) { + $this->assertFalse( \Activitypub\get_attachment_ap_id( $invalid_id ) ); + } + + /** + * Data provider for non-positive IDs. + * + * @return array + */ + public function data_non_positive_ids() { + return array( + 'zero' => array( 0 ), + 'negative one' => array( -1 ), + 'negative large' => array( -999 ), + ); + } +} diff --git a/tests/phpunit/tests/includes/oauth/class-test-scope.php b/tests/phpunit/tests/includes/oauth/class-test-scope.php index ddc9179b1d..1047e37364 100644 --- a/tests/phpunit/tests/includes/oauth/class-test-scope.php +++ b/tests/phpunit/tests/includes/oauth/class-test-scope.php @@ -28,6 +28,7 @@ public function test_scope_constants_defined() { $this->assertEquals( 'follow', Scope::FOLLOW ); $this->assertEquals( 'push', Scope::PUSH ); $this->assertEquals( 'profile', Scope::PROFILE ); + $this->assertEquals( 'upload', Scope::UPLOAD ); } /** @@ -39,7 +40,8 @@ public function test_all_scopes_constant() { $this->assertContains( Scope::FOLLOW, Scope::ALL ); $this->assertContains( Scope::PUSH, Scope::ALL ); $this->assertContains( Scope::PROFILE, Scope::ALL ); - $this->assertCount( 5, Scope::ALL ); + $this->assertContains( Scope::UPLOAD, Scope::ALL ); + $this->assertCount( 6, Scope::ALL ); } /** @@ -193,6 +195,7 @@ public function test_is_valid_true() { $this->assertTrue( Scope::is_valid( 'follow' ) ); $this->assertTrue( Scope::is_valid( 'push' ) ); $this->assertTrue( Scope::is_valid( 'profile' ) ); + $this->assertTrue( Scope::is_valid( 'upload' ) ); } /** @@ -238,6 +241,7 @@ public function test_get_all_with_descriptions() { $this->assertArrayHasKey( 'follow', $result ); $this->assertArrayHasKey( 'push', $result ); $this->assertArrayHasKey( 'profile', $result ); + $this->assertArrayHasKey( 'upload', $result ); } /** diff --git a/tests/phpunit/tests/includes/rest/class-test-media-controller.php b/tests/phpunit/tests/includes/rest/class-test-media-controller.php new file mode 100644 index 0000000000..46bd3f858a --- /dev/null +++ b/tests/phpunit/tests/includes/rest/class-test-media-controller.php @@ -0,0 +1,482 @@ +user->create( array( 'role' => 'author' ) ); + \get_user_by( 'ID', self::$user_id )->add_cap( 'activitypub' ); + } + + /** + * Set up test environment. + */ + public function set_up() { + parent::set_up(); + \add_filter( 'activitypub_oauth_check_permission', '__return_true' ); + + \update_option( 'activitypub_api', true ); + \do_action( 'rest_api_init' ); + } + + /** + * Tear down test environment. + */ + public function tear_down() { + \remove_filter( 'activitypub_oauth_check_permission', '__return_true' ); + parent::tear_down(); + } + + /** + * Test route registration. + * + * @covers ::register_routes + */ + public function test_register_routes() { + $routes = \rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/' . ACTIVITYPUB_REST_NAMESPACE . '/(?:users|actors)/(?P[-]?\d+)/uploadMedia', $routes ); + $this->assertArrayHasKey( '/' . ACTIVITYPUB_REST_NAMESPACE . '/media/(?P\d+)', $routes ); + } + + /** + * Test GET /media/{id} returns the AP representation of an image. + * + * @covers ::get_item + */ + public function test_get_item() { + $attachment_id = self::factory()->attachment->create_object( + 'image.jpg', + 0, + array( + 'post_mime_type' => 'image/jpeg', + 'post_type' => 'attachment', + ) + ); + \update_post_meta( $attachment_id, '_wp_attachment_image_alt', 'A red square' ); + + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/media/%d', ACTIVITYPUB_REST_NAMESPACE, $attachment_id ) ); + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertEquals( 'Image', $data['type'] ); + $this->assertEquals( 'image/jpeg', $data['mediaType'] ); + $this->assertEquals( 'A red square', $data['name'] ); + $this->assertStringContainsString( '/activitypub/1.0/media/' . $attachment_id, $data['id'] ); + } + + /** + * Test that the controller does not expose a JSON schema. + * + * Media objects are ActivityPub objects, not WP REST schema-typed resources. + * The schema endpoint is therefore intentionally absent. + * + * @covers ::get_item_schema + */ + public function test_get_item_schema() { + $controller = new Media_Controller(); + $this->assertEmpty( $controller->get_item_schema() ); + } + + /** + * Test GET /media/{id} for a non-attachment post returns 404. + * + * @covers ::get_item + */ + public function test_get_item_404_for_non_attachment() { + $post_id = self::factory()->post->create(); + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/media/%d', ACTIVITYPUB_REST_NAMESPACE, $post_id ) ); + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 404, $response->get_status() ); + } + + /** + * Test GET /media/{id} returns the AP representation of an audio file. + * + * @covers ::get_item + */ + public function test_get_item_returns_audio() { + $attachment_id = self::factory()->attachment->create_object( + 'song.mp3', + 0, + array( + 'post_mime_type' => 'audio/mpeg', + 'post_type' => 'attachment', + ) + ); + + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/media/%d', ACTIVITYPUB_REST_NAMESPACE, $attachment_id ) ); + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'Audio', $data['type'] ); + $this->assertEquals( 'audio/mpeg', $data['mediaType'] ); + } + + /** + * Test GET /media/{id} returns the AP representation of a video file. + * + * @covers ::get_item + */ + public function test_get_item_returns_video() { + $attachment_id = self::factory()->attachment->create_object( + 'clip.mp4', + 0, + array( + 'post_mime_type' => 'video/mp4', + 'post_type' => 'attachment', + ) + ); + + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/media/%d', ACTIVITYPUB_REST_NAMESPACE, $attachment_id ) ); + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'Video', $data['type'] ); + $this->assertEquals( 'video/mp4', $data['mediaType'] ); + } + + /** + * Test GET /media/{id} returns 415 for an unsupported MIME type. + * + * @covers ::get_item + */ + public function test_get_item_415_for_unsupported_mime() { + $attachment_id = self::factory()->attachment->create_object( + 'document.pdf', + 0, + array( + 'post_mime_type' => 'application/pdf', + 'post_type' => 'attachment', + ) + ); + + $request = new \WP_REST_Request( 'GET', sprintf( '/%s/media/%d', ACTIVITYPUB_REST_NAMESPACE, $attachment_id ) ); + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 415, $response->get_status() ); + } + + /** + * Build a 1x1 PNG and write it to a temp file. Returns the path. + * + * @return string Path to the temp PNG. + */ + private function create_png_temp_file() { + // 1x1 transparent PNG. + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Test data, not obfuscation. + $png = base64_decode( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=' ); + $tmp = \wp_tempnam( 'media-upload-test.png' ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test file creation. + \file_put_contents( $tmp, $png ); + return $tmp; + } + + /** + * Test POST /actors/{id}/uploadMedia with file + object parts. + * + * @covers ::upload_item + */ + public function test_upload_item_creates_attachment_and_returns_location() { + $tmp = $this->create_png_temp_file(); + \wp_set_current_user( self::$user_id ); + $request = new \WP_REST_Request( 'POST', sprintf( '/%s/actors/%d/uploadMedia', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ); + + $request->set_file_params( + array( + 'file' => array( + 'name' => 'pixel.png', + 'type' => 'image/png', + 'tmp_name' => $tmp, + 'error' => 0, + 'size' => \filesize( $tmp ), + ), + ) + ); + $request->set_body_params( + array( + 'object' => \wp_json_encode( + array( + 'type' => 'Image', + 'name' => 'Test pixel', + ) + ), + ) + ); + + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 201, $response->get_status(), \wp_json_encode( $response->get_data() ) ); + + $headers = $response->get_headers(); + $this->assertArrayHasKey( 'Location', $headers ); + $this->assertStringContainsString( '/activitypub/1.0/media/', $headers['Location'] ); + + $data = $response->get_data(); + $this->assertEquals( 'Image', $data['type'] ); + // Uploaded PNGs are optimized to WebP when the server supports it; fall back to PNG otherwise. + $this->assertContains( $data['mediaType'], array( 'image/webp', 'image/png' ) ); + $this->assertEquals( 'Test pixel', $data['name'] ); + $this->assertSame( $headers['Location'], $data['id'] ); + + // Parse attachment id out of the Location URL and verify the DB state. + \preg_match( '#/media/(\d+)#', $headers['Location'], $matches ); + $attachment_id = isset( $matches[1] ) ? (int) $matches[1] : 0; + $this->assertGreaterThan( 0, $attachment_id ); + $this->assertEquals( 'attachment', \get_post_type( $attachment_id ) ); + $this->assertEquals( 'Test pixel', \get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) ); + } + + /** + * Test POST /uploadMedia with no file returns 400. + * + * @covers ::upload_item + */ + public function test_upload_item_missing_file() { + \wp_set_current_user( self::$user_id ); + $request = new \WP_REST_Request( 'POST', sprintf( '/%s/actors/%d/uploadMedia', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ); + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + $this->assertEquals( 'activitypub_missing_file', $response->get_data()['code'] ); + } + + /** + * Test POST /uploadMedia Pleroma-style: file only, with `description` form field. + * + * @covers ::upload_item + */ + public function test_upload_item_pleroma_style() { + $tmp = $this->create_png_temp_file(); + \wp_set_current_user( self::$user_id ); + + $request = new \WP_REST_Request( 'POST', sprintf( '/%s/actors/%d/uploadMedia', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ); + $request->set_file_params( + array( + 'file' => array( + 'name' => 'pixel.png', + 'type' => 'image/png', + 'tmp_name' => $tmp, + 'error' => 0, + 'size' => \filesize( $tmp ), + ), + ) + ); + $request->set_body_params( array( 'description' => 'Pleroma alt text' ) ); + + $response = \rest_get_server()->dispatch( $request ); + $this->assertEquals( 201, $response->get_status() ); + $this->assertEquals( 'Pleroma alt text', $response->get_data()['name'] ); + } + + /** + * Test that explicit object.name beats a competing description form field. + * + * @covers ::upload_item + */ + public function test_upload_item_object_name_beats_description() { + $tmp = $this->create_png_temp_file(); + \wp_set_current_user( self::$user_id ); + + $request = new \WP_REST_Request( 'POST', sprintf( '/%s/actors/%d/uploadMedia', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ); + $request->set_file_params( + array( + 'file' => array( + 'name' => 'pixel.png', + 'type' => 'image/png', + 'tmp_name' => $tmp, + 'error' => 0, + 'size' => \filesize( $tmp ), + ), + ) + ); + $request->set_body_params( + array( + 'object' => \wp_json_encode( + array( + 'type' => 'Image', + 'name' => 'From object.name', + ) + ), + 'description' => 'From description', + ) + ); + + $response = \rest_get_server()->dispatch( $request ); + $this->assertEquals( 201, $response->get_status() ); + $this->assertEquals( 'From object.name', $response->get_data()['name'] ); + } + + /** + * Test POST /uploadMedia with malformed object JSON returns 400. + * + * @covers ::upload_item + */ + public function test_upload_item_malformed_object_json() { + $tmp = $this->create_png_temp_file(); + \wp_set_current_user( self::$user_id ); + $request = new \WP_REST_Request( 'POST', sprintf( '/%s/actors/%d/uploadMedia', ACTIVITYPUB_REST_NAMESPACE, self::$user_id ) ); + $request->set_file_params( + array( + 'file' => array( + 'name' => 'pixel.png', + 'type' => 'image/png', + 'tmp_name' => $tmp, + 'error' => 0, + 'size' => \filesize( $tmp ), + ), + ) + ); + $request->set_body_params( array( 'object' => '{not json' ) ); + + $response = \rest_get_server()->dispatch( $request ); + $this->assertEquals( 400, $response->get_status() ); + $this->assertEquals( 'activitypub_invalid_object', $response->get_data()['code'] ); + } + + /** + * Test that the User actor advertises uploadMedia in endpoints. + */ + public function test_user_actor_advertises_upload_media_endpoint() { + $user = \Activitypub\Collection\Actors::get_by_id( self::$user_id ); + $endpoints = $user->get_endpoints(); + + $this->assertArrayHasKey( 'uploadMedia', $endpoints ); + $this->assertStringContainsString( + sprintf( '/activitypub/1.0/actors/%d/uploadMedia', self::$user_id ), + $endpoints['uploadMedia'] + ); + } + + /** + * Test that the Blog actor advertises uploadMedia in endpoints. + */ + public function test_blog_actor_advertises_upload_media_endpoint() { + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_BLOG_MODE ); + $blog = \Activitypub\Collection\Actors::get_by_id( \Activitypub\Collection\Actors::BLOG_USER_ID ); + \delete_option( 'activitypub_actor_mode' ); + $endpoints = $blog->get_endpoints(); + + $this->assertArrayHasKey( 'uploadMedia', $endpoints ); + $this->assertStringContainsString( + '/activitypub/1.0/actors/0/uploadMedia', + $endpoints['uploadMedia'] + ); + } + + /** + * Test that a user cannot upload media to another user's uploadMedia route. + * + * @covers ::upload_permissions_check + */ + public function test_upload_item_forbidden_for_other_user() { + $other_user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + \get_user_by( 'ID', $other_user_id )->add_cap( 'activitypub' ); + + $tmp = $this->create_png_temp_file(); + \wp_set_current_user( self::$user_id ); + + $request = new \WP_REST_Request( 'POST', sprintf( '/%s/actors/%d/uploadMedia', ACTIVITYPUB_REST_NAMESPACE, $other_user_id ) ); + $request->set_file_params( + array( + 'file' => array( + 'name' => 'pixel.png', + 'type' => 'image/png', + 'tmp_name' => $tmp, + 'error' => 0, + 'size' => \filesize( $tmp ), + ), + ) + ); + + $response = \rest_get_server()->dispatch( $request ); + $this->assertEquals( 403, $response->get_status() ); + } + + /** + * Test that a user without upload_files capability cannot upload. + * + * @covers ::upload_permissions_check + */ + public function test_upload_item_forbidden_without_upload_files_capability() { + $subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + \get_user_by( 'ID', $subscriber_id )->add_cap( 'activitypub' ); + + $tmp = $this->create_png_temp_file(); + \wp_set_current_user( $subscriber_id ); + + $request = new \WP_REST_Request( 'POST', sprintf( '/%s/actors/%d/uploadMedia', ACTIVITYPUB_REST_NAMESPACE, $subscriber_id ) ); + $request->set_file_params( + array( + 'file' => array( + 'name' => 'pixel.png', + 'type' => 'image/png', + 'tmp_name' => $tmp, + 'error' => 0, + 'size' => \filesize( $tmp ), + ), + ) + ); + + $response = \rest_get_server()->dispatch( $request ); + $this->assertEquals( 403, $response->get_status() ); + $this->assertEquals( 'activitypub_cannot_upload', $response->get_data()['code'] ); + } + + /** + * Test that the route is only registered when activitypub_api is enabled. + */ + public function test_route_gated_behind_activitypub_api_option() { + global $wp_rest_server; + + $previous = \get_option( 'activitypub_api', false ); + + try { + \update_option( 'activitypub_api', false ); + + // Reset the server so previously-registered routes are cleared. + $wp_rest_server = new \WP_REST_Server(); + \do_action( 'rest_api_init' ); + + $routes = \rest_get_server()->get_routes(); + $this->assertArrayNotHasKey( + '/' . ACTIVITYPUB_REST_NAMESPACE . '/(?:users|actors)/(?P[-]?\d+)/uploadMedia', + $routes + ); + } finally { + // Restore state. + \update_option( 'activitypub_api', $previous ); + $wp_rest_server = new \WP_REST_Server(); + \do_action( 'rest_api_init' ); + } + } +}