Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Comment thread
dahlia marked this conversation as resolved.

[*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
Expand Down
5 changes: 5 additions & 0 deletions docs/manual/collections.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 22 additions & 4 deletions examples/custom-collections/README.md
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.
258 changes: 258 additions & 0 deletions examples/custom-collections/federation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import {
Comment thread
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> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};

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());
});
Comment thread
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,
);
Comment thread
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);
});
Comment thread
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);
}
Comment thread
dahlia marked this conversation as resolved.
Comment thread
dahlia marked this conversation as resolved.
Comment thread
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]);
}
Comment thread
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;
Loading
Loading