Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e25eacd
Gitignore docs/superpowers/ for local-only superpowers artifacts.
pfefferle May 8, 2026
ec9ecfe
Add 'upload' OAuth scope for the uploadMedia endpoint
pfefferle May 12, 2026
d9dd6e2
Cover 'upload' scope in is_valid and descriptions tests
pfefferle May 12, 2026
948908f
Add get_attachment_ap_id() helper for canonical media URLs
pfefferle May 12, 2026
b3c38f4
Cover non-positive IDs in get_attachment_ap_id tests
pfefferle May 12, 2026
0a9f43f
Add Media_Controller skeleton with GET /media/{id} endpoint
pfefferle May 12, 2026
6374264
Cover audio/video/PDF branches in Media_Controller tests
pfefferle May 12, 2026
98fcbf4
Implement POST uploadMedia (multipart, returns AP object)
pfefferle May 12, 2026
74a2435
Harden uploadMedia: clean up on transform failure, sanitize error, ve…
pfefferle May 12, 2026
9c26705
Accept Pleroma-style 'description' form field on uploadMedia
pfefferle May 12, 2026
d6a5ee4
Test object.name precedence over Pleroma description
pfefferle May 12, 2026
dc585c7
Advertise uploadMedia endpoint on User and Blog actors
pfefferle May 12, 2026
15615fb
Gate uploadMedia route behind activitypub_api option
pfefferle May 12, 2026
c2c3485
Use try/finally to keep gate test from leaking state on failure
pfefferle May 12, 2026
e9b1116
Document C2S media upload + add changelog entry
pfefferle May 12, 2026
857ead5
Harden uploadMedia auth: require upload_files cap, gate advertisement
pfefferle May 12, 2026
c89abf3
Apply Attachments image optimization to C2S uploads
pfefferle May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/changelog/c2s-media-upload
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ _site
.claude/**/*.local*
.claude/summaries/
.agents/**/*.local*
docs/superpowers/
.cursor
.DS_Store
.php_cs.cache
Expand Down
6 changes: 6 additions & 0 deletions FEDERATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,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)
Expand Down
1 change: 1 addition & 0 deletions activitypub.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
8 changes: 6 additions & 2 deletions includes/class-attachments.php
Original file line number Diff line number Diff line change
Expand Up @@ -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/' ) ) {
Expand Down
27 changes: 27 additions & 0 deletions includes/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
8 changes: 7 additions & 1 deletion includes/model/class-blog.php
Original file line number Diff line number Diff line change
Expand Up @@ -419,14 +419,20 @@ 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' ),
'oauthRegistrationEndpoint' => get_rest_url_by_path( 'oauth/clients' ),
'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;
}

/**
Expand Down
8 changes: 7 additions & 1 deletion includes/model/class-user.php
Original file line number Diff line number Diff line change
Expand Up @@ -328,14 +328,20 @@ 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' ),
'oauthRegistrationEndpoint' => get_rest_url_by_path( 'oauth/clients' ),
'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;
}

/**
Expand Down
9 changes: 9 additions & 0 deletions includes/oauth/class-scope.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -49,6 +56,7 @@ class Scope {
self::FOLLOW,
self::PUSH,
self::PROFILE,
self::UPLOAD,
);

/**
Expand All @@ -62,6 +70,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',
);

/**
Expand Down
Loading
Loading