diff --git a/CHANGES.md b/CHANGES.md index 8f4655411..7d2fde52d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -225,16 +225,23 @@ To be released. redistribution that threadiverse software (Lemmy, Mbin, NodeBB) uses to fan activity out to every subscriber. [[#704], [#710]] + - Added a custom collections cookbook example for bookmark-like data, + demonstrating cursor pagination, URI-template filtering, collection + counters, actor stream links, and requester-aware collections using + `ctx.getSignedKeyOwner()`. [[#694], [#722]] + [*Building a federated blog* tutorial]: https://fedify.dev/tutorial/astro-blog [Astro]: https://astro.build/ [Bun]: https://bun.sh/ [*Building a threadiverse community platform*]: https://fedify.dev/tutorial/threadiverse [*Creating your own federated microblog*]: https://fedify.dev/tutorial/microblog [#691]: https://github.com/fedify-dev/fedify/issues/691 +[#694]: https://github.com/fedify-dev/fedify/issues/694 [#695]: https://github.com/fedify-dev/fedify/pull/695 [#704]: https://github.com/fedify-dev/fedify/issues/704 [#706]: https://github.com/fedify-dev/fedify/issues/706 [#715]: https://github.com/fedify-dev/fedify/pull/715 +[#722]: https://github.com/fedify-dev/fedify/pull/722 Version 2.1.10 diff --git a/docs/manual/collections.md b/docs/manual/collections.md index abad8d0e1..7ea5bda90 100644 --- a/docs/manual/collections.md +++ b/docs/manual/collections.md @@ -1396,12 +1396,17 @@ followers, Fedify allows you to create custom collections for your specific needs. Custom collections can be used to expose any type of ActivityPub objects in a paginated manner. +For runnable code that compares several custom collection patterns, see the +[custom collections example]. + There are two types of custom collections you can create: - **Collection**: An unordered collection of objects - **Ordered Collection**: An ordered collection of objects where the order matters +[custom collections example]: https://github.com/fedify-dev/fedify/tree/main/examples/custom-collections + ### Setting up a custom collection To create a custom collection, you use either `setCollectionDispatcher()` for diff --git a/examples/custom-collections/README.md b/examples/custom-collections/README.md index d816b90b9..f45c47c31 100644 --- a/examples/custom-collections/README.md +++ b/examples/custom-collections/README.md @@ -1,11 +1,29 @@ Custom collections example ========================== -This example demonstrates how to implement custom collections in Fedify. -Custom collections allow you to define your own ActivityPub collections with -custom logic for dispatching items and counting collection sizes. +This example is a small cookbook for custom ActivityPub collections in Fedify. +It uses a single-user bookmark log as the domain, but the important part is +how each collection dispatcher maps server-side data to an ActivityPub +collection. + +The script demonstrates three patterns: + + - `/users/alice/collections/public`: a public `OrderedCollection` with + cursor-based pages, `setCounter()`, `setFirstCursor()`, and + `setLastCursor()`. + - `/users/alice/collections/tags/{tag}`: a parameterized collection that + filters public bookmarks using a URI template value. + - `/users/alice/collections/followers-only`: a collection whose result + depends on the signed requester. It uses `.authorize()` with + `ctx.getSignedKeyOwner()` so unsigned or non-follower requests are rejected. + +Run it from this directory: ~~~~ sh -deno task codegen # At very first time only +deno task codegen # Only the first time deno run -A ./main.ts ~~~~ + +The output prints the actor document, collection metadata and page responses +for the public and tag-filtered routes, and the HTTP status codes returned +for unsigned requests to the followers-only collection. diff --git a/examples/custom-collections/federation.ts b/examples/custom-collections/federation.ts new file mode 100644 index 000000000..296da3763 --- /dev/null +++ b/examples/custom-collections/federation.ts @@ -0,0 +1,258 @@ +import { + createFederation, + MemoryKvStore, + type RequestContext, +} from "@fedify/fedify"; +import { + Article, + Hashtag, + Person, + PUBLIC_COLLECTION, + type Recipient, +} from "@fedify/vocab"; +import { + type Bookmark, + firstCursor, + followerIds, + followersOnlyBookmarks, + lastCursor, + normalizeActorId, + normalizeTag, + OWNER, + PAGE_SIZE, + parseCursor, + publicBookmarks, + taggedBookmarks, +} from "./lib.ts"; + +const PUBLIC_BOOKMARKS = "public-bookmarks"; +const TAGGED_BOOKMARKS = "tagged-bookmarks"; +const FOLLOWERS_ONLY_BOOKMARKS = "followers-only-bookmarks"; +const HTML_ESCAPES: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +const federation = createFederation({ + kv: new MemoryKvStore(), +}); + +federation + .setActorDispatcher("/users/{identifier}", (ctx, identifier) => { + if (identifier !== OWNER) return null; + + // inbox/outbox are omitted here; use setInboxListeners() and + // setOutboxDispatcher() for full ActivityPub actors. + return new Person({ + id: ctx.getActorUri(identifier), + preferredUsername: identifier, + name: "Alice's bookmarks", + summary: "A single-user bookmark log with custom collection examples.", + url: new URL(`/users/${identifier}`, ctx.url), + followers: ctx.getFollowersUri(identifier), + streams: [ + ctx.getCollectionUri(PUBLIC_BOOKMARKS, { identifier }), + ctx.getCollectionUri(TAGGED_BOOKMARKS, { + identifier, + tag: "activitypub", + }), + ctx.getCollectionUri(FOLLOWERS_ONLY_BOOKMARKS, { identifier }), + ], + }); + }); + +federation.setFollowersDispatcher( + "/users/{identifier}/followers", + (_ctx, identifier) => { + if (identifier !== OWNER) return null; + + // For large follower lists, add counters and cursor callbacks as below. + const items: Recipient[] = Array.from(followerIds, (id) => { + const actorId = new URL(id); + return { + id: actorId, + inboxId: new URL(`${actorId.pathname}/inbox`, actorId), + }; + }); + return { items }; + }, +); + +federation + .setOrderedCollectionDispatcher( + PUBLIC_BOOKMARKS, + Article, + "/users/{identifier}/collections/public", + (ctx, values, cursor) => { + if (values.identifier !== OWNER) return null; + + return collectionBookmarks( + ctx, + publicBookmarks(), + cursor, + ); + }, + ) + .setCounter((_ctx, values) => { + if (values.identifier !== OWNER) return null; + return publicBookmarks().length; + }) + .setFirstCursor((_ctx, values) => { + if (values.identifier !== OWNER) return null; + return firstCursor(publicBookmarks()); + }) + .setLastCursor((_ctx, values) => { + if (values.identifier !== OWNER) return null; + return lastCursor(publicBookmarks()); + }); + +federation + .setOrderedCollectionDispatcher( + TAGGED_BOOKMARKS, + Article, + "/users/{identifier}/collections/tags/{tag}", + (ctx, values, cursor) => { + if (values.identifier !== OWNER) return null; + + return collectionBookmarks( + ctx, + taggedBookmarks(values.tag), + cursor, + ); + }, + ) + .setCounter((_ctx, values) => { + if (values.identifier !== OWNER) return null; + return taggedBookmarks(values.tag).length; + }) + .setFirstCursor((_ctx, values) => { + if (values.identifier !== OWNER) return null; + return firstCursor(taggedBookmarks(values.tag)); + }) + .setLastCursor((_ctx, values) => { + if (values.identifier !== OWNER) return null; + return lastCursor(taggedBookmarks(values.tag)); + }); + +federation + .setOrderedCollectionDispatcher( + FOLLOWERS_ONLY_BOOKMARKS, + Article, + "/users/{identifier}/collections/followers-only", + (ctx, values, cursor) => { + if (values.identifier !== OWNER) return null; + + return collectionBookmarks( + ctx, + followersOnlyBookmarks(), + cursor, + ); + }, + ) + .setCounter((_ctx, values) => { + if (values.identifier !== OWNER) return null; + return followersOnlyBookmarks().length; + }) + .setFirstCursor((_ctx, values) => { + if (values.identifier !== OWNER) return null; + return firstCursor(followersOnlyBookmarks()); + }) + .setLastCursor((_ctx, values) => { + if (values.identifier !== OWNER) return null; + return lastCursor(followersOnlyBookmarks()); + }) + .authorize(async (ctx, values) => { + if (values.identifier !== OWNER) return true; + return await isFollowerRequest(ctx); + }); + +function collectionBookmarks( + ctx: RequestContext, + items: Bookmark[], + cursor: string | null, +): { + items: Article[]; + nextCursor: string | null; + prevCursor: string | null; +} { + return cursor == null + ? { + items: items.map((bookmark) => toArticle(ctx, bookmark)), + nextCursor: null, + prevCursor: null, + } + : pageBookmarks(ctx, items, cursor); +} + +function pageBookmarks( + ctx: RequestContext, + items: Bookmark[], + cursor: string, +): { + items: Article[]; + nextCursor: string | null; + prevCursor: string | null; +} { + const offset = parseCursor(cursor); + if (offset == null || offset >= Math.max(items.length, 1)) { + return { items: [], nextCursor: null, prevCursor: null }; + } + + return { + items: items + .slice(offset, offset + PAGE_SIZE) + .map((bookmark) => toArticle(ctx, bookmark)), + nextCursor: offset + PAGE_SIZE < items.length + ? String(offset + PAGE_SIZE) + : null, + prevCursor: offset > 0 ? String(Math.max(0, offset - PAGE_SIZE)) : null, + }; +} + +function toArticle(ctx: RequestContext, bookmark: Bookmark): Article { + const bookmarkUrl = new URL(bookmark.href); + return new Article({ + id: new URL(`/users/${OWNER}/bookmarks/${bookmark.id}`, ctx.url), + attribution: ctx.getActorUri(OWNER), + name: bookmark.title, + summary: bookmark.note, + content: `

${ + escapeHtml(bookmark.title) + }

`, + mediaType: "text/html", + url: bookmarkUrl, + published: bookmark.savedAt, + to: bookmark.visibility === "public" + ? PUBLIC_COLLECTION + : ctx.getFollowersUri(OWNER), + tags: bookmark.tags.map((tag) => + new Hashtag({ + href: ctx.getCollectionUri(TAGGED_BOOKMARKS, { + identifier: OWNER, + tag: normalizeTag(tag), + }), + name: `#${tag}`, + }) + ), + }); +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (character) => HTML_ESCAPES[character]); +} + +async function isFollowerRequest(ctx: RequestContext): Promise { + try { + const signedKeyOwner = await ctx.getSignedKeyOwner(); + return signedKeyOwner?.id == null + ? false + : followerIds.has(normalizeActorId(signedKeyOwner.id)); + } catch { + return false; + } +} + +export default federation; diff --git a/examples/custom-collections/lib.ts b/examples/custom-collections/lib.ts new file mode 100644 index 000000000..1268723cc --- /dev/null +++ b/examples/custom-collections/lib.ts @@ -0,0 +1,119 @@ +export const OWNER = "alice"; +export const PAGE_SIZE = 2; + +export interface Bookmark { + id: string; + title: string; + href: string; + note: string; + tags: string[]; + visibility: "public" | "followers"; + savedAt: Temporal.Instant; +} + +const bookmarks: Bookmark[] = [ + { + id: "fedify-manual", + title: "Fedify manual", + href: "https://fedify.dev/manual/", + note: "Reference material for building ActivityPub servers with Fedify.", + tags: ["fedify", "activitypub"], + visibility: "public", + savedAt: Temporal.Instant.from("2026-04-20T09:00:00Z"), + }, + { + id: "activitypub-spec", + title: "ActivityPub specification", + href: "https://www.w3.org/TR/activitypub/", + note: "The W3C ActivityPub recommendation.", + tags: ["activitypub", "spec"], + visibility: "public", + savedAt: Temporal.Instant.from("2026-04-19T12:00:00Z"), + }, + { + id: "uri-template", + title: "URI Template", + href: "https://www.rfc-editor.org/rfc/rfc6570", + note: "How Fedify dispatcher path parameters are expanded.", + tags: ["spec", "routing"], + visibility: "public", + savedAt: Temporal.Instant.from("2026-04-18T16:30:00Z"), + }, + { + id: "private-reading-list", + title: "Private reading list", + href: "https://example.net/reading-list", + note: "A bookmark visible only to accepted followers.", + tags: ["fedify", "reading"], + visibility: "followers", + savedAt: Temporal.Instant.from("2026-04-17T08:15:00Z"), + }, + { + id: "moderation-notes", + title: "Moderation notes", + href: "https://example.net/moderation", + note: "Follower-facing notes for a small community server.", + tags: ["activitypub", "moderation"], + visibility: "followers", + savedAt: Temporal.Instant.from("2026-04-16T10:45:00Z"), + }, +]; + +export const followerIds = new Set( + [ + "https://remote.example/users/bob", + "https://social.example/users/carol", + ].map(normalizeActorId), +); + +export function publicBookmarks(): Bookmark[] { + return sortBookmarks( + bookmarks.filter((bookmark) => bookmark.visibility === "public"), + ); +} + +export function followersOnlyBookmarks(): Bookmark[] { + return sortBookmarks( + bookmarks.filter((bookmark) => bookmark.visibility === "followers"), + ); +} + +export function taggedBookmarks(tag: string): Bookmark[] { + const normalizedTag = normalizeTag(tag); + return sortBookmarks( + bookmarks.filter((bookmark) => + bookmark.visibility === "public" && + bookmark.tags.some((itemTag) => normalizeTag(itemTag) === normalizedTag) + ), + ); +} + +function sortBookmarks(items: Bookmark[]): Bookmark[] { + return items.toSorted((a, b) => + Temporal.Instant.compare(b.savedAt, a.savedAt) + ); +} + +export function firstCursor(items: Bookmark[]): string | null { + return items.length < 1 ? null : "0"; +} + +export function lastCursor(items: Bookmark[]): string | null { + if (items.length < 1) return null; + return String(Math.floor((items.length - 1) / PAGE_SIZE) * PAGE_SIZE); +} + +export function parseCursor(cursor: string): number | null { + const offset = Number(cursor); + return Number.isSafeInteger(offset) && offset >= 0 && offset % PAGE_SIZE === 0 + ? offset + : null; +} + +export function normalizeTag(tag: string): string { + return tag.trim().toLowerCase(); +} + +export function normalizeActorId(id: string | URL): string { + return new URL(id).href; +} diff --git a/examples/custom-collections/main.ts b/examples/custom-collections/main.ts index 55cda8f81..80de397df 100644 --- a/examples/custom-collections/main.ts +++ b/examples/custom-collections/main.ts @@ -1,118 +1,73 @@ -import { createFederation, MemoryKvStore } from "@fedify/fedify"; -import { Note } from "@fedify/vocab"; +import federation from "./federation.ts"; -// Mock data - in a real application, this would query your database -const POSTS = [ - new Note({ - id: new URL("https://example.com/posts/post-1"), - content: "ActivityPub is a decentralized social networking protocol...", - tags: [ - new URL("https://example.com/tags/ActivityPub"), - new URL("https://example.com/tags/Decentralization"), - ], - }), - - new Note({ - id: new URL("https://example.com/posts/post-2"), - content: "Fedify makes it easy to build federated applications...", - }), - - new Note({ - id: new URL("https://example.com/posts/post-3"), - content: "WebFinger is a protocol for discovering information...", - tags: [new URL("https://example.com/tags/ActivityPub")], - }), +async function fetchActivityJson(path: string): Promise { + const response = await federation.fetch( + new Request(new URL(path, "https://example.com"), { + headers: { Accept: "application/activity+json" }, + }), + { contextData: undefined }, + ); - new Note({ - id: new URL("https://example.com/posts/post-4"), - content: "HTTP Signatures provide authentication for ActivityPub...", - }), + if (!response.ok) { + throw new Error(`${path}: ${response.status} ${await response.text()}`); + } + return await response.json(); +} - new Note({ - id: new URL("https://example.com/posts/post-5"), - content: "Understanding ActivityPub's data model is crucial...", - }), -]; +async function fetchStatus(path: string): Promise { + const response = await federation.fetch( + new Request(new URL(path, "https://example.com"), { + headers: { Accept: "application/activity+json" }, + }), + { contextData: undefined }, + ); -function getTagFromUrl(url: string): string { - const parts = url.split("/"); - return parts[parts.length - 1]; + const status = response.status; + await response.body?.cancel(); + return status; } -function getTaggedPostsByTag(tag: string): Note[] { - return POSTS - .filter((post) => { - if (!post.tagIds) { - return false; - } - return post.tagIds.some((tagId) => { - return getTagFromUrl(tagId.toString()) === tag; - }); - }); +async function printActivityJson(label: string, path: string): Promise { + console.log(`\n## ${label}`); + console.log(JSON.stringify(await fetchActivityJson(path), null, 2)); } -async function demonstrateCustomCollection(): Promise { - // Federation instance created for demonstration - const federation = createFederation({ kv: new MemoryKvStore() }); - - federation.setCollectionDispatcher( - "TaggedPosts", - Note, - "/users/{userId}/tags/{tag}", - ( - _ctx: { url: URL }, - values: Record, - cursor: string | null, - ) => { - if (!values.tag) { - throw new Error("Missing userId or tag in values"); - } - - // Normally here you would look up posts from a database by user ID and tag name: - const posts = getTaggedPostsByTag(values.tag); - - if (cursor != null) { - const idx = Number.parseInt(cursor, 10); - if (Number.isNaN(idx) || idx > posts.length || idx < 0) { - return { items: [], nextCursor: null, prevCursor: null }; - } - return { - items: idx < posts.length ? [posts[idx]] : [], - nextCursor: idx < posts.length - 1 ? (idx + 1).toString() : null, - prevCursor: idx > 0 ? (idx - 1).toString() : null, - }; - } - return { items: posts, nextCursor: null, prevCursor: null }; - }, - ).setCounter((_ctx, values) => { - // Return the total count of tagged posts - const count = getTaggedPostsByTag(values.tag).length; - return count; - }); +async function printActivityStatus( + label: string, + path: string, +): Promise { + console.log(`\n## ${label}`); + console.log(await fetchStatus(path)); +} - return await federation.fetch( - new Request( - "https://example.com/users/123/tags/ActivityPub", - { - headers: { - Accept: "application/activity+json", - }, - }, - ), - { - contextData: undefined, - }, +async function main(): Promise { + await printActivityJson("Actor with custom collection links", "/users/alice"); + await printActivityJson( + "Public bookmarks collection", + "/users/alice/collections/public", + ); + await printActivityJson( + "Public bookmarks first page", + "/users/alice/collections/public?cursor=0", + ); + await printActivityJson( + "Tag-filtered ActivityPub bookmarks collection", + "/users/alice/collections/tags/activitypub", + ); + await printActivityJson( + "Tag-filtered ActivityPub bookmarks first page", + "/users/alice/collections/tags/activitypub?cursor=0", + ); + await printActivityStatus( + "Followers-only collection requested without a signature", + "/users/alice/collections/followers-only", + ); + await printActivityStatus( + "Followers-only first page requested without a signature", + "/users/alice/collections/followers-only?cursor=0", ); } if (import.meta.main) { - const response = await demonstrateCustomCollection(); - - if (response.ok) { - const jsonResponse = await response.json(); - console.log("Custom collection data:", jsonResponse); - } else { - const errorText = await response.text(); - console.log("Error response:", errorText); - } + await main(); }