Skip to content

Support ActivityPub Media Upload extension via setMediaUploader() #754

@dahlia

Description

@dahlia

Summary

Add support for the ActivityPub Media Upload extension so that Fedify-based servers can accept C2S media uploads from clients. The proposed API mirrors the C2S outbox listeners introduced in #430 (released in Fedify 2.2).

Background

The Media Upload extension is a SocialCG-incubated companion to ActivityPub's C2S protocol. Unlike normal C2S interactions, it does not go through POST /outbox; instead, the actor advertises a dedicated upload endpoint via endpoints.uploadMedia, and clients send a multipart/form-data request containing two fields:

  • file: the binary media payload
  • object: an ActivityStreams object “shell” (no id, no url) that the server finalizes

The server responds with one of:

  • 201 Created if the uploaded object is immediately fetchable, with Location pointing at the new object's id
  • 202 Accepted if processing (e.g. transcoding) is still in progress, with Location pointing at the eventual object URL

The W3C wiki page for the extension was last updated in 2017 and never reached recommendation status, but it remains the closest thing to a standardized media upload mechanism for ActivityPub C2S, and supporting it positions Fedify well for the small but growing set of C2S-capable clients (e.g. ActivityPods).

Now that #430 has landed and setOutboxListeners() provides a working pattern for authenticated C2S endpoints, media upload is the natural next gap to fill.

Proposed API

A new setMediaUploader(path, callback) method on the federation builder, returning a setter that accepts a single .authorize() chain (parallel to setOutboxListeners()):

federation
  .setMediaUploader(
    "/users/{identifier}/media",
    async (ctx, file, object) => {
      const stored = await uploadToStorage(file);

      // Result is fetchable right away → 201 Created
      return new Image({
        id: ctx.getObjectUri(Image, { uuid: stored.uuid }),
        url: new URL(stored.publicUrl),
        mediaType: file.type,
        name: object.name,
      });

      // Or, the result is not ready yet (still transcoding, etc.) → 202 Accepted
      // return ctx.getObjectUri(Video, { uuid: stored.uuid });
    },
  )
  .authorize(async (ctx, identifier) => {
    const token = ctx.request.headers.get("authorization");
    return await validateToken(identifier, token);
  });

Callback signature

type MediaUploaderCallback<TContextData> = (
  ctx: RequestContext<TContextData>,
  file: File,
  object: vocab.Object,
) => Promise<vocab.Object | URL>;
  • file is a Web-standard File (returned by Request#formData()), giving the callback name, type, size, arrayBuffer(), and stream() uniformly across Deno, Node.js, and Bun.
  • object is the parsed AS shell. Since the client may send any subtype of Object and the shell lacks an id, the callback receives the base Object class and narrows with instanceof as needed.

Return value semantics

The return type is the union vocab.Object | URL, which directly encodes whether the uploaded resource is already fetchable or is still being processed in the background. This is the HTTP-level distinction between 201 Created and 202 Accepted; it has nothing to do with JavaScript's async/await (the callback itself is always async because it returns a Promise):

  • Returning a vocab.Object instance (the resource exists and is ready) → respond 201 Created, with Location: <object.id> and the serialized object as the body.
  • Returning a URL instance (the resource will exist at that URL once processing finishes) → respond 202 Accepted, with Location: <returnedURL> and an empty body.

In both cases, the framework writes the Location header automatically.

Actor endpoint advertisement

Once setMediaUploader() is registered, the framework automatically populates endpoints.uploadMedia in actor serialization. If it is not registered, the field is omitted entirely. This mirrors how inbox and outbox URIs are auto-filled today.

Error responses

Standard handling for malformed input:

  • Non-multipart/form-data request → 415 Unsupported Media Type
  • Missing file field → 400 Bad Request
  • Missing or unparseable object field → 400 Bad Request
  • .authorize() returns false401 Unauthorized (overridable via onUnauthorized, same as outbox)

Validation and lint rule

The framework verifies that the callback's return value points at a registered object dispatcher route:

  • When a vocab.Object is returned, object.id is checked against registered object dispatcher paths.
  • When a URL is returned, the URL itself is checked the same way.

A mismatch produces a runtime warning (the upload still succeeds) and a corresponding @fedify/lint rule statically flags returns that are not derived from ctx.getObjectUri().

Out of scope

The following are intentionally left as the application's responsibility, consistent with the philosophy adopted in #430:

  • Wrapping the uploaded object in a Create activity: the spec permits the server to auto-publish to the outbox, but doing so silently would surprise users and is trivially implemented by the developer when desired.
  • Registering an object dispatcher for the uploaded type: the upload endpoint emits IDs, but serving those IDs back as fetchable AS objects is the developer's job via setObjectDispatcher(). The framework only warns on apparent mismatches.
  • Multi-file uploads: the spec defines a single file field, so the API accepts exactly one file per request. Clients that need batch uploads issue multiple requests.

Scope/dependencies

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions