Loading CHANGES.md +5 −1 Original line number Diff line number Diff line Loading @@ -13,7 +13,10 @@ To be released. - Added `suppressError` option to dereferencing accessors of Activity Vocabulary classes. - Added more collection dispatchers. [[#78]] - Added `Federation.setInboxDispatcher()` method. [[#71]] - Added `Federation.setLikedDispatcher()` method. - Frequently used JSON-LD contexts are now preloaded. [[74]] Loading Loading @@ -44,6 +47,7 @@ To be released. [#71]: https://github.com/dahlia/fedify/issues/71 [#74]: https://github.com/dahlia/fedify/issues/74 [#76]: https://github.com/dahlia/fedify/pull/76 [#78]: https://github.com/dahlia/fedify/issues/78 [#79]: https://github.com/dahlia/fedify/issues/79 Loading docs/manual/collections.md +32 −0 Original line number Diff line number Diff line Loading @@ -469,3 +469,35 @@ federation > In the above example, we filter the actors in memory, but in the real > world, you should filter the actors in the database query to improve the > performance. Liked ----- *This API is available since Fedify 0.11.0.* The liked collection is a collection of objects that an actor has liked. The liked collection is similar to the outbox collection, but it's a collection of `Like` activities instead of any activities. Cursors and counters for the liked collection are implemented in the same way as the outbox collection, so we don't repeat the explanation here. The below example shows how to construct a liked collection: ~~~~ typescript federation .setLikedDispatcher("/users/{handle}/liked", async (ctx, handle, cursor) => { // Work with the database to find the objects that the actor has liked // (the below `getLikedPostsByUserHandle` is a hypothetical function): const objects = await getLikedByUserHandle(handle); // Turn the posts into `Like` activities: const items = posts.map(post => new Like({ id: new URL(`#post-${post.id}`, ctx.url), actor: ctx.getActorUri(handle), object: new URL(post.uri), }) ); }); ~~~~ federation/context.ts +15 −1 Original line number Diff line number Diff line Loading @@ -97,6 +97,15 @@ export interface Context<TContextData> { */ getFollowersUri(handle: string): URL; /** * Builds the URI of an actor's liked collection with the given handle. * @param handle The actor's handle. * @returns The actor's liked collection URI. * @throws {RouterError} If no liked collection is available. * @since 0.11.0 */ getLikedUri(handle: string): URL; /** * Determines the type of the URI and extracts the associated data. * @param uri The URI to parse. Loading Loading @@ -304,7 +313,12 @@ export type ParseUriResult = /** * The case of a followers collection URI. */ | { type: "followers"; handle: string }; | { type: "followers"; handle: string } /** * The case of a liked collection URI. * @since 0.11.0 */ | { type: "liked"; handle: string }; /** * Options for {@link Context.sendActivity} method and Loading federation/middleware.test.ts +15 −0 Original line number Diff line number Diff line Loading @@ -73,6 +73,7 @@ test("Federation.createContext()", async (t) => { assertThrows(() => ctx.getOutboxUri("handle"), RouterError); assertThrows(() => ctx.getFollowingUri("handle"), RouterError); assertThrows(() => ctx.getFollowersUri("handle"), RouterError); assertThrows(() => ctx.getLikedUri("handle"), RouterError); assertEquals(ctx.parseUri(new URL("https://example.com/")), null); assertEquals( ctx.getHandleFromActorUri(new URL("https://example.com/")), Loading Loading @@ -288,6 +289,20 @@ test("Federation.createContext()", async (t) => { ctx.parseUri(new URL("https://example.com/users/handle/followers")), { type: "followers", handle: "handle" }, ); federation.setLikedDispatcher( "/users/{handle}/liked", () => ({ items: [] }), ); ctx = federation.createContext(new URL("https://example.com/"), 123); assertEquals( ctx.getLikedUri("handle"), new URL("https://example.com/users/handle/liked"), ); assertEquals( ctx.parseUri(new URL("https://example.com/users/handle/liked")), { type: "liked", handle: "handle" }, ); }); await t.step("RequestContext", async () => { Loading federation/middleware.ts +73 −1 Original line number Diff line number Diff line Loading @@ -14,9 +14,10 @@ import type { Actor, Recipient } from "../vocab/actor.ts"; import { Activity, CryptographicKey, type Like, Multikey, type Object, } from "../vocab/mod.ts"; } from "../vocab/vocab.ts"; import { handleWebFinger } from "../webfinger/handler.ts"; import type { ActorDispatcher, Loading Loading @@ -244,6 +245,7 @@ export class Federation<TContextData> { #outboxCallbacks?: CollectionCallbacks<Activity, TContextData, void>; #followingCallbacks?: CollectionCallbacks<Actor | URL, TContextData, void>; #followersCallbacks?: CollectionCallbacks<Recipient, TContextData, URL>; #likedCallbacks?: CollectionCallbacks<Like, TContextData, void>; #inboxListeners?: Map< new (...args: unknown[]) => Activity, InboxListener<TContextData, Activity> Loading Loading @@ -1086,6 +1088,56 @@ export class Federation<TContextData> { return setters; } /** * Registers a liked collection dispatcher. * @param path The URI path pattern for the liked collection. The syntax * is based on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). The path * must have one variable: `{handle}`. * @param dispatcher A liked collection callback to register. * @returns An object with methods to set other liked collection * callbacks. * @throws {@link RouterError} Thrown if the path pattern is invalid. * @since 0.11.0 */ setLikedDispatcher( path: `${string}{handle}${string}`, dispatcher: CollectionDispatcher<Like, TContextData, void>, ): CollectionCallbackSetters<TContextData, void> { if (this.#router.has("liked")) { throw new RouterError("Liked collection dispatcher already set."); } const variables = this.#router.add(path, "liked"); if (variables.size !== 1 || !variables.has("handle")) { throw new RouterError( "Path for liked collection dispatcher must have one variable: {handle}", ); } const callbacks: CollectionCallbacks<Like, TContextData, void> = { dispatcher, }; this.#likedCallbacks = callbacks; const setters: CollectionCallbackSetters<TContextData, void> = { setCounter(counter: CollectionCounter<TContextData, void>) { callbacks.counter = counter; return setters; }, setFirstCursor(cursor: CollectionCursor<TContextData, void>) { callbacks.firstCursor = cursor; return setters; }, setLastCursor(cursor: CollectionCursor<TContextData, void>) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } /** * Assigns the URL path for the inbox and starts setting inbox listeners. * Loading Loading @@ -1469,6 +1521,16 @@ export class Federation<TContextData> { onNotAcceptable, }); } case "liked": return await handleCollection(request, { name: "liked", handle: route.values.handle, context, collectionCallbacks: this.#likedCallbacks, onUnauthorized, onNotFound, onNotAcceptable, }); default: { const response = onNotFound(request); return response instanceof Promise ? await response : response; Loading Loading @@ -1619,6 +1681,14 @@ class ContextImpl<TContextData> implements Context<TContextData> { return new URL(path, this.#url); } getLikedUri(handle: string): URL { const path = this.#router.build("liked", { handle }); if (path == null) { throw new RouterError("No liked collection path registered."); } return new URL(path, this.#url); } parseUri(uri: URL): ParseUriResult | null { if (uri.origin !== this.#url.origin) return null; const route = this.#router.route(uri.pathname); Loading @@ -1643,6 +1713,8 @@ class ContextImpl<TContextData> implements Context<TContextData> { return { type: "following", handle: route.values.handle }; } else if (route.name === "followers") { return { type: "followers", handle: route.values.handle }; } else if (route.name === "liked") { return { type: "liked", handle: route.values.handle }; } return null; } Loading Loading
CHANGES.md +5 −1 Original line number Diff line number Diff line Loading @@ -13,7 +13,10 @@ To be released. - Added `suppressError` option to dereferencing accessors of Activity Vocabulary classes. - Added more collection dispatchers. [[#78]] - Added `Federation.setInboxDispatcher()` method. [[#71]] - Added `Federation.setLikedDispatcher()` method. - Frequently used JSON-LD contexts are now preloaded. [[74]] Loading Loading @@ -44,6 +47,7 @@ To be released. [#71]: https://github.com/dahlia/fedify/issues/71 [#74]: https://github.com/dahlia/fedify/issues/74 [#76]: https://github.com/dahlia/fedify/pull/76 [#78]: https://github.com/dahlia/fedify/issues/78 [#79]: https://github.com/dahlia/fedify/issues/79 Loading
docs/manual/collections.md +32 −0 Original line number Diff line number Diff line Loading @@ -469,3 +469,35 @@ federation > In the above example, we filter the actors in memory, but in the real > world, you should filter the actors in the database query to improve the > performance. Liked ----- *This API is available since Fedify 0.11.0.* The liked collection is a collection of objects that an actor has liked. The liked collection is similar to the outbox collection, but it's a collection of `Like` activities instead of any activities. Cursors and counters for the liked collection are implemented in the same way as the outbox collection, so we don't repeat the explanation here. The below example shows how to construct a liked collection: ~~~~ typescript federation .setLikedDispatcher("/users/{handle}/liked", async (ctx, handle, cursor) => { // Work with the database to find the objects that the actor has liked // (the below `getLikedPostsByUserHandle` is a hypothetical function): const objects = await getLikedByUserHandle(handle); // Turn the posts into `Like` activities: const items = posts.map(post => new Like({ id: new URL(`#post-${post.id}`, ctx.url), actor: ctx.getActorUri(handle), object: new URL(post.uri), }) ); }); ~~~~
federation/context.ts +15 −1 Original line number Diff line number Diff line Loading @@ -97,6 +97,15 @@ export interface Context<TContextData> { */ getFollowersUri(handle: string): URL; /** * Builds the URI of an actor's liked collection with the given handle. * @param handle The actor's handle. * @returns The actor's liked collection URI. * @throws {RouterError} If no liked collection is available. * @since 0.11.0 */ getLikedUri(handle: string): URL; /** * Determines the type of the URI and extracts the associated data. * @param uri The URI to parse. Loading Loading @@ -304,7 +313,12 @@ export type ParseUriResult = /** * The case of a followers collection URI. */ | { type: "followers"; handle: string }; | { type: "followers"; handle: string } /** * The case of a liked collection URI. * @since 0.11.0 */ | { type: "liked"; handle: string }; /** * Options for {@link Context.sendActivity} method and Loading
federation/middleware.test.ts +15 −0 Original line number Diff line number Diff line Loading @@ -73,6 +73,7 @@ test("Federation.createContext()", async (t) => { assertThrows(() => ctx.getOutboxUri("handle"), RouterError); assertThrows(() => ctx.getFollowingUri("handle"), RouterError); assertThrows(() => ctx.getFollowersUri("handle"), RouterError); assertThrows(() => ctx.getLikedUri("handle"), RouterError); assertEquals(ctx.parseUri(new URL("https://example.com/")), null); assertEquals( ctx.getHandleFromActorUri(new URL("https://example.com/")), Loading Loading @@ -288,6 +289,20 @@ test("Federation.createContext()", async (t) => { ctx.parseUri(new URL("https://example.com/users/handle/followers")), { type: "followers", handle: "handle" }, ); federation.setLikedDispatcher( "/users/{handle}/liked", () => ({ items: [] }), ); ctx = federation.createContext(new URL("https://example.com/"), 123); assertEquals( ctx.getLikedUri("handle"), new URL("https://example.com/users/handle/liked"), ); assertEquals( ctx.parseUri(new URL("https://example.com/users/handle/liked")), { type: "liked", handle: "handle" }, ); }); await t.step("RequestContext", async () => { Loading
federation/middleware.ts +73 −1 Original line number Diff line number Diff line Loading @@ -14,9 +14,10 @@ import type { Actor, Recipient } from "../vocab/actor.ts"; import { Activity, CryptographicKey, type Like, Multikey, type Object, } from "../vocab/mod.ts"; } from "../vocab/vocab.ts"; import { handleWebFinger } from "../webfinger/handler.ts"; import type { ActorDispatcher, Loading Loading @@ -244,6 +245,7 @@ export class Federation<TContextData> { #outboxCallbacks?: CollectionCallbacks<Activity, TContextData, void>; #followingCallbacks?: CollectionCallbacks<Actor | URL, TContextData, void>; #followersCallbacks?: CollectionCallbacks<Recipient, TContextData, URL>; #likedCallbacks?: CollectionCallbacks<Like, TContextData, void>; #inboxListeners?: Map< new (...args: unknown[]) => Activity, InboxListener<TContextData, Activity> Loading Loading @@ -1086,6 +1088,56 @@ export class Federation<TContextData> { return setters; } /** * Registers a liked collection dispatcher. * @param path The URI path pattern for the liked collection. The syntax * is based on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). The path * must have one variable: `{handle}`. * @param dispatcher A liked collection callback to register. * @returns An object with methods to set other liked collection * callbacks. * @throws {@link RouterError} Thrown if the path pattern is invalid. * @since 0.11.0 */ setLikedDispatcher( path: `${string}{handle}${string}`, dispatcher: CollectionDispatcher<Like, TContextData, void>, ): CollectionCallbackSetters<TContextData, void> { if (this.#router.has("liked")) { throw new RouterError("Liked collection dispatcher already set."); } const variables = this.#router.add(path, "liked"); if (variables.size !== 1 || !variables.has("handle")) { throw new RouterError( "Path for liked collection dispatcher must have one variable: {handle}", ); } const callbacks: CollectionCallbacks<Like, TContextData, void> = { dispatcher, }; this.#likedCallbacks = callbacks; const setters: CollectionCallbackSetters<TContextData, void> = { setCounter(counter: CollectionCounter<TContextData, void>) { callbacks.counter = counter; return setters; }, setFirstCursor(cursor: CollectionCursor<TContextData, void>) { callbacks.firstCursor = cursor; return setters; }, setLastCursor(cursor: CollectionCursor<TContextData, void>) { callbacks.lastCursor = cursor; return setters; }, authorize(predicate: AuthorizePredicate<TContextData>) { callbacks.authorizePredicate = predicate; return setters; }, }; return setters; } /** * Assigns the URL path for the inbox and starts setting inbox listeners. * Loading Loading @@ -1469,6 +1521,16 @@ export class Federation<TContextData> { onNotAcceptable, }); } case "liked": return await handleCollection(request, { name: "liked", handle: route.values.handle, context, collectionCallbacks: this.#likedCallbacks, onUnauthorized, onNotFound, onNotAcceptable, }); default: { const response = onNotFound(request); return response instanceof Promise ? await response : response; Loading Loading @@ -1619,6 +1681,14 @@ class ContextImpl<TContextData> implements Context<TContextData> { return new URL(path, this.#url); } getLikedUri(handle: string): URL { const path = this.#router.build("liked", { handle }); if (path == null) { throw new RouterError("No liked collection path registered."); } return new URL(path, this.#url); } parseUri(uri: URL): ParseUriResult | null { if (uri.origin !== this.#url.origin) return null; const route = this.#router.route(uri.pathname); Loading @@ -1643,6 +1713,8 @@ class ContextImpl<TContextData> implements Context<TContextData> { return { type: "following", handle: route.values.handle }; } else if (route.name === "followers") { return { type: "followers", handle: route.values.handle }; } else if (route.name === "liked") { return { type: "liked", handle: route.values.handle }; } return null; } Loading