Skip to content

Commit d2a1ce2

Browse files
authored
Merge pull request #722 from dahlia/examples/custom-collections
Custom collections cookbook example
2 parents 96f2567 + 100192f commit d2a1ce2

6 files changed

Lines changed: 470 additions & 108 deletions

File tree

CHANGES.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,16 +243,23 @@ To be released.
243243
redistribution that threadiverse software (Lemmy, Mbin, NodeBB) uses to fan
244244
activity out to every subscriber. [[#704], [#710]]
245245

246+
- Added a custom collections cookbook example for bookmark-like data,
247+
demonstrating cursor pagination, URI-template filtering, collection
248+
counters, actor stream links, and requester-aware collections using
249+
`ctx.getSignedKeyOwner()`. [[#694], [#722]]
250+
246251
[*Building a federated blog* tutorial]: https://fedify.dev/tutorial/astro-blog
247252
[Astro]: https://astro.build/
248253
[Bun]: https://bun.sh/
249254
[*Building a threadiverse community platform*]: https://fedify.dev/tutorial/threadiverse
250255
[*Creating your own federated microblog*]: https://fedify.dev/tutorial/microblog
251256
[#691]: https://github.com/fedify-dev/fedify/issues/691
257+
[#694]: https://github.com/fedify-dev/fedify/issues/694
252258
[#695]: https://github.com/fedify-dev/fedify/pull/695
253259
[#704]: https://github.com/fedify-dev/fedify/issues/704
254260
[#706]: https://github.com/fedify-dev/fedify/issues/706
255261
[#715]: https://github.com/fedify-dev/fedify/pull/715
262+
[#722]: https://github.com/fedify-dev/fedify/pull/722
256263

257264

258265
Version 2.1.10

docs/manual/collections.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,12 +1396,17 @@ followers, Fedify allows you to create custom collections for your specific
13961396
needs. Custom collections can be used to expose any type of ActivityPub
13971397
objects in a paginated manner.
13981398

1399+
For runnable code that compares several custom collection patterns, see the
1400+
[custom collections example].
1401+
13991402
There are two types of custom collections you can create:
14001403

14011404
- **Collection**: An unordered collection of objects
14021405
- **Ordered Collection**: An ordered collection of objects where the order
14031406
matters
14041407

1408+
[custom collections example]: https://github.com/fedify-dev/fedify/tree/main/examples/custom-collections
1409+
14051410
### Setting up a custom collection
14061411

14071412
To create a custom collection, you use either `setCollectionDispatcher()` for
Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,29 @@
11
Custom collections example
22
==========================
33

4-
This example demonstrates how to implement custom collections in Fedify.
5-
Custom collections allow you to define your own ActivityPub collections with
6-
custom logic for dispatching items and counting collection sizes.
4+
This example is a small cookbook for custom ActivityPub collections in Fedify.
5+
It uses a single-user bookmark log as the domain, but the important part is
6+
how each collection dispatcher maps server-side data to an ActivityPub
7+
collection.
8+
9+
The script demonstrates three patterns:
10+
11+
- `/users/alice/collections/public`: a public `OrderedCollection` with
12+
cursor-based pages, `setCounter()`, `setFirstCursor()`, and
13+
`setLastCursor()`.
14+
- `/users/alice/collections/tags/{tag}`: a parameterized collection that
15+
filters public bookmarks using a URI template value.
16+
- `/users/alice/collections/followers-only`: a collection whose result
17+
depends on the signed requester. It uses `.authorize()` with
18+
`ctx.getSignedKeyOwner()` so unsigned or non-follower requests are rejected.
19+
20+
Run it from this directory:
721

822
~~~~ sh
9-
deno task codegen # At very first time only
23+
deno task codegen # Only the first time
1024
deno run -A ./main.ts
1125
~~~~
26+
27+
The output prints the actor document, collection metadata and page responses
28+
for the public and tag-filtered routes, and the HTTP status codes returned
29+
for unsigned requests to the followers-only collection.
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import {
2+
createFederation,
3+
MemoryKvStore,
4+
type RequestContext,
5+
} from "@fedify/fedify";
6+
import {
7+
Article,
8+
Hashtag,
9+
Person,
10+
PUBLIC_COLLECTION,
11+
type Recipient,
12+
} from "@fedify/vocab";
13+
import {
14+
type Bookmark,
15+
firstCursor,
16+
followerIds,
17+
followersOnlyBookmarks,
18+
lastCursor,
19+
normalizeActorId,
20+
normalizeTag,
21+
OWNER,
22+
PAGE_SIZE,
23+
parseCursor,
24+
publicBookmarks,
25+
taggedBookmarks,
26+
} from "./lib.ts";
27+
28+
const PUBLIC_BOOKMARKS = "public-bookmarks";
29+
const TAGGED_BOOKMARKS = "tagged-bookmarks";
30+
const FOLLOWERS_ONLY_BOOKMARKS = "followers-only-bookmarks";
31+
const HTML_ESCAPES: Record<string, string> = {
32+
"&": "&amp;",
33+
"<": "&lt;",
34+
">": "&gt;",
35+
'"': "&quot;",
36+
"'": "&#39;",
37+
};
38+
39+
const federation = createFederation<void>({
40+
kv: new MemoryKvStore(),
41+
});
42+
43+
federation
44+
.setActorDispatcher("/users/{identifier}", (ctx, identifier) => {
45+
if (identifier !== OWNER) return null;
46+
47+
// inbox/outbox are omitted here; use setInboxListeners() and
48+
// setOutboxDispatcher() for full ActivityPub actors.
49+
return new Person({
50+
id: ctx.getActorUri(identifier),
51+
preferredUsername: identifier,
52+
name: "Alice's bookmarks",
53+
summary: "A single-user bookmark log with custom collection examples.",
54+
url: new URL(`/users/${identifier}`, ctx.url),
55+
followers: ctx.getFollowersUri(identifier),
56+
streams: [
57+
ctx.getCollectionUri(PUBLIC_BOOKMARKS, { identifier }),
58+
ctx.getCollectionUri(TAGGED_BOOKMARKS, {
59+
identifier,
60+
tag: "activitypub",
61+
}),
62+
ctx.getCollectionUri(FOLLOWERS_ONLY_BOOKMARKS, { identifier }),
63+
],
64+
});
65+
});
66+
67+
federation.setFollowersDispatcher(
68+
"/users/{identifier}/followers",
69+
(_ctx, identifier) => {
70+
if (identifier !== OWNER) return null;
71+
72+
// For large follower lists, add counters and cursor callbacks as below.
73+
const items: Recipient[] = Array.from(followerIds, (id) => {
74+
const actorId = new URL(id);
75+
return {
76+
id: actorId,
77+
inboxId: new URL(`${actorId.pathname}/inbox`, actorId),
78+
};
79+
});
80+
return { items };
81+
},
82+
);
83+
84+
federation
85+
.setOrderedCollectionDispatcher(
86+
PUBLIC_BOOKMARKS,
87+
Article,
88+
"/users/{identifier}/collections/public",
89+
(ctx, values, cursor) => {
90+
if (values.identifier !== OWNER) return null;
91+
92+
return collectionBookmarks(
93+
ctx,
94+
publicBookmarks(),
95+
cursor,
96+
);
97+
},
98+
)
99+
.setCounter((_ctx, values) => {
100+
if (values.identifier !== OWNER) return null;
101+
return publicBookmarks().length;
102+
})
103+
.setFirstCursor((_ctx, values) => {
104+
if (values.identifier !== OWNER) return null;
105+
return firstCursor(publicBookmarks());
106+
})
107+
.setLastCursor((_ctx, values) => {
108+
if (values.identifier !== OWNER) return null;
109+
return lastCursor(publicBookmarks());
110+
});
111+
112+
federation
113+
.setOrderedCollectionDispatcher(
114+
TAGGED_BOOKMARKS,
115+
Article,
116+
"/users/{identifier}/collections/tags/{tag}",
117+
(ctx, values, cursor) => {
118+
if (values.identifier !== OWNER) return null;
119+
120+
return collectionBookmarks(
121+
ctx,
122+
taggedBookmarks(values.tag),
123+
cursor,
124+
);
125+
},
126+
)
127+
.setCounter((_ctx, values) => {
128+
if (values.identifier !== OWNER) return null;
129+
return taggedBookmarks(values.tag).length;
130+
})
131+
.setFirstCursor((_ctx, values) => {
132+
if (values.identifier !== OWNER) return null;
133+
return firstCursor(taggedBookmarks(values.tag));
134+
})
135+
.setLastCursor((_ctx, values) => {
136+
if (values.identifier !== OWNER) return null;
137+
return lastCursor(taggedBookmarks(values.tag));
138+
});
139+
140+
federation
141+
.setOrderedCollectionDispatcher(
142+
FOLLOWERS_ONLY_BOOKMARKS,
143+
Article,
144+
"/users/{identifier}/collections/followers-only",
145+
(ctx, values, cursor) => {
146+
if (values.identifier !== OWNER) return null;
147+
148+
return collectionBookmarks(
149+
ctx,
150+
followersOnlyBookmarks(),
151+
cursor,
152+
);
153+
},
154+
)
155+
.setCounter((_ctx, values) => {
156+
if (values.identifier !== OWNER) return null;
157+
return followersOnlyBookmarks().length;
158+
})
159+
.setFirstCursor((_ctx, values) => {
160+
if (values.identifier !== OWNER) return null;
161+
return firstCursor(followersOnlyBookmarks());
162+
})
163+
.setLastCursor((_ctx, values) => {
164+
if (values.identifier !== OWNER) return null;
165+
return lastCursor(followersOnlyBookmarks());
166+
})
167+
.authorize(async (ctx, values) => {
168+
if (values.identifier !== OWNER) return true;
169+
return await isFollowerRequest(ctx);
170+
});
171+
172+
function collectionBookmarks(
173+
ctx: RequestContext<void>,
174+
items: Bookmark[],
175+
cursor: string | null,
176+
): {
177+
items: Article[];
178+
nextCursor: string | null;
179+
prevCursor: string | null;
180+
} {
181+
return cursor == null
182+
? {
183+
items: items.map((bookmark) => toArticle(ctx, bookmark)),
184+
nextCursor: null,
185+
prevCursor: null,
186+
}
187+
: pageBookmarks(ctx, items, cursor);
188+
}
189+
190+
function pageBookmarks(
191+
ctx: RequestContext<void>,
192+
items: Bookmark[],
193+
cursor: string,
194+
): {
195+
items: Article[];
196+
nextCursor: string | null;
197+
prevCursor: string | null;
198+
} {
199+
const offset = parseCursor(cursor);
200+
if (offset == null || offset >= Math.max(items.length, 1)) {
201+
return { items: [], nextCursor: null, prevCursor: null };
202+
}
203+
204+
return {
205+
items: items
206+
.slice(offset, offset + PAGE_SIZE)
207+
.map((bookmark) => toArticle(ctx, bookmark)),
208+
nextCursor: offset + PAGE_SIZE < items.length
209+
? String(offset + PAGE_SIZE)
210+
: null,
211+
prevCursor: offset > 0 ? String(Math.max(0, offset - PAGE_SIZE)) : null,
212+
};
213+
}
214+
215+
function toArticle(ctx: RequestContext<void>, bookmark: Bookmark): Article {
216+
const bookmarkUrl = new URL(bookmark.href);
217+
return new Article({
218+
id: new URL(`/users/${OWNER}/bookmarks/${bookmark.id}`, ctx.url),
219+
attribution: ctx.getActorUri(OWNER),
220+
name: bookmark.title,
221+
summary: bookmark.note,
222+
content: `<p><a href="${escapeHtml(bookmarkUrl.href)}">${
223+
escapeHtml(bookmark.title)
224+
}</a></p>`,
225+
mediaType: "text/html",
226+
url: bookmarkUrl,
227+
published: bookmark.savedAt,
228+
to: bookmark.visibility === "public"
229+
? PUBLIC_COLLECTION
230+
: ctx.getFollowersUri(OWNER),
231+
tags: bookmark.tags.map((tag) =>
232+
new Hashtag({
233+
href: ctx.getCollectionUri(TAGGED_BOOKMARKS, {
234+
identifier: OWNER,
235+
tag: normalizeTag(tag),
236+
}),
237+
name: `#${tag}`,
238+
})
239+
),
240+
});
241+
}
242+
243+
function escapeHtml(value: string): string {
244+
return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]);
245+
}
246+
247+
async function isFollowerRequest(ctx: RequestContext<void>): Promise<boolean> {
248+
try {
249+
const signedKeyOwner = await ctx.getSignedKeyOwner();
250+
return signedKeyOwner?.id == null
251+
? false
252+
: followerIds.has(normalizeActorId(signedKeyOwner.id));
253+
} catch {
254+
return false;
255+
}
256+
}
257+
258+
export default federation;

0 commit comments

Comments
 (0)