Skip to content

Media upload

Fedify can route multipart/form-data POST requests to a dedicated media upload endpoint, implementing the ActivityPub Media Upload extension. This lets your own clients upload binary media (images, videos, and so on) as part of ActivityPub's client-to-server protocol, without exposing a separate non-standard API.

Unlike outbox posting, media upload does not go through POST /outbox. Instead, the actor advertises a separate endpoint under endpoints.uploadMedia, and the client sends a request with two parts:

  • file: the binary media payload.
  • object: an ActivityStreams object shell (without an id or url) that the server finalizes.

Registering a media uploader

The ~Federatable.setMediaUploader() method takes the endpoint path and a callback that finalizes the uploaded media. As with outbox listeners, the returned setter exposes an ~MediaUploaderSetters.authorize() hook that runs before the callback and can reject unauthorized requests with 401 Unauthorized:

federation
.
setMediaUploader
(
"/users/{identifier}/media", async (
ctx
,
identifier
,
file
,
object
) => {
const
stored
= await
uploadToStorage
(
file
);
return new
Image
({
id
:
ctx
.
getObjectUri
(
Image
, {
uuid
:
stored
.
uuid
}),
url
: new
URL
(
stored
.
publicUrl
),
mediaType
:
file
.
type
,
name
:
object
.
name
,
}); }, ) .
authorize
(async (
ctx
,
identifier
) => {
const
session
= await
verifyAccessToken
(
ctx
.
request
.
headers
.
get
("authorization"),
); return
session
?.
identifier
===
identifier
;
});

WARNING

Always configure ~MediaUploaderSetters.authorize() in production. Without it, the endpoint accepts uploads from anyone who can reach the URL, which for an endpoint that stores files is a serious abuse, denial-of-service, and storage-cost risk. If you register a media uploader without an authorize hook, Fedify logs a runtime warning, and the @fedify/lint rule media-uploader-authorization-required flags it statically. Omit authorize() only when you deliberately intend a public upload endpoint.

The callback receives the request context, the matched {identifier}, the uploaded file (a Web-standard File), and the parsed object shell. Since the client may send any subtype of Object and the shell lacks an id, the callback receives the base Object class; narrow it with instanceof when you need a specific subtype.

NOTE

Fedify reads the whole multipart/form-data body into memory before invoking your callback, and it does not impose an upload size limit of its own (the same as the inbox and outbox). Enforce a maximum upload size at your deployment layer (e.g. a reverse proxy's client_max_body_size) so that oversized uploads are rejected before they reach the endpoint.

Return value: 201 Created versus 202 Accepted

The callback's return value determines the HTTP response. This encodes the distinction between a resource that is ready to fetch and one that is still being processed; it is independent of whether your callback returns synchronously or as a Promise (in practice it will be asynchronous, since it usually stores the file):

  • Returning an Object (the resource is fetchable right away) makes Fedify respond with 201 Created, a Location header pointing at the object's id, and the serialized object as the body. The object must have an id (set it with getObjectUri()); a 201 Created response cannot omit the Location header, so an object without an id results in 500 Internal Server Error.
  • Returning a URL (the resource will exist at that URL once processing finishes, e.g. after transcoding) makes Fedify respond with 202 Accepted, a Location header pointing at the returned URL, and an empty body.
federation
.
setMediaUploader
(
"/users/{identifier}/media", async (
ctx
,
identifier
,
file
,
object
) => {
const {
uuid
} = await
enqueueTranscoding
(
file
);
// The result is not ready yet, so return the eventual URL (202 Accepted): return
ctx
.
getObjectUri
(
Video
, {
uuid
});
}, );

In both cases the returned id/URL should be produced by getObjectUri() and point at an object dispatcher route you have registered with setObjectDispatcher(). Fedify does not serve the uploaded object back for you; serving that URI as a fetchable ActivityStreams object is the application's responsibility. If the returned value does not match a registered object dispatcher route, Fedify logs a runtime warning (the upload still succeeds), and the @fedify/lint package provides a matching lint rule; see Linting for details.

Advertising the endpoint

Registering a media uploader does not by itself expose the endpoint to clients. Advertise it in your actor's endpoints.uploadMedia property using ~Context.getMediaUploaderUri(), the same way you expose the shared inbox with getInboxUri():

federation
.
setActorDispatcher
("/users/{identifier}", (
ctx
,
identifier
) => {
return new
Person
({
id
:
ctx
.
getActorUri
(
identifier
),
endpoints
: new
Endpoints
({
uploadMedia
:
ctx
.
getMediaUploaderUri
(
identifier
),
}), }); });

If a media uploader is registered but the actor does not advertise the matching endpoints.uploadMedia URI, Fedify logs a runtime warning, and @fedify/lint flags the same mistake statically.

Error responses

Fedify handles malformed requests before your callback runs:

  • A request that is not multipart/form-data receives 415 Unsupported Media Type.
  • A request rejected by ~MediaUploaderSetters.authorize() receives 401 Unauthorized (overridable via the onUnauthorized option of createFederation(), the same as outbox listeners).
  • A request whose {identifier} has no actor (the actor dispatcher returns null or a Tombstone) receives 404 Not Found, and neither the authorize hook nor the callback runs. The media uploader treats the actor dispatcher as the source of valid actors, so uploads for nonexistent or deleted actors are never stored.
  • A request missing the file part receives 400 Bad Request.
  • A request whose object part is missing or unparseable receives 400 Bad Request.
  • A non-POST request to the endpoint receives 405 Method Not Allowed.

NOTE

Unlike the outbox, the media upload endpoint resolves the actor before running authorization. A request for a nonexistent or deleted actor therefore receives 404 Not Found even when it would also fail authorization, whereas the outbox returns 401 Unauthorized in that case. The order is deliberate: the actor existence check is cheap, so resolving it first avoids buffering the entire upload for a bogus identifier, and it makes the 404 reliable regardless of the authorize hook. This does not reveal anything new, since actor existence is already public (the actor URI itself returns 404 for unknown actors).

Current scope

The media uploader provides the routing, authorization, and response surface for client-to-server media upload, but the rest of the server-side behavior remains application-defined.

In particular, Fedify does not currently do the following for you:

  • Store the uploaded file or generate the object's id
  • Serve the uploaded object back as a fetchable ActivityStreams object (use setObjectDispatcher() for that)
  • Wrap the uploaded object in a Create activity or publish it to the outbox
  • Accept more than one file per request (the extension defines a single file field, so clients that need batch uploads issue multiple requests)