-
-
Notifications
You must be signed in to change notification settings - Fork 99
Custom collections cookbook example #722
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
5d9dd09
Add custom collections cookbook
dahlia 10d1fdf
Tighten custom collections example
dahlia 8d24c33
Polish custom collections cookbook
dahlia d6946d1
Clarify custom collection boundaries
dahlia 08157ea
Tighten bookmark Article output
dahlia 028229d
Keep bookmark summaries plain
dahlia 2642c58
Split custom collections example
dahlia 46cc69c
Tidy custom collection runner
dahlia a9bf404
Treat bad follower signatures as denied
dahlia 100192f
Use table-driven HTML escaping
dahlia File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,258 @@ | ||
| import { | ||
|
dahlia marked this conversation as resolved.
|
||
| 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<string, string> = { | ||
| "&": "&", | ||
| "<": "<", | ||
| ">": ">", | ||
| '"': """, | ||
| "'": "'", | ||
| }; | ||
|
|
||
| const federation = createFederation<void>({ | ||
| 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()); | ||
| }); | ||
|
dahlia marked this conversation as resolved.
|
||
|
|
||
| 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, | ||
| ); | ||
|
dahlia marked this conversation as resolved.
|
||
| }, | ||
| ) | ||
| .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); | ||
| }); | ||
|
dahlia marked this conversation as resolved.
|
||
|
|
||
| function collectionBookmarks( | ||
| ctx: RequestContext<void>, | ||
| 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); | ||
| } | ||
|
dahlia marked this conversation as resolved.
dahlia marked this conversation as resolved.
dahlia marked this conversation as resolved.
|
||
|
|
||
| function pageBookmarks( | ||
| ctx: RequestContext<void>, | ||
| 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<void>, 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: `<p><a href="${escapeHtml(bookmarkUrl.href)}">${ | ||
| escapeHtml(bookmark.title) | ||
| }</a></p>`, | ||
| 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]); | ||
| } | ||
|
dahlia marked this conversation as resolved.
|
||
|
|
||
| async function isFollowerRequest(ctx: RequestContext<void>): Promise<boolean> { | ||
| try { | ||
| const signedKeyOwner = await ctx.getSignedKeyOwner(); | ||
| return signedKeyOwner?.id == null | ||
| ? false | ||
| : followerIds.has(normalizeActorId(signedKeyOwner.id)); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| export default federation; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.