Skip to content

Commit 5885824

Browse files
authored
Merge pull request #927 from dahlia/media-upload-ext
Support the ActivityPub Media Upload extension
2 parents a974c7d + 8fdd9a0 commit 5885824

38 files changed

Lines changed: 3684 additions & 14 deletions

‎CHANGES.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,26 @@ To be released.
2222
as a remote JSON-LD document, which is required for [FEP-ef61]
2323
portable objects. [[#827], [#915]]
2424

25+
- Added support for the [ActivityPub Media Upload extension] so that servers
26+
can accept client-to-server media uploads: [[#754], [#927]]
27+
28+
- `Federation` and `FederationBuilder` gained a `setMediaUploader()`
29+
method (through the new `MediaUploaderSetters` interface) that registers
30+
a `multipart/form-data` upload endpoint. Its callback finalizes the
31+
uploaded `file` alongside the posted `object` shell and returns either
32+
the created object (`201 Created`) or the `URL` at which it will become
33+
available once processing finishes (`202 Accepted`).
34+
- `Context` gained a `getMediaUploaderUri()` method for building the
35+
endpoint URI, which actor dispatchers advertise under the new
36+
`Endpoints.uploadMedia` property.
37+
- A new metric endpoint category, `media_upload`, classifies these
38+
requests in the `fedify.endpoint` attribute.
39+
- Fedify logs a runtime warning when a callback's returned URI does not
40+
point at a registered object dispatcher route, when a registered
41+
media uploader is not advertised under `endpoints.uploadMedia`, or
42+
when a media uploader is registered without an `authorize()` hook (so
43+
the endpoint would accept uploads from anyone).
44+
2545
- Added a custom background task API that generalizes Fedify's
2646
enqueue-and-process-later pattern to arbitrary application-defined jobs:
2747

@@ -70,8 +90,10 @@ To be released.
7090
[FEP-8b32]: https://w3id.org/fep/8b32
7191
[FEP-fe34]: https://w3id.org/fep/fe34
7292
[FEP-ef61]: https://w3id.org/fep/ef61
93+
[ActivityPub Media Upload extension]: https://www.w3.org/wiki/SocialCG/ActivityPub/MediaUpload
7394
[Standard Schema]: https://standardschema.dev/
7495
[#206]: https://github.com/fedify-dev/fedify/issues/206
96+
[#754]: https://github.com/fedify-dev/fedify/issues/754
7597
[#797]: https://github.com/fedify-dev/fedify/issues/797
7698
[#798]: https://github.com/fedify-dev/fedify/issues/798
7799
[#799]: https://github.com/fedify-dev/fedify/issues/799
@@ -85,6 +107,7 @@ To be released.
85107
[#923]: https://github.com/fedify-dev/fedify/pull/923
86108
[#925]: https://github.com/fedify-dev/fedify/pull/925
87109
[#926]: https://github.com/fedify-dev/fedify/pull/926
110+
[#927]: https://github.com/fedify-dev/fedify/pull/927
88111

89112
### @fedify/vocab
90113

@@ -105,6 +128,9 @@ To be released.
105128
`FeatureAuthorization`, plus actor `featuredCollections` and
106129
`InteractionPolicy.canFeature` properties. [[#810], [#914]]
107130

131+
- Added the `Endpoints.uploadMedia` property, the standard ActivityStreams
132+
endpoint for the [ActivityPub Media Upload extension]. [[#754], [#927]]
133+
108134
- Fixed the CommonJS vocabulary build so it no longer requires
109135
`@js-temporal/polyfill` at runtime. The build now bundles
110136
`temporal-polyfill`, while type declarations rely on the standard
@@ -205,6 +231,22 @@ To be released.
205231
`temporal-polyfill`, while type declarations rely on the standard
206232
`esnext.temporal` lib reference. [[#823], [#925]]
207233

234+
### @fedify/lint
235+
236+
- Added four lint rules for the media upload endpoint introduced in
237+
`@fedify/fedify`: [[#754], [#927]]
238+
239+
- `media-uploader-object-uri-required` warns when a `setMediaUploader()`
240+
callback does not derive its return value from `ctx.getObjectUri()`.
241+
- `media-uploader-authorization-required` warns when `setMediaUploader()`
242+
is registered without an `.authorize()` hook.
243+
- `actor-upload-media-property-required` warns when a media uploader is
244+
registered but the actor dispatcher does not advertise
245+
`endpoints.uploadMedia`.
246+
- `actor-upload-media-property-mismatch` warns when
247+
`endpoints.uploadMedia` is not built with
248+
`ctx.getMediaUploaderUri(identifier)`.
249+
208250

209251
Version 2.3.1
210252
-------------

‎docs/.vitepress/config.mts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ const MANUAL = {
140140
{ text: "Actor dispatcher", link: "/manual/actor.md" },
141141
{ text: "Inbox listeners", link: "/manual/inbox.md" },
142142
{ text: "Outbox listeners", link: "/manual/outbox.md" },
143+
{ text: "Media upload", link: "/manual/media-upload.md" },
143144
{ text: "Sending activities", link: "/manual/send.md" },
144145
{ text: "Collections", link: "/manual/collections.md" },
145146
{ text: "Conversation backfill", link: "/manual/backfill.md" },

‎docs/manual/actor.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,13 @@ const ctx = null as unknown as Context<void>;
240240
new Endpoints({ sharedInbox: ctx.getInboxUri() })
241241
~~~~
242242

243+
If you register a media uploader with `Federation.setMediaUploader()`, advertise
244+
it here too, under the `uploadMedia` endpoint, using the
245+
`Context.getMediaUploaderUri()` method. See the [*Media upload*][media-upload]
246+
guide for details.
247+
248+
[media-upload]: ./media-upload.md
249+
243250
### `publicKey`
244251

245252
The `publicKey` property contains the public key of the actor. It is

‎docs/manual/context.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ shows the methods:
101101
- `~Context.getObjectUri()`
102102
- `~Context.getInboxUri()`
103103
- `~Context.getOutboxUri()`
104+
- `~Context.getMediaUploaderUri()`
104105
- `~Context.getFollowingUri()`
105106
- `~Context.getFollowersUri()`
106107
- `~Context.getLikedUri()`

‎docs/manual/lint.md‎

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,87 @@ federation
756756
});
757757
~~~~
758758

759+
### `media-uploader-object-uri-required`
760+
761+
Warns when a `setMediaUploader()` callback returns a value that is not derived
762+
from `ctx.getObjectUri()`.
763+
764+
**When this rule applies:**
765+
You've registered a media uploader with `setMediaUploader()`, but its callback
766+
never references `ctx.getObjectUri()`, so the returned `id`/`URL` probably does
767+
not point at a registered object dispatcher route.
768+
769+
**Why it matters:**
770+
The upload endpoint emits object IDs, but serving those IDs back as fetchable
771+
ActivityStreams objects is the developer's job via `setObjectDispatcher()`.
772+
Deriving the returned value from `ctx.getObjectUri()` keeps the emitted ID in
773+
sync with a registered object dispatcher route.
774+
775+
~~~~ typescript twoslash
776+
// @noErrors: 2345
777+
import { createFederation } from "@fedify/fedify";
778+
import { Image } from "@fedify/vocab";
779+
const federation = createFederation<void>({ kv: null as any });
780+
// ---cut-before---
781+
// ❌ Bad: Returned id is a hard-coded URL, not from ctx.getObjectUri()
782+
federation.setMediaUploader(
783+
"/users/{identifier}/media",
784+
async (ctx, identifier, file, object) => {
785+
return new Image({ id: new URL("https://example.com/media/1") });
786+
},
787+
);
788+
789+
// ✅ Good: Returned id is derived from ctx.getObjectUri()
790+
federation.setMediaUploader(
791+
"/users/{identifier}/media",
792+
async (ctx, identifier, file, object) => {
793+
return new Image({
794+
id: ctx.getObjectUri(Image, { uuid: "1" }),
795+
mediaType: file.type,
796+
});
797+
},
798+
);
799+
~~~~
800+
801+
### `media-uploader-authorization-required`
802+
803+
Warns when `setMediaUploader()` is registered without an `.authorize()` hook.
804+
805+
**When this rule applies:**
806+
You've called `federation.setMediaUploader()` but never chained (or otherwise
807+
called) `.authorize()` on the returned setter.
808+
809+
**Why it matters:**
810+
Without an authorize hook, the upload endpoint accepts uploads from anyone who
811+
can reach the URL. For an endpoint that stores files, that is a serious abuse,
812+
denial-of-service, and storage-cost risk, so `.authorize()` should be
813+
configured unless a public upload endpoint is genuinely intended.
814+
815+
~~~~ typescript twoslash
816+
// @noErrors: 2345
817+
import { createFederation } from "@fedify/fedify";
818+
import { Image } from "@fedify/vocab";
819+
const federation = createFederation<void>({ kv: null as any });
820+
// ---cut-before---
821+
// ❌ Bad: No authorize() hook, so anyone can upload
822+
federation.setMediaUploader(
823+
"/users/{identifier}/media",
824+
async (ctx, identifier, file, object) =>
825+
ctx.getObjectUri(Image, { uuid: "1" }),
826+
);
827+
828+
// ✅ Good: Protected with authorize()
829+
federation
830+
.setMediaUploader(
831+
"/users/{identifier}/media",
832+
async (ctx, identifier, file, object) =>
833+
ctx.getObjectUri(Image, { uuid: "1" }),
834+
)
835+
.authorize(async (ctx, identifier) => {
836+
return ctx.request.headers.get("authorization") === `Bearer ${identifier}`;
837+
});
838+
~~~~
839+
759840
### `actor-followers-property-required`
760841

761842
Ensures `followers` is defined when `setFollowersDispatcher()` is configured.
@@ -1219,6 +1300,92 @@ federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
12191300
});
12201301
~~~~
12211302

1303+
### `actor-upload-media-property-required`
1304+
1305+
Ensures `endpoints.uploadMedia` is defined when `setMediaUploader()` is
1306+
configured.
1307+
1308+
**When this rule applies:**
1309+
You've called `federation.setMediaUploader()`, but the actor object doesn't
1310+
advertise the endpoint under an
1311+
`endpoints: new Endpoints({ uploadMedia: ... })` property.
1312+
1313+
**Why it matters:**
1314+
Registering a media uploader does not by itself expose the endpoint to clients.
1315+
Advertising it under `endpoints.uploadMedia` is what lets clients discover where
1316+
to upload media.
1317+
1318+
~~~~ typescript twoslash
1319+
// @noErrors: 2345
1320+
import { createFederation } from "@fedify/fedify";
1321+
import { Endpoints, Person } from "@fedify/vocab";
1322+
const federation = createFederation<void>({ kv: null as any });
1323+
// ---cut-before---
1324+
// ❌ Bad: Missing endpoints.uploadMedia when a media uploader is registered
1325+
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
1326+
return new Person({
1327+
id: ctx.getActorUri(identifier),
1328+
// Missing endpoints.uploadMedia!
1329+
});
1330+
});
1331+
1332+
federation.setMediaUploader(
1333+
"/users/{identifier}/media",
1334+
async (ctx, identifier, file, object) =>
1335+
ctx.getObjectUri(Person, { uuid: "1" }),
1336+
);
1337+
1338+
// ✅ Good: Advertise endpoints.uploadMedia
1339+
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
1340+
return new Person({
1341+
id: ctx.getActorUri(identifier),
1342+
endpoints: new Endpoints({
1343+
uploadMedia: ctx.getMediaUploaderUri(identifier),
1344+
}),
1345+
});
1346+
});
1347+
~~~~
1348+
1349+
### `actor-upload-media-property-mismatch`
1350+
1351+
Validates that `endpoints.uploadMedia` is set using
1352+
`ctx.getMediaUploaderUri(identifier)`.
1353+
1354+
**When this rule applies:**
1355+
The `endpoints.uploadMedia` property is set to a value other than
1356+
`ctx.getMediaUploaderUri(identifier)`.
1357+
1358+
**Why it matters:**
1359+
The advertised upload endpoint URI must match the path configured in
1360+
`setMediaUploader()`, or clients will upload to the wrong URL.
1361+
1362+
~~~~ typescript twoslash
1363+
// @noErrors: 2345
1364+
import { createFederation } from "@fedify/fedify";
1365+
import { Endpoints, Person } from "@fedify/vocab";
1366+
const federation = createFederation<void>({ kv: null as any });
1367+
// ---cut-before---
1368+
// ❌ Bad: Using a hard-coded URL for the upload endpoint
1369+
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
1370+
return new Person({
1371+
id: ctx.getActorUri(identifier),
1372+
endpoints: new Endpoints({
1373+
uploadMedia: new URL("https://example.com/upload"), // Wrong!
1374+
}),
1375+
});
1376+
});
1377+
1378+
// ✅ Good: Use ctx.getMediaUploaderUri(identifier)
1379+
federation.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
1380+
return new Person({
1381+
id: ctx.getActorUri(identifier),
1382+
endpoints: new Endpoints({
1383+
uploadMedia: ctx.getMediaUploaderUri(identifier),
1384+
}),
1385+
});
1386+
});
1387+
~~~~
1388+
12221389
### `collection-filtering-not-implemented`
12231390

12241391
Warns when collection dispatchers don't implement filtering.

‎docs/manual/log.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,16 @@ The `["fedify", "federation", "inbox"]` category is used for logging messages
183183
related to incoming activities. When you cannot receive an activity, you can
184184
check the log messages in this category with the `"debug"` level.
185185

186+
### `["fedify", "federation", "mediaUploader"]`
187+
188+
*This category is available since Fedify 2.4.0.*
189+
190+
The `["fedify", "federation", "mediaUploader"]` category is used for logging
191+
messages related to the [media upload endpoint](./media-upload.md). In
192+
particular, it emits a `"warning"` when a media uploader is registered without
193+
an `authorize()` hook, and when a callback returns a URI that does not point at
194+
a registered object dispatcher route.
195+
186196
### `["fedify", "federation", "object"]`
187197

188198
The `["fedify", "federation", "object"]` category is used for logging messages

0 commit comments

Comments
 (0)