From d63861036144ec50b2d28dc1264a0013768aeb11 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Tue, 8 Apr 2025 16:01:10 +0200 Subject: [PATCH] refactor(federation): :recycle: Rewrite federation SDK --- api/api/v1/accounts/lookup/index.ts | 5 +- api/api/v1/accounts/search/index.ts | 4 +- .../v1/accounts/update_credentials/index.ts | 23 +- api/api/v1/statuses/[id]/index.ts | 5 +- api/api/v1/statuses/index.ts | 5 +- api/api/v2/search/index.ts | 10 +- api/inbox/index.ts | 3 +- api/likes/[uuid]/index.ts | 2 +- api/notes/[uuid]/index.ts | 2 +- api/notes/[uuid]/quotes.ts | 22 +- api/notes/[uuid]/replies.ts | 22 +- api/users/[uuid]/inbox/index.ts | 4 +- api/users/[uuid]/index.ts | 3 +- api/users/[uuid]/outbox/index.ts | 20 +- api/well-known/versia.ts | 2 +- api/well-known/webfinger/index.ts | 28 +- bun.lock | 14 +- classes/database/emoji.ts | 26 +- classes/database/instance.ts | 139 +++----- classes/database/like.ts | 29 +- classes/database/media.ts | 39 ++- classes/database/note.ts | 200 +++++------ classes/database/reaction.ts | 27 +- classes/database/user.ts | 330 ++++++++---------- classes/functions/status.ts | 32 +- classes/inbox/processor.ts | 210 ++++------- classes/queues/delivery.ts | 23 +- classes/queues/inbox.ts | 60 ++-- drizzle/schema.ts | 34 +- package.json | 2 +- packages/federation/crypto.ts | 74 ++++ packages/federation/entities/collection.ts | 29 ++ packages/federation/entities/contentformat.ts | 82 +++++ packages/federation/entities/delete.ts | 16 + packages/federation/entities/entity.ts | 17 + .../federation/entities/extensions/likes.ts | 28 ++ .../federation/entities/extensions/polls.ts | 16 + .../entities/extensions/reactions.ts | 16 + .../federation/entities/extensions/reports.ts | 16 + .../federation/entities/extensions/share.ts | 16 + packages/federation/entities/follow.ts | 61 ++++ packages/federation/entities/index.ts | 21 ++ .../federation/entities/instancemetadata.ts | 31 ++ packages/federation/entities/note.ts | 29 ++ packages/federation/entities/user.ts | 33 ++ packages/federation/http.ts | 203 +++++++++++ packages/federation/inbox-processor.ts | 54 +++ packages/federation/package.json | 69 ++++ packages/federation/regex.ts | 64 ++++ packages/federation/schemas/collection.ts | 16 + packages/federation/schemas/common.ts | 17 + packages/federation/schemas/contentformat.ts | 117 +++++++ packages/federation/schemas/delete.ts | 11 + packages/federation/schemas/entity.ts | 23 ++ .../federation/schemas/extensions/emojis.ts | 25 ++ .../federation/schemas/extensions/groups.ts | 41 +++ .../federation/schemas/extensions/likes.ts | 15 + .../schemas/extensions/migration.ts | 15 + .../federation/schemas/extensions/polls.ts | 22 ++ .../schemas/extensions/reactions.ts | 10 + .../federation/schemas/extensions/reports.ts | 15 + .../federation/schemas/extensions/share.ts | 9 + .../federation/schemas/extensions/vanity.ts | 46 +++ packages/federation/schemas/follow.ts | 31 ++ packages/federation/schemas/index.ts | 27 ++ packages/federation/schemas/instance.ts | 41 +++ packages/federation/schemas/note.ts | 67 ++++ packages/federation/schemas/user.ts | 60 ++++ packages/federation/schemas/webfinger.ts | 19 + packages/federation/types.ts | 11 + types/api.ts | 30 +- utils/content_types.ts | 7 +- 72 files changed, 2137 insertions(+), 738 deletions(-) create mode 100644 packages/federation/crypto.ts create mode 100644 packages/federation/entities/collection.ts create mode 100644 packages/federation/entities/contentformat.ts create mode 100644 packages/federation/entities/delete.ts create mode 100644 packages/federation/entities/entity.ts create mode 100644 packages/federation/entities/extensions/likes.ts create mode 100644 packages/federation/entities/extensions/polls.ts create mode 100644 packages/federation/entities/extensions/reactions.ts create mode 100644 packages/federation/entities/extensions/reports.ts create mode 100644 packages/federation/entities/extensions/share.ts create mode 100644 packages/federation/entities/follow.ts create mode 100644 packages/federation/entities/index.ts create mode 100644 packages/federation/entities/instancemetadata.ts create mode 100644 packages/federation/entities/note.ts create mode 100644 packages/federation/entities/user.ts create mode 100644 packages/federation/http.ts create mode 100644 packages/federation/inbox-processor.ts create mode 100644 packages/federation/package.json create mode 100644 packages/federation/regex.ts create mode 100644 packages/federation/schemas/collection.ts create mode 100644 packages/federation/schemas/common.ts create mode 100644 packages/federation/schemas/contentformat.ts create mode 100644 packages/federation/schemas/delete.ts create mode 100644 packages/federation/schemas/entity.ts create mode 100644 packages/federation/schemas/extensions/emojis.ts create mode 100644 packages/federation/schemas/extensions/groups.ts create mode 100644 packages/federation/schemas/extensions/likes.ts create mode 100644 packages/federation/schemas/extensions/migration.ts create mode 100644 packages/federation/schemas/extensions/polls.ts create mode 100644 packages/federation/schemas/extensions/reactions.ts create mode 100644 packages/federation/schemas/extensions/reports.ts create mode 100644 packages/federation/schemas/extensions/share.ts create mode 100644 packages/federation/schemas/extensions/vanity.ts create mode 100644 packages/federation/schemas/follow.ts create mode 100644 packages/federation/schemas/index.ts create mode 100644 packages/federation/schemas/instance.ts create mode 100644 packages/federation/schemas/note.ts create mode 100644 packages/federation/schemas/user.ts create mode 100644 packages/federation/schemas/webfinger.ts create mode 100644 packages/federation/types.ts diff --git a/api/api/v1/accounts/lookup/index.ts b/api/api/v1/accounts/lookup/index.ts index 7dd68a72..c2d13a39 100644 --- a/api/api/v1/accounts/lookup/index.ts +++ b/api/api/v1/accounts/lookup/index.ts @@ -49,7 +49,6 @@ export default apiRoute((app) => ), async (context) => { const { acct } = context.req.valid("query"); - const { user } = context.get("auth"); // Check if acct is matching format username@domain.com or @username@domain.com const { username, domain } = parseUserAddress(acct); @@ -93,9 +92,7 @@ export default apiRoute((app) => } // Fetch from remote instance - const manager = await (user ?? User).getFederationRequester(); - - const uri = await User.webFinger(manager, username, domain); + const uri = await User.webFinger(username, domain); if (!uri) { throw ApiError.accountNotFound(); diff --git a/api/api/v1/accounts/search/index.ts b/api/api/v1/accounts/search/index.ts index 30536892..dfe3f421 100644 --- a/api/api/v1/accounts/search/index.ts +++ b/api/api/v1/accounts/search/index.ts @@ -91,9 +91,7 @@ export default apiRoute((app) => const accounts: User[] = []; if (resolve && domain) { - const manager = await (user ?? User).getFederationRequester(); - - const uri = await User.webFinger(manager, username, domain); + const uri = await User.webFinger(username, domain); if (uri) { const resolvedUser = await User.resolve(uri); diff --git a/api/api/v1/accounts/update_credentials/index.ts b/api/api/v1/accounts/update_credentials/index.ts index 11d2057f..12ac5abd 100644 --- a/api/api/v1/accounts/update_credentials/index.ts +++ b/api/api/v1/accounts/update_credentials/index.ts @@ -5,6 +5,7 @@ import { Account as AccountSchema, zBoolean } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas"; import { Emoji, Media, User } from "@versia/kit/db"; import { Users } from "@versia/kit/tables"; +import * as VersiaEntities from "@versia/sdk/entities"; import { and, eq, isNull } from "drizzle-orm"; import { describeRoute } from "hono-openapi"; import { resolver, validator } from "hono-openapi/zod"; @@ -186,12 +187,14 @@ export default apiRoute((app) => if (note && self.source) { self.source.note = note; - self.note = await contentToHtml({ - "text/markdown": { - content: note, - remote: false, - }, - }); + self.note = await contentToHtml( + new VersiaEntities.TextContentFormat({ + "text/markdown": { + content: note, + remote: false, + }, + }), + ); } if (source?.privacy) { @@ -275,23 +278,23 @@ export default apiRoute((app) => for (const field of fields_attributes) { // Can be Markdown or plaintext, also has emojis const parsedName = await contentToHtml( - { + new VersiaEntities.TextContentFormat({ "text/markdown": { content: field.name, remote: false, }, - }, + }), undefined, true, ); const parsedValue = await contentToHtml( - { + new VersiaEntities.TextContentFormat({ "text/markdown": { content: field.value, remote: false, }, - }, + }), undefined, true, ); diff --git a/api/api/v1/statuses/[id]/index.ts b/api/api/v1/statuses/[id]/index.ts index b706ce54..ae699bb6 100644 --- a/api/api/v1/statuses/[id]/index.ts +++ b/api/api/v1/statuses/[id]/index.ts @@ -14,6 +14,7 @@ import { } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas"; import { Media } from "@versia/kit/db"; +import * as VersiaEntities from "@versia/sdk/entities"; import { describeRoute } from "hono-openapi"; import { resolver, validator } from "hono-openapi/zod"; import { z } from "zod"; @@ -228,12 +229,12 @@ export default apiRoute((app) => { const newNote = await note.updateFromData({ author: user, content: statusText - ? { + ? new VersiaEntities.TextContentFormat({ [content_type]: { content: statusText, remote: false, }, - } + }) : undefined, isSensitive: sensitive, spoilerText: spoiler_text, diff --git a/api/api/v1/statuses/index.ts b/api/api/v1/statuses/index.ts index b0a529a5..8ea0180a 100644 --- a/api/api/v1/statuses/index.ts +++ b/api/api/v1/statuses/index.ts @@ -8,6 +8,7 @@ import { } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas"; import { Media, Note } from "@versia/kit/db"; +import * as VersiaEntities from "@versia/sdk/entities"; import { describeRoute } from "hono-openapi"; import { resolver, validator } from "hono-openapi/zod"; import { z } from "zod"; @@ -176,12 +177,12 @@ export default apiRoute((app) => const newNote = await Note.fromData({ author: user, - content: { + content: new VersiaEntities.TextContentFormat({ [content_type]: { content: status ?? "", remote: false, }, - }, + }), visibility, isSensitive: sensitive ?? false, spoilerText: spoiler_text ?? "", diff --git a/api/api/v2/search/index.ts b/api/api/v2/search/index.ts index eab6a163..1bc0c01c 100644 --- a/api/api/v2/search/index.ts +++ b/api/api/v2/search/index.ts @@ -198,15 +198,7 @@ export default apiRoute((app) => } if (resolve && domain) { - const manager = await ( - user ?? User - ).getFederationRequester(); - - const uri = await User.webFinger( - manager, - username, - domain, - ); + const uri = await User.webFinger(username, domain); if (uri) { const newUser = await User.resolve(uri); diff --git a/api/inbox/index.ts b/api/inbox/index.ts index 4d2c6663..e7f8ad56 100644 --- a/api/inbox/index.ts +++ b/api/inbox/index.ts @@ -1,5 +1,4 @@ import { apiRoute, handleZodError } from "@/api"; -import type { Entity } from "@versia/federation/types"; import { describeRoute } from "hono-openapi"; import { validator } from "hono-openapi/zod"; import { z } from "zod"; @@ -33,7 +32,7 @@ export default apiRoute((app) => handleZodError, ), async (context) => { - const body: Entity = await context.req.valid("json"); + const body = await context.req.valid("json"); await inboxQueue.add(InboxJobType.ProcessEntity, { data: body, diff --git a/api/likes/[uuid]/index.ts b/api/likes/[uuid]/index.ts index d64136b4..9e4df8f2 100644 --- a/api/likes/[uuid]/index.ts +++ b/api/likes/[uuid]/index.ts @@ -1,8 +1,8 @@ import { apiRoute, handleZodError } from "@/api"; import { Status as StatusSchema } from "@versia/client/schemas"; -import { LikeExtension as LikeSchema } from "@versia/federation/schemas"; import { Like, User } from "@versia/kit/db"; import { Likes } from "@versia/kit/tables"; +import { LikeSchema } from "@versia/sdk/schemas"; import { and, eq, sql } from "drizzle-orm"; import { describeRoute } from "hono-openapi"; import { resolver, validator } from "hono-openapi/zod"; diff --git a/api/notes/[uuid]/index.ts b/api/notes/[uuid]/index.ts index 677dacf2..b8d2f229 100644 --- a/api/notes/[uuid]/index.ts +++ b/api/notes/[uuid]/index.ts @@ -1,8 +1,8 @@ import { apiRoute, handleZodError } from "@/api"; import { Status as StatusSchema } from "@versia/client/schemas"; -import { Note as NoteSchema } from "@versia/federation/schemas"; import { Note } from "@versia/kit/db"; import { Notes } from "@versia/kit/tables"; +import { NoteSchema } from "@versia/sdk/schemas"; import { and, eq, inArray } from "drizzle-orm"; import { describeRoute } from "hono-openapi"; import { resolver, validator } from "hono-openapi/zod"; diff --git a/api/notes/[uuid]/quotes.ts b/api/notes/[uuid]/quotes.ts index 0a53e5c9..f62e0bfb 100644 --- a/api/notes/[uuid]/quotes.ts +++ b/api/notes/[uuid]/quotes.ts @@ -1,9 +1,9 @@ import { apiRoute, handleZodError } from "@/api"; import { Status as StatusSchema } from "@versia/client/schemas"; -import { URICollection as URICollectionSchema } from "@versia/federation/schemas"; -import type { URICollection } from "@versia/federation/types"; import { Note, db } from "@versia/kit/db"; import { Notes } from "@versia/kit/tables"; +import * as VersiaEntities from "@versia/sdk/entities"; +import { URICollectionSchema } from "@versia/sdk/schemas"; import { and, eq, inArray } from "drizzle-orm"; import { describeRoute } from "hono-openapi"; import { resolver, validator } from "hono-openapi/zod"; @@ -88,39 +88,39 @@ export default apiRoute((app) => ), ); - const uriCollection = { - author: note.author.getUri().href, + const uriCollection = new VersiaEntities.URICollection({ + author: note.author.getUri(), first: new URL( `/notes/${note.id}/quotes?offset=0`, config.http.base_url, - ).href, + ), last: replyCount > limit ? new URL( `/notes/${note.id}/quotes?offset=${replyCount - limit}`, config.http.base_url, - ).href + ) : new URL( `/notes/${note.id}/quotes`, config.http.base_url, - ).href, + ), next: offset + limit < replyCount ? new URL( `/notes/${note.id}/quotes?offset=${offset + limit}`, config.http.base_url, - ).href + ) : null, previous: offset - limit >= 0 ? new URL( `/notes/${note.id}/quotes?offset=${offset - limit}`, config.http.base_url, - ).href + ) : null, total: replyCount, - items: replies.map((reply) => reply.getUri().href), - } satisfies URICollection; + items: replies.map((reply) => reply.getUri()), + }); // If base_url uses https and request uses http, rewrite request to use https // This fixes reverse proxy errors diff --git a/api/notes/[uuid]/replies.ts b/api/notes/[uuid]/replies.ts index bc04e127..4499d61b 100644 --- a/api/notes/[uuid]/replies.ts +++ b/api/notes/[uuid]/replies.ts @@ -1,9 +1,9 @@ import { apiRoute, handleZodError } from "@/api"; import { Status as StatusSchema } from "@versia/client/schemas"; -import { URICollection as URICollectionSchema } from "@versia/federation/schemas"; -import type { URICollection } from "@versia/federation/types"; import { Note, db } from "@versia/kit/db"; import { Notes } from "@versia/kit/tables"; +import * as VersiaEntities from "@versia/sdk/entities"; +import { URICollectionSchema } from "@versia/sdk/schemas"; import { and, eq, inArray } from "drizzle-orm"; import { describeRoute } from "hono-openapi"; import { resolver, validator } from "hono-openapi/zod"; @@ -86,39 +86,39 @@ export default apiRoute((app) => ), ); - const uriCollection = { - author: note.author.getUri().href, + const uriCollection = new VersiaEntities.URICollection({ + author: note.author.getUri(), first: new URL( `/notes/${note.id}/replies?offset=0`, config.http.base_url, - ).href, + ), last: replyCount > limit ? new URL( `/notes/${note.id}/replies?offset=${replyCount - limit}`, config.http.base_url, - ).href + ) : new URL( `/notes/${note.id}/replies`, config.http.base_url, - ).href, + ), next: offset + limit < replyCount ? new URL( `/notes/${note.id}/replies?offset=${offset + limit}`, config.http.base_url, - ).href + ) : null, previous: offset - limit >= 0 ? new URL( `/notes/${note.id}/replies?offset=${offset - limit}`, config.http.base_url, - ).href + ) : null, total: replyCount, - items: replies.map((reply) => reply.getUri().href), - } satisfies URICollection; + items: replies.map((reply) => reply.getUri()), + }); // If base_url uses https and request uses http, rewrite request to use https // This fixes reverse proxy errors diff --git a/api/users/[uuid]/inbox/index.ts b/api/users/[uuid]/inbox/index.ts index 024dcfa8..ba1583ad 100644 --- a/api/users/[uuid]/inbox/index.ts +++ b/api/users/[uuid]/inbox/index.ts @@ -1,10 +1,10 @@ import { apiRoute, handleZodError } from "@/api"; -import type { Entity } from "@versia/federation/types"; import { describeRoute } from "hono-openapi"; import { resolver, validator } from "hono-openapi/zod"; import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { InboxJobType, inboxQueue } from "~/classes/queues/inbox"; +import type { JSONObject } from "~/packages/federation/types"; export default apiRoute((app) => app.post( @@ -89,7 +89,7 @@ export default apiRoute((app) => ), validator("json", z.any(), handleZodError), async (context) => { - const body: Entity = await context.req.valid("json"); + const body: JSONObject = await context.req.valid("json"); await inboxQueue.add(InboxJobType.ProcessEntity, { data: body, diff --git a/api/users/[uuid]/index.ts b/api/users/[uuid]/index.ts index c0f483a7..1dfdb3f9 100644 --- a/api/users/[uuid]/index.ts +++ b/api/users/[uuid]/index.ts @@ -1,6 +1,6 @@ import { apiRoute, handleZodError } from "@/api"; -import { User as UserSchema } from "@versia/federation/schemas"; import { User } from "@versia/kit/db"; +import { UserSchema } from "@versia/sdk/schemas"; import { describeRoute } from "hono-openapi"; import { resolver, validator } from "hono-openapi/zod"; import { z } from "zod"; @@ -43,6 +43,7 @@ export default apiRoute((app) => }), handleZodError, ), + // @ts-expect-error async (context) => { const { uuid } = context.req.valid("param"); diff --git a/api/users/[uuid]/outbox/index.ts b/api/users/[uuid]/outbox/index.ts index a15a6127..9f2dee9b 100644 --- a/api/users/[uuid]/outbox/index.ts +++ b/api/users/[uuid]/outbox/index.ts @@ -1,10 +1,8 @@ import { apiRoute, handleZodError } from "@/api"; -import { - Collection as CollectionSchema, - Note as NoteSchema, -} from "@versia/federation/schemas"; import { Note, User, db } from "@versia/kit/db"; import { Notes } from "@versia/kit/tables"; +import * as VersiaEntities from "@versia/sdk/entities"; +import { CollectionSchema, NoteSchema } from "@versia/sdk/schemas"; import { and, eq, inArray } from "drizzle-orm"; import { describeRoute } from "hono-openapi"; import { resolver, validator } from "hono-openapi/zod"; @@ -96,35 +94,35 @@ export default apiRoute((app) => ), ); - const json = { + const json = new VersiaEntities.Collection({ first: new URL( `/users/${uuid}/outbox?page=1`, config.http.base_url, - ).toString(), + ), last: new URL( `/users/${uuid}/outbox?page=${Math.ceil( totalNotes / NOTES_PER_PAGE, )}`, config.http.base_url, - ).toString(), + ), total: totalNotes, - author: author.getUri().toString(), + author: author.getUri(), next: notes.length === NOTES_PER_PAGE ? new URL( `/users/${uuid}/outbox?page=${pageNumber + 1}`, config.http.base_url, - ).toString() + ) : null, previous: pageNumber > 1 ? new URL( `/users/${uuid}/outbox?page=${pageNumber - 1}`, config.http.base_url, - ).toString() + ) : null, items: notes.map((note) => note.toVersia()), - }; + }); const { headers } = await author.sign( json, diff --git a/api/well-known/versia.ts b/api/well-known/versia.ts index 8e01a684..a325b486 100644 --- a/api/well-known/versia.ts +++ b/api/well-known/versia.ts @@ -1,8 +1,8 @@ import { apiRoute } from "@/api"; import { urlToContentFormat } from "@/content_types"; -import { InstanceMetadata as InstanceMetadataSchema } from "@versia/federation/schemas"; import { User } from "@versia/kit/db"; import { Users } from "@versia/kit/tables"; +import { InstanceMetadataSchema } from "@versia/sdk/schemas"; import { asc } from "drizzle-orm"; import { describeRoute } from "hono-openapi"; import { resolver } from "hono-openapi/zod"; diff --git a/api/well-known/webfinger/index.ts b/api/well-known/webfinger/index.ts index b8b34dce..c788c83f 100644 --- a/api/well-known/webfinger/index.ts +++ b/api/well-known/webfinger/index.ts @@ -6,10 +6,9 @@ import { webfingerMention, } from "@/api"; import { getLogger } from "@logtape/logtape"; -import type { ResponseError } from "@versia/federation"; -import { WebFinger } from "@versia/federation/schemas"; import { User } from "@versia/kit/db"; import { Users } from "@versia/kit/tables"; +import { WebFingerSchema } from "@versia/sdk/schemas"; import { and, eq, isNull } from "drizzle-orm"; import { describeRoute } from "hono-openapi"; import { resolver, validator } from "hono-openapi/zod"; @@ -28,7 +27,7 @@ export default apiRoute((app) => description: "User information", content: { "application/json": { - schema: resolver(WebFinger), + schema: resolver(WebFingerSchema), }, }, }, @@ -81,23 +80,22 @@ export default apiRoute((app) => throw ApiError.accountNotFound(); } - let activityPubUrl = ""; + let activityPubUrl: URL | null = null; if (config.federation.bridge) { - const manager = await User.getFederationRequester(); - try { - activityPubUrl = await manager.webFinger( - user.data.username, - config.http.base_url.host, - "application/activity+json", - config.federation.bridge.url.origin, - ); + activityPubUrl = + await User.federationRequester.resolveWebFinger( + user.data.username, + config.http.base_url.host, + "application/activity+json", + config.federation.bridge.url.origin, + ); } catch (e) { - const error = e as ResponseError; + const error = e as ApiError; getLogger(["federation", "bridge"]) - .error`Error from bridge: ${await error.response.data}`; + .error`Error from bridge: ${error.message}`; } } @@ -112,7 +110,7 @@ export default apiRoute((app) => ? { rel: "self", type: "application/activity+json", - href: activityPubUrl, + href: activityPubUrl.href, } : undefined, { diff --git a/bun.lock b/bun.lock index 4e38a200..53a168db 100644 --- a/bun.lock +++ b/bun.lock @@ -20,8 +20,8 @@ "@scalar/hono-api-reference": "^0.8.0", "@sentry/bun": "^9.11.0", "@versia/client": "workspace:*", - "@versia/federation": "^0.2.1", "@versia/kit": "workspace:*", + "@versia/sdk": "workspace:*", "altcha-lib": "^1.2.0", "blurhash": "^2.0.5", "bullmq": "^5.47.2", @@ -95,6 +95,10 @@ "@badgateway/oauth2-client": "^2.4.2", }, }, + "packages/federation": { + "name": "@versia/sdk", + "version": "0.0.1", + }, "packages/plugin-kit": { "name": "@versia/kit", "version": "0.0.0", @@ -568,10 +572,10 @@ "@versia/client": ["@versia/client@workspace:packages/client"], - "@versia/federation": ["@versia/federation@0.2.1", "", { "dependencies": { "magic-regexp": "^0.8.0", "mime-types": "^2.1.35", "zod": "^3.24.1", "zod-validation-error": "^3.4.0" } }, "sha512-FTo3VGNJBGmCi0ZEQMzqFZBbcfbX81kmg0UgY4cKamr1dJWgEf72IAZnEDgrBffFjYtreLGdEjFkkcq3JfS8oQ=="], - "@versia/kit": ["@versia/kit@workspace:packages/plugin-kit"], + "@versia/sdk": ["@versia/sdk@workspace:packages/federation"], + "@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.3", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-IYSLEQj4LgZZuoVpdSUCw3dIynTWQgPlaRP6iAvMle4My0HdYwr5g5wQAfwOeHQBmYwEkqF70nRpSilr6PoUDg=="], "@vue/compiler-core": ["@vue/compiler-core@3.5.13", "", { "dependencies": { "@babel/parser": "^7.25.3", "@vue/shared": "3.5.13", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.0" } }, "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q=="], @@ -1412,8 +1416,6 @@ "@ts-morph/common/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - "@versia/federation/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], @@ -1532,8 +1534,6 @@ "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], - "@versia/federation/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "cheerio-select/domutils/dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="], "cheerio/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], diff --git a/classes/database/emoji.ts b/classes/database/emoji.ts index bc0580a1..5267f37c 100644 --- a/classes/database/emoji.ts +++ b/classes/database/emoji.ts @@ -1,8 +1,9 @@ import { emojiValidatorWithColons, emojiValidatorWithIdentifiers } from "@/api"; import type { CustomEmoji } from "@versia/client/schemas"; -import type { CustomEmojiExtension } from "@versia/federation/types"; import { type Instance, Media, db } from "@versia/kit/db"; import { Emojis, type Instances, type Medias } from "@versia/kit/tables"; +import * as VersiaEntities from "@versia/sdk/entities"; +import type { ImageContentFormatSchema } from "@versia/sdk/schemas"; import { randomUUIDv7 } from "bun"; import { type InferInsertModel, @@ -130,7 +131,10 @@ export class Emoji extends BaseInterface { } public static async fetchFromRemote( - emojiToFetch: CustomEmojiExtension["emojis"][0], + emojiToFetch: { + name: string; + url: z.infer; + }, instance: Instance, ): Promise { const existingEmoji = await Emoji.fromSql( @@ -189,15 +193,23 @@ export class Emoji extends BaseInterface { }; } - public toVersia(): CustomEmojiExtension["emojis"][0] { + public toVersia(): { + name: string; + url: z.infer; + } { return { name: `:${this.data.shortcode}:`, - url: this.media.toVersia(), + url: this.media.toVersia().data as z.infer< + typeof ImageContentFormatSchema + >, }; } public static async fromVersia( - emoji: CustomEmojiExtension["emojis"][0], + emoji: { + name: string; + url: z.infer; + }, instance: Instance, ): Promise { // Extracts the shortcode from the emoji name (e.g. :shortcode: -> shortcode) @@ -209,7 +221,9 @@ export class Emoji extends BaseInterface { throw new Error("Could not extract shortcode from emoji name"); } - const media = await Media.fromVersia(emoji.url); + const media = await Media.fromVersia( + new VersiaEntities.ImageContentFormat(emoji.url), + ); return Emoji.insert({ id: randomUUIDv7(), diff --git a/classes/database/instance.ts b/classes/database/instance.ts index 6302aaf2..eaf8987b 100644 --- a/classes/database/instance.ts +++ b/classes/database/instance.ts @@ -1,8 +1,7 @@ import { getLogger } from "@logtape/logtape"; -import { EntityValidator, type ResponseError } from "@versia/federation"; -import type { InstanceMetadata } from "@versia/federation/types"; import { db } from "@versia/kit/db"; import { Instances } from "@versia/kit/tables"; +import * as VersiaEntities from "@versia/sdk/entities"; import { randomUUIDv7 } from "bun"; import chalk from "chalk"; import { @@ -137,24 +136,20 @@ export class Instance extends BaseInterface { } public static async fetchMetadata(url: URL): Promise<{ - metadata: InstanceMetadata; + metadata: VersiaEntities.InstanceMetadata; protocol: "versia" | "activitypub"; }> { const origin = new URL(url).origin; const wellKnownUrl = new URL("/.well-known/versia", origin); - const requester = await User.getFederationRequester(); + try { + const metadata = await User.federationRequester.fetchEntity( + wellKnownUrl, + VersiaEntities.InstanceMetadata, + ); - const { ok, raw, data } = await requester - .get(wellKnownUrl, { - // @ts-expect-error Bun extension - proxy: config.http.proxy_address, - }) - .catch((e) => ({ - ...(e as ResponseError).response, - })); - - if (!(ok && raw.headers.get("content-type")?.includes("json"))) { + return { metadata, protocol: "versia" }; + } catch { // If the server doesn't have a Versia well-known endpoint, it's not a Versia instance // Try to resolve ActivityPub metadata instead const data = await Instance.fetchActivityPubMetadata(url); @@ -171,57 +166,35 @@ export class Instance extends BaseInterface { protocol: "activitypub", }; } - - try { - const metadata = await new EntityValidator().InstanceMetadata(data); - - return { metadata, protocol: "versia" }; - } catch { - throw new ApiError( - 404, - `Instance at ${origin} has invalid metadata`, - ); - } } private static async fetchActivityPubMetadata( url: URL, - ): Promise { + ): Promise { const origin = new URL(url).origin; const wellKnownUrl = new URL("/.well-known/nodeinfo", origin); // Go to endpoint, then follow the links to the actual metadata const logger = getLogger(["federation", "resolvers"]); - const requester = await User.getFederationRequester(); try { - const { - raw: response, - ok, - data: wellKnown, - } = await requester - .get<{ - links: { rel: string; href: string }[]; - }>(wellKnownUrl, { - // @ts-expect-error Bun extension - proxy: config.http.proxy_address, - }) - .catch((e) => ({ - ...( - e as ResponseError<{ - links: { rel: string; href: string }[]; - }> - ).response, - })); + const { json, ok, status } = await fetch(wellKnownUrl, { + // @ts-expect-error Bun extension + proxy: config.http.proxy_address, + }); if (!ok) { logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold( origin, - )} - HTTP ${response.status}`; + )} - HTTP ${status}`; return null; } + const wellKnown = (await json()) as { + links: { rel: string; href: string }[]; + }; + if (!wellKnown.links) { logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold( origin, @@ -243,44 +216,32 @@ export class Instance extends BaseInterface { } const { - raw: metadataResponse, + json: json2, ok: ok2, - data: metadata, - } = await requester - .get<{ - metadata: { - nodeName?: string; - title?: string; - nodeDescription?: string; - description?: string; - }; - software: { version: string }; - }>(metadataUrl.href, { - // @ts-expect-error Bun extension - proxy: config.http.proxy_address, - }) - .catch((e) => ({ - ...( - e as ResponseError<{ - metadata: { - nodeName?: string; - title?: string; - nodeDescription?: string; - description?: string; - }; - software: { version: string }; - }> - ).response, - })); + status: status2, + } = await fetch(metadataUrl.href, { + // @ts-expect-error Bun extension + proxy: config.http.proxy_address, + }); if (!ok2) { logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold( origin, - )} - HTTP ${metadataResponse.status}`; + )} - HTTP ${status2}`; return null; } - return { + const metadata = (await json2()) as { + metadata: { + nodeName?: string; + title?: string; + nodeDescription?: string; + description?: string; + }; + software: { version: string }; + }; + + return new VersiaEntities.InstanceMetadata({ name: metadata.metadata.nodeName || metadata.metadata.title || "", description: @@ -301,7 +262,7 @@ export class Instance extends BaseInterface { extensions: [], versions: [], }, - }; + }); } catch (error) { logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold( origin, @@ -340,13 +301,13 @@ export class Instance extends BaseInterface { return Instance.insert({ id: randomUUIDv7(), baseUrl: host, - name: metadata.name, - version: metadata.software.version, - logo: metadata.logo, + name: metadata.data.name, + version: metadata.data.software.version, + logo: metadata.data.logo, protocol, - publicKey: metadata.public_key, - inbox: metadata.shared_inbox ?? null, - extensions: metadata.extensions ?? null, + publicKey: metadata.data.public_key, + inbox: metadata.data.shared_inbox?.href ?? null, + extensions: metadata.data.extensions ?? null, }); } @@ -365,13 +326,13 @@ export class Instance extends BaseInterface { const { metadata, protocol } = output; await this.update({ - name: metadata.name, - version: metadata.software.version, - logo: metadata.logo, + name: metadata.data.name, + version: metadata.data.software.version, + logo: metadata.data.logo, protocol, - publicKey: metadata.public_key, - inbox: metadata.shared_inbox ?? null, - extensions: metadata.extensions ?? null, + publicKey: metadata.data.public_key, + inbox: metadata.data.shared_inbox?.href ?? null, + extensions: metadata.data.extensions ?? null, }); return this; diff --git a/classes/database/like.ts b/classes/database/like.ts index df473e24..dc0808c3 100644 --- a/classes/database/like.ts +++ b/classes/database/like.ts @@ -1,4 +1,3 @@ -import type { Delete, LikeExtension } from "@versia/federation/types"; import { db } from "@versia/kit/db"; import { Likes, @@ -6,6 +5,7 @@ import { Notifications, type Users, } from "@versia/kit/tables"; +import * as VersiaEntities from "@versia/sdk/entities"; import { type InferInsertModel, type InferSelectModel, @@ -149,25 +149,24 @@ export class Like extends BaseInterface { return new URL(`/likes/${this.data.id}`, config.http.base_url); } - public toVersia(): LikeExtension { - return { + public toVersia(): VersiaEntities.Like { + return new VersiaEntities.Like({ id: this.data.id, author: User.getUri( this.data.liker.id, this.data.liker.uri ? new URL(this.data.liker.uri) : null, - ).toString(), + ), type: "pub.versia:likes/Like", created_at: new Date(this.data.createdAt).toISOString(), - liked: - this.data.liked.uri ?? - new URL(`/notes/${this.data.liked.id}`, config.http.base_url) - .href, - uri: this.getUri().toString(), - }; + liked: this.data.liked.uri + ? new URL(this.data.liked.uri) + : new URL(`/notes/${this.data.liked.id}`, config.http.base_url), + uri: this.getUri(), + }); } - public unlikeToVersia(unliker?: User): Delete { - return { + public unlikeToVersia(unliker?: User): VersiaEntities.Delete { + return new VersiaEntities.Delete({ type: "Delete", id: crypto.randomUUID(), created_at: new Date().toISOString(), @@ -178,9 +177,9 @@ export class Like extends BaseInterface { : this.data.liker.uri ? new URL(this.data.liker.uri) : null, - ).toString(), + ), deleted_type: "pub.versia:likes/Like", - deleted: this.getUri().toString(), - }; + deleted: this.getUri(), + }); } } diff --git a/classes/database/media.ts b/classes/database/media.ts index 0cef287c..c28258b4 100644 --- a/classes/database/media.ts +++ b/classes/database/media.ts @@ -1,9 +1,13 @@ import { join } from "node:path"; import { mimeLookup } from "@/content_types.ts"; import type { Attachment as AttachmentSchema } from "@versia/client/schemas"; -import type { ContentFormat } from "@versia/federation/types"; import { db } from "@versia/kit/db"; import { Medias } from "@versia/kit/tables"; +import * as VersiaEntities from "@versia/sdk/entities"; +import type { + ContentFormatSchema, + ImageContentFormatSchema, +} from "@versia/sdk/schemas"; import { S3Client, SHA256, randomUUIDv7, write } from "bun"; import { type InferInsertModel, @@ -202,7 +206,9 @@ export class Media extends BaseInterface { const newAttachment = await Media.insert({ id: randomUUIDv7(), content, - thumbnail: thumbnailContent, + thumbnail: thumbnailContent as z.infer< + typeof ImageContentFormatSchema + >, }); if (config.media.conversion.convert_images) { @@ -234,7 +240,7 @@ export class Media extends BaseInterface { ): Promise { const mimeType = await mimeLookup(uri); - const content: ContentFormat = { + const content: z.infer = { [mimeType]: { content: uri.toString(), remote: true, @@ -303,7 +309,7 @@ export class Media extends BaseInterface { public async updateFromUrl(uri: URL): Promise { const mimeType = await mimeLookup(uri); - const content: ContentFormat = { + const content: z.infer = { [mimeType]: { content: uri.toString(), remote: true, @@ -333,12 +339,19 @@ export class Media extends BaseInterface { const content = await Media.fileToContentFormat(file, url); await this.update({ - thumbnail: content, + thumbnail: content as z.infer, }); } public async updateMetadata( - metadata: Partial>, + metadata: Partial< + Omit< + z.infer[keyof z.infer< + typeof ContentFormatSchema + >], + "content" + > + >, ): Promise { const content = this.data.content; @@ -447,7 +460,7 @@ export class Media extends BaseInterface { options?: Partial<{ description: string; }>, - ): Promise { + ): Promise> { const buffer = await file.arrayBuffer(); const isImage = file.type.startsWith("image/"); const { width, height } = isImage ? await sharp(buffer).metadata() : {}; @@ -521,15 +534,17 @@ export class Media extends BaseInterface { }; } - public toVersia(): ContentFormat { - return this.data.content; + public toVersia(): VersiaEntities.ContentFormat { + return new VersiaEntities.ContentFormat(this.data.content); } - public static fromVersia(contentFormat: ContentFormat): Promise { + public static fromVersia( + contentFormat: VersiaEntities.ContentFormat, + ): Promise { return Media.insert({ id: randomUUIDv7(), - content: contentFormat, - originalContent: contentFormat, + content: contentFormat.data, + originalContent: contentFormat.data, }); } } diff --git a/classes/database/note.ts b/classes/database/note.ts index 66adc099..6b2b418a 100644 --- a/classes/database/note.ts +++ b/classes/database/note.ts @@ -4,12 +4,6 @@ import { sanitizedHtmlStrip } from "@/sanitization"; import { sentry } from "@/sentry"; import { getLogger } from "@logtape/logtape"; import type { Status, Status as StatusSchema } from "@versia/client/schemas"; -import { EntityValidator } from "@versia/federation"; -import type { - ContentFormat, - Delete as VersiaDelete, - Note as VersiaNote, -} from "@versia/federation/types"; import { Instance, db } from "@versia/kit/db"; import { EmojiToNote, @@ -18,6 +12,7 @@ import { Notes, Users, } from "@versia/kit/tables"; +import * as VersiaEntities from "@versia/sdk/entities"; import { randomUUIDv7 } from "bun"; import { type InferInsertModel, @@ -39,6 +34,7 @@ import { parseTextMentions, } from "~/classes/functions/status"; import { config } from "~/config.ts"; +import type { NonTextContentFormatSchema } from "~/packages/federation/schemas/contentformat.ts"; import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts"; import { Application } from "./application.ts"; import { BaseInterface } from "./base.ts"; @@ -222,7 +218,7 @@ export class Note extends BaseInterface { await deliveryQueue.addBulk( users.map((user) => ({ data: { - entity: this.toVersia(), + entity: this.toVersia().toJSON(), recipientId: user.id, senderId: this.author.id, }, @@ -323,7 +319,12 @@ export class Note extends BaseInterface { throw new Error("Cannot refetch a local note (it is not remote)"); } - const updated = await Note.fetchFromRemote(this.getUri()); + const note = await User.federationRequester.fetchEntity( + this.getUri(), + VersiaEntities.Note, + ); + + const updated = await Note.fromVersia(note); if (!updated) { throw new Error("Note not found after update"); @@ -341,12 +342,12 @@ export class Note extends BaseInterface { */ public static async fromData(data: { author: User; - content: ContentFormat; + content: VersiaEntities.TextContentFormat; visibility: z.infer; isSensitive: boolean; spoilerText: string; emojis?: Emoji[]; - uri?: string; + uri?: URL; mentions?: User[]; /** List of IDs of database Attachment objects */ mediaAttachments?: Media[]; @@ -355,8 +356,8 @@ export class Note extends BaseInterface { application?: Application; }): Promise { const plaintextContent = - data.content["text/plain"]?.content ?? - Object.entries(data.content)[0][1].content; + data.content.data["text/plain"]?.content ?? + Object.entries(data.content.data)[0][1].content; const parsedMentions = mergeAndDeduplicate( data.mentions ?? [], @@ -374,15 +375,15 @@ export class Note extends BaseInterface { authorId: data.author.id, content: htmlContent, contentSource: - data.content["text/plain"]?.content || - data.content["text/markdown"]?.content || - Object.entries(data.content)[0][1].content || + data.content.data["text/plain"]?.content || + data.content.data["text/markdown"]?.content || + Object.entries(data.content.data)[0][1].content || "", contentType: "text/html", visibility: data.visibility, sensitive: data.isSensitive, spoilerText: await sanitizedHtmlStrip(data.spoilerText), - uri: data.uri || null, + uri: data.uri?.href || null, replyId: data.replyId ?? null, quotingId: data.quoteId ?? null, applicationId: data.application?.id ?? null, @@ -416,12 +417,12 @@ export class Note extends BaseInterface { */ public async updateFromData(data: { author: User; - content?: ContentFormat; + content?: VersiaEntities.TextContentFormat; visibility?: z.infer; isSensitive?: boolean; spoilerText?: string; emojis?: Emoji[]; - uri?: string; + uri?: URL; mentions?: User[]; mediaAttachments?: Media[]; replyId?: string; @@ -429,8 +430,8 @@ export class Note extends BaseInterface { application?: Application; }): Promise { const plaintextContent = data.content - ? (data.content["text/plain"]?.content ?? - Object.entries(data.content)[0][1].content) + ? (data.content.data["text/plain"]?.content ?? + Object.entries(data.content.data)[0][1].content) : undefined; const parsedMentions = mergeAndDeduplicate( @@ -451,15 +452,16 @@ export class Note extends BaseInterface { await this.update({ content: htmlContent, contentSource: data.content - ? data.content["text/plain"]?.content || - data.content["text/markdown"]?.content || - Object.entries(data.content)[0][1].content || + ? data.content.data["text/plain"]?.content || + data.content.data["text/markdown"]?.content || + Object.entries(data.content.data)[0][1].content || "" : undefined, contentType: "text/html", visibility: data.visibility, sensitive: data.isSensitive, spoilerText: data.spoilerText, + uri: data.uri?.href, replyId: data.replyId, quotingId: data.quoteId, applicationId: data.application?.id, @@ -575,37 +577,12 @@ export class Note extends BaseInterface { return await Note.fromId(uuid[0]); } - return await Note.fetchFromRemote(uri); - } + const note = await User.federationRequester.fetchEntity( + uri, + VersiaEntities.Note, + ); - /** - * Save a note from a remote server - * @param uri - The URI of the note to save - * @returns The saved note, or null if the note could not be fetched - */ - public static async fetchFromRemote(uri: URL): Promise { - const instance = await Instance.resolve(uri); - - if (!instance) { - return null; - } - - const requester = await User.getFederationRequester(); - - const { data } = await requester.get(uri, { - // @ts-expect-error Bun extension - proxy: config.http.proxy_address, - }); - - const note = await new EntityValidator().Note(data); - - const author = await User.resolve(new URL(note.author)); - - if (!author) { - throw new Error("Invalid object author"); - } - - return await Note.fromVersia(note, author, instance); + return Note.fromVersia(note); } /** @@ -615,15 +592,18 @@ export class Note extends BaseInterface { * @param instance Instance of the note * @returns The saved note */ - public static async fromVersia( - note: VersiaNote, - author: User, - instance: Instance, - ): Promise { + public static async fromVersia(note: VersiaEntities.Note): Promise { const emojis: Emoji[] = []; const logger = getLogger(["federation", "resolvers"]); - for (const emoji of note.extensions?.["pub.versia:custom_emojis"] + const author = await User.resolve(note.data.author); + if (!author) { + throw new Error("Invalid object author"); + } + + const instance = await Instance.resolve(note.data.uri); + + for (const emoji of note.data.extensions?.["pub.versia:custom_emojis"] ?.emojis ?? []) { const resolvedEmoji = await Emoji.fetchFromRemote( emoji, @@ -641,7 +621,7 @@ export class Note extends BaseInterface { const attachments: Media[] = []; - for (const attachment of note.attachments ?? []) { + for (const attachment of note.attachments) { const resolvedAttachment = await Media.fromVersia(attachment).catch( (e) => { logger.error`${e}`; @@ -655,9 +635,9 @@ export class Note extends BaseInterface { } } - let visibility = note.group - ? ["public", "followers"].includes(note.group) - ? (note.group as "public" | "private") + let visibility = note.data.group + ? ["public", "followers"].includes(note.data.group as string) + ? (note.data.group as "public" | "private") : ("url" as const) : ("direct" as const); @@ -668,34 +648,37 @@ export class Note extends BaseInterface { const newData = { author, - content: note.content ?? { - "text/plain": { - content: "", - remote: false, - }, - }, + content: + note.content ?? + new VersiaEntities.TextContentFormat({ + "text/plain": { + content: "", + remote: false, + }, + }), visibility, - isSensitive: note.is_sensitive ?? false, - spoilerText: note.subject ?? "", + isSensitive: note.data.is_sensitive ?? false, + spoilerText: note.data.subject ?? "", emojis, - uri: note.uri, - mentions: await Promise.all( - (note.mentions ?? []) - .map((mention) => User.resolve(new URL(mention))) - .filter((mention) => mention !== null) as Promise[], - ), + uri: note.data.uri, + mentions: ( + await Promise.all( + (note.data.mentions ?? []).map( + async (mention) => await User.resolve(mention), + ), + ) + ).filter((mention) => mention !== null), mediaAttachments: attachments, - replyId: note.replies_to - ? (await Note.resolve(new URL(note.replies_to)))?.data.id + replyId: note.data.replies_to + ? (await Note.resolve(note.data.replies_to))?.data.id : undefined, - quoteId: note.quotes - ? (await Note.resolve(new URL(note.quotes)))?.data.id + quoteId: note.data.quotes + ? (await Note.resolve(note.data.quotes))?.data.id : undefined, }; // Check if new note already exists - - const foundNote = await Note.fromSql(eq(Notes.uri, note.uri)); + const foundNote = await Note.fromSql(eq(Notes.uri, note.data.uri.href)); // If it exists, simply update it if (foundNote) { @@ -872,31 +855,31 @@ export class Note extends BaseInterface { ); } - public deleteToVersia(): VersiaDelete { + public deleteToVersia(): VersiaEntities.Delete { const id = crypto.randomUUID(); - return { + return new VersiaEntities.Delete({ type: "Delete", id, - author: this.author.getUri().toString(), + author: this.author.getUri(), deleted_type: "Note", - deleted: this.getUri().toString(), + deleted: this.getUri(), created_at: new Date().toISOString(), - }; + }); } /** * Convert a note to the Versia format * @returns The note in the Versia format */ - public toVersia(): VersiaNote { + public toVersia(): VersiaEntities.Note { const status = this.data; - return { + return new VersiaEntities.Note({ type: "Note", created_at: new Date(status.createdAt).toISOString(), id: status.id, - author: this.author.getUri().toString(), - uri: this.getUri().toString(), + author: this.author.getUri(), + uri: this.getUri(), content: { "text/html": { content: status.content, @@ -908,28 +891,37 @@ export class Note extends BaseInterface { }, }, collections: { - replies: `/notes/${status.id}/replies`, - quotes: `/notes/${status.id}/quotes`, + replies: new URL( + `/notes/${status.id}/replies`, + config.http.base_url, + ), + quotes: new URL( + `/notes/${status.id}/quotes`, + config.http.base_url, + ), }, - attachments: (status.attachments ?? []).map((attachment) => - new Media(attachment).toVersia(), + attachments: status.attachments.map( + (attachment) => + new Media(attachment).toVersia().data as z.infer< + typeof NonTextContentFormatSchema + >, ), is_sensitive: status.sensitive, mentions: status.mentions.map((mention) => User.getUri( mention.id, mention.uri ? new URL(mention.uri) : null, - ).toString(), + ), ), quotes: status.quote - ? (status.quote.uri ?? - new URL(`/notes/${status.quote.id}`, config.http.base_url) - .href) + ? status.quote.uri + ? new URL(status.quote.uri) + : new URL(`/notes/${status.quote.id}`, config.http.base_url) : null, replies_to: status.reply - ? (status.reply.uri ?? - new URL(`/notes/${status.reply.id}`, config.http.base_url) - .href) + ? status.reply.uri + ? new URL(status.reply.uri) + : new URL(`/notes/${status.reply.id}`, config.http.base_url) : null, subject: status.spoilerText, // TODO: Refactor as part of groups @@ -942,7 +934,7 @@ export class Note extends BaseInterface { }, // TODO: Add polls and reactions }, - }; + }); } /** diff --git a/classes/database/reaction.ts b/classes/database/reaction.ts index 63d4fc2d..d8825318 100644 --- a/classes/database/reaction.ts +++ b/classes/database/reaction.ts @@ -1,6 +1,6 @@ -import type { ReactionExtension } from "@versia/federation/types"; import { Emoji, Instance, type Note, User, db } from "@versia/kit/db"; import { type Notes, Reactions, type Users } from "@versia/kit/tables"; +import * as VersiaEntities from "@versia/sdk/entities"; import { randomUUIDv7 } from "bun"; import { type InferInsertModel, @@ -173,24 +173,23 @@ export class Reaction extends BaseInterface { return !!this.data.emoji || !this.data.emojiText; } - public toVersia(): ReactionExtension { + public toVersia(): VersiaEntities.Reaction { if (!this.isLocal()) { throw new Error("Cannot convert a non-local reaction to Versia"); } - return { - uri: this.getUri(config.http.base_url).toString(), + return new VersiaEntities.Reaction({ + uri: this.getUri(config.http.base_url), type: "pub.versia:reactions/Reaction", author: User.getUri( this.data.authorId, this.data.author.uri ? new URL(this.data.author.uri) : null, - ).toString(), + ), created_at: new Date(this.data.createdAt).toISOString(), id: this.id, - object: - this.data.note.uri ?? - new URL(`/notes/${this.data.noteId}`, config.http.base_url) - .href, + object: this.data.note.uri + ? new URL(this.data.note.uri) + : new URL(`/notes/${this.data.noteId}`, config.http.base_url), content: this.hasCustomEmoji() ? `:${this.data.emoji?.shortcode}:` : this.data.emojiText || "", @@ -205,11 +204,11 @@ export class Reaction extends BaseInterface { }, } : undefined, - }; + }); } public static async fromVersia( - reactionToConvert: ReactionExtension, + reactionToConvert: VersiaEntities.Reaction, author: User, note: Note, ): Promise { @@ -218,7 +217,7 @@ export class Reaction extends BaseInterface { } const emojiEntity = - reactionToConvert.extensions?.["pub.versia:custom_emojis"] + reactionToConvert.data.extensions?.["pub.versia:custom_emojis"] ?.emojis[0]; const emoji = emojiEntity ? await Emoji.fetchFromRemote( @@ -233,11 +232,11 @@ export class Reaction extends BaseInterface { return Reaction.insert({ id: randomUUIDv7(), - uri: reactionToConvert.uri, + uri: reactionToConvert.data.uri.href, authorId: author.id, noteId: note.id, emojiId: emoji ? emoji.id : null, - emojiText: emoji ? null : reactionToConvert.content, + emojiText: emoji ? null : reactionToConvert.data.content, }); } } diff --git a/classes/database/user.ts b/classes/database/user.ts index 172d49cc..522d80c4 100644 --- a/classes/database/user.ts +++ b/classes/database/user.ts @@ -9,19 +9,6 @@ import type { Source, } from "@versia/client/schemas"; import type { RolePermission } from "@versia/client/schemas"; -import { - EntityValidator, - FederationRequester, - type HttpVerb, - SignatureConstructor, -} from "@versia/federation"; -import type { - Collection, - Unfollow, - FollowAccept as VersiaFollowAccept, - FollowReject as VersiaFollowReject, - User as VersiaUser, -} from "@versia/federation/types"; import { Media, Notification, PushSubscription, db } from "@versia/kit/db"; import { EmojiToUser, @@ -32,6 +19,10 @@ import { UserToPinnedNotes, Users, } from "@versia/kit/tables"; +import { sign } from "@versia/sdk/crypto"; +import * as VersiaEntities from "@versia/sdk/entities"; +import { FederationRequester } from "@versia/sdk/http"; +import type { ImageContentFormatSchema } from "@versia/sdk/schemas"; import { randomUUIDv7 } from "bun"; import { password as bunPassword } from "bun"; import chalk from "chalk"; @@ -54,7 +45,7 @@ import type { z } from "zod"; import { findManyUsers } from "~/classes/functions/user"; import { searchManager } from "~/classes/search/search-manager"; import { config } from "~/config.ts"; -import type { KnownEntity } from "~/types/api.ts"; +import type { HttpVerb, KnownEntity } from "~/types/api.ts"; import { ProxiableUrl } from "../media/url.ts"; import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts"; import { PushJobType, pushQueue } from "../queues/push.ts"; @@ -240,7 +231,7 @@ export class User extends BaseInterface { ): Promise { if (followee.isRemote()) { await deliveryQueue.add(DeliveryJobType.FederateEntity, { - entity: this.unfollowToVersia(followee), + entity: this.unfollowToVersia(followee).toJSON(), recipientId: followee.id, senderId: this.id, }); @@ -251,15 +242,15 @@ export class User extends BaseInterface { }); } - private unfollowToVersia(followee: User): Unfollow { + private unfollowToVersia(followee: User): VersiaEntities.Unfollow { const id = crypto.randomUUID(); - return { + return new VersiaEntities.Unfollow({ type: "Unfollow", id, - author: this.getUri().toString(), + author: this.getUri(), created_at: new Date().toISOString(), - followee: followee.getUri().toString(), - }; + followee: followee.getUri(), + }); } public async sendFollowAccept(follower: User): Promise { @@ -271,16 +262,16 @@ export class User extends BaseInterface { throw new Error("Followee must be a local user"); } - const entity: VersiaFollowAccept = { + const entity = new VersiaEntities.FollowAccept({ type: "FollowAccept", id: crypto.randomUUID(), - author: this.getUri().toString(), + author: this.getUri(), created_at: new Date().toISOString(), - follower: follower.getUri().toString(), - }; + follower: follower.getUri(), + }); await deliveryQueue.add(DeliveryJobType.FederateEntity, { - entity, + entity: entity.toJSON(), recipientId: follower.id, senderId: this.id, }); @@ -295,43 +286,77 @@ export class User extends BaseInterface { throw new Error("Followee must be a local user"); } - const entity: VersiaFollowReject = { + const entity = new VersiaEntities.FollowReject({ type: "FollowReject", id: crypto.randomUUID(), - author: this.getUri().toString(), + author: this.getUri(), created_at: new Date().toISOString(), - follower: follower.getUri().toString(), - }; + follower: follower.getUri(), + }); await deliveryQueue.add(DeliveryJobType.FederateEntity, { - entity, + entity: entity.toJSON(), recipientId: follower.id, senderId: this.id, }); } + /** + * Signs a Versia entity with that user's private key + * + * @param entity Entity to sign + * @param signatureUrl URL to embed in signature (must be the same URI of queries made with this signature) + * @param signatureMethod HTTP method to embed in signature (default: POST) + * @returns The signed string and headers to send with the request + */ + public async sign( + entity: KnownEntity | VersiaEntities.Collection, + signatureUrl: URL, + signatureMethod: HttpVerb = "POST", + ): Promise<{ + headers: Headers; + }> { + const privateKey = await crypto.subtle.importKey( + "pkcs8", + Buffer.from(this.data.privateKey ?? "", "base64"), + "Ed25519", + false, + ["sign"], + ); + + const { headers } = await sign( + privateKey, + this.getUri(), + new Request(signatureUrl, { + method: signatureMethod, + body: JSON.stringify(entity), + }), + ); + + return { headers }; + } + /** * Perform a WebFinger lookup to find a user's URI - * @param manager * @param username * @param hostname * @returns URI, or null if not found */ - public static async webFinger( - manager: FederationRequester, + public static webFinger( username: string, hostname: string, ): Promise { try { - return new URL(await manager.webFinger(username, hostname)); + return User.federationRequester.resolveWebFinger( + username, + hostname, + ); } catch { try { - return new URL( - await manager.webFinger( - username, - hostname, - "application/activity+json", - ), + return User.federationRequester.resolveWebFinger( + username, + hostname, + "application/activity+json", ); } catch { return Promise.resolve(null); @@ -455,7 +480,7 @@ export class User extends BaseInterface { * @param uri The URI of the like, if it is remote * @returns The like object created or the existing like */ - public async like(note: Note, uri?: string): Promise { + public async like(note: Note, uri?: URL): Promise { // Check if the user has already liked the note const existingLike = await Like.fromSql( and(eq(Likes.likerId, this.id), eq(Likes.likedId, note.id)), @@ -469,7 +494,7 @@ export class User extends BaseInterface { id: randomUUIDv7(), likerId: this.id, likedId: note.id, - uri, + uri: uri?.href, }); if (this.isLocal() && note.author.isLocal()) { @@ -601,7 +626,7 @@ export class User extends BaseInterface { } if (instance.data.protocol === "versia") { - return await User.saveFromVersia(uri, instance); + return await User.saveFromVersia(uri); } if (instance.data.protocol === "activitypub") { @@ -616,28 +641,19 @@ export class User extends BaseInterface { config.federation.bridge.url, ); - return await User.saveFromVersia(bridgeUri, instance); + return await User.saveFromVersia(bridgeUri); } throw new Error(`Unsupported protocol: ${instance.data.protocol}`); } - private static async saveFromVersia( - uri: URL, - instance: Instance, - ): Promise { - const requester = await User.getFederationRequester(); - const output = await requester.get>(uri, { - // @ts-expect-error Bun extension - proxy: config.http.proxy_address, - }); + private static async saveFromVersia(uri: URL): Promise { + const userData = await User.federationRequester.fetchEntity( + uri, + VersiaEntities.User, + ); - const { data: json } = output; - - const validator = new EntityValidator(); - const data = await validator.User(json); - - const user = await User.fromVersia(data, instance); + const user = await User.fromVersia(userData); await searchManager.addUser(user); @@ -663,30 +679,32 @@ export class User extends BaseInterface { ); } - public static async fromVersia( - user: VersiaUser, - instance: Instance, - ): Promise { + public static async fromVersia(user: VersiaEntities.User): Promise { + const instance = await Instance.resolve(user.data.uri); + const data = { - username: user.username, - uri: user.uri, - createdAt: new Date(user.created_at).toISOString(), + username: user.data.username, + uri: user.data.uri.href, + createdAt: new Date(user.data.created_at).toISOString(), endpoints: { dislikes: - user.collections["pub.versia:likes/Dislikes"] ?? undefined, - featured: user.collections.featured, - likes: user.collections["pub.versia:likes/Likes"] ?? undefined, - followers: user.collections.followers, - following: user.collections.following, - inbox: user.inbox, - outbox: user.collections.outbox, + user.data.collections["pub.versia:likes/Dislikes"]?.href ?? + undefined, + featured: user.data.collections.featured.href, + likes: + user.data.collections["pub.versia:likes/Likes"]?.href ?? + undefined, + followers: user.data.collections.followers.href, + following: user.data.collections.following.href, + inbox: user.data.inbox.href, + outbox: user.data.collections.outbox.href, }, - fields: user.fields ?? [], - updatedAt: new Date(user.created_at).toISOString(), + fields: user.data.fields ?? [], + updatedAt: new Date(user.data.created_at).toISOString(), instanceId: instance.id, - displayName: user.display_name ?? "", - note: getBestContentType(user.bio).content, - publicKey: user.public_key.key, + displayName: user.data.display_name ?? "", + note: getBestContentType(user.data.bio).content, + publicKey: user.data.public_key.key, source: { language: "en", note: "", @@ -697,46 +715,46 @@ export class User extends BaseInterface { }; const userEmojis = - user.extensions?.["pub.versia:custom_emojis"]?.emojis ?? []; + user.data.extensions?.["pub.versia:custom_emojis"]?.emojis ?? []; const emojis = await Promise.all( userEmojis.map((emoji) => Emoji.fromVersia(emoji, instance)), ); // Check if new user already exists - const foundUser = await User.fromSql(eq(Users.uri, user.uri)); + const foundUser = await User.fromSql(eq(Users.uri, user.data.uri.href)); // If it exists, simply update it if (foundUser) { let avatar: Media | null = null; let header: Media | null = null; - if (user.avatar) { + if (user.data.avatar) { if (foundUser.avatar) { avatar = new Media( await foundUser.avatar.update({ - content: user.avatar, + content: user.data.avatar, }), ); } else { avatar = await Media.insert({ id: randomUUIDv7(), - content: user.avatar, + content: user.data.avatar, }); } } - if (user.header) { + if (user.data.header) { if (foundUser.header) { header = new Media( await foundUser.header.update({ - content: user.header, + content: user.data.header, }), ); } else { header = await Media.insert({ id: randomUUIDv7(), - content: user.header, + content: user.data.header, }); } } @@ -752,17 +770,17 @@ export class User extends BaseInterface { } // Else, create a new user - const avatar = user.avatar + const avatar = user.data.avatar ? await Media.insert({ id: randomUUIDv7(), - content: user.avatar, + content: user.data.avatar, }) : null; - const header = user.header + const header = user.data.header ? await Media.insert({ id: randomUUIDv7(), - content: user.header, + content: user.data.header, }) : null; @@ -977,72 +995,25 @@ export class User extends BaseInterface { return updated.data; } - /** - * Signs a Versia entity with that user's private key - * - * @param entity Entity to sign - * @param signatureUrl URL to embed in signature (must be the same URI of queries made with this signature) - * @param signatureMethod HTTP method to embed in signature (default: POST) - * @returns The signed string and headers to send with the request - */ - public async sign( - entity: KnownEntity | Collection, - signatureUrl: URL, - signatureMethod: HttpVerb = "POST", - ): Promise<{ - headers: Headers; - signedString: string; - }> { - const signatureConstructor = await SignatureConstructor.fromStringKey( - this.data.privateKey ?? "", - this.getUri(), - ); - - const output = await signatureConstructor.sign( - signatureMethod, - signatureUrl, - JSON.stringify(entity), - ); - - if (config.debug?.federation) { - const logger = getLogger("federation"); - - // Log public key - logger.debug`Sender public key: ${this.data.publicKey}`; - - // Log signed string - logger.debug`Signed string:\n${output.signedString}`; - } - - return output; - } - - /** - * Helper to get the appropriate Versia SDK requester with the instance's private key - * - * @returns The requester - */ - public static getFederationRequester(): FederationRequester { - const signatureConstructor = new SignatureConstructor( + public static get federationRequester(): FederationRequester { + return new FederationRequester( config.instance.keys.private, config.http.base_url, ); - - return new FederationRequester(signatureConstructor); } - /** - * Helper to get the appropriate Versia SDK requester with this user's private key - * - * @returns The requester - */ - public async getFederationRequester(): Promise { - const signatureConstructor = await SignatureConstructor.fromStringKey( - this.data.privateKey ?? "", - this.getUri(), - ); - - return new FederationRequester(signatureConstructor); + public get federationRequester(): Promise { + return crypto.subtle + .importKey( + "pkcs8", + Buffer.from(this.data.privateKey ?? "", "base64"), + "Ed25519", + false, + ["sign"], + ) + .then((k) => { + return new FederationRequester(k, this.getUri()); + }); } /** @@ -1071,7 +1042,8 @@ export class User extends BaseInterface { followers.map((follower) => ({ name: DeliveryJobType.FederateEntity, data: { - entity, + entity: entity.toJSON(), + type: entity.data.type, recipientId: follower.id, senderId: this.id, }, @@ -1098,20 +1070,14 @@ export class User extends BaseInterface { ); } - const { headers } = await this.sign(entity, new URL(inbox)); - try { - await new FederationRequester().post(inbox, entity, { - // @ts-expect-error Bun extension - proxy: config.http.proxy_address, - headers: { - ...headers.toJSON(), - "Content-Type": "application/json; charset=utf-8", - }, - }); + await (await this.federationRequester).postEntity( + new URL(inbox), + entity, + ); } catch (e) { getLogger(["federation", "delivery"]) - .error`Federating ${chalk.gray(entity.type)} to ${user.getUri()} ${chalk.bold.red("failed")}`; + .error`Federating ${chalk.gray(entity.data.type)} to ${user.getUri()} ${chalk.bold.red("failed")}`; getLogger(["federation", "delivery"]).error`${e}`; sentry?.captureException(e); @@ -1176,17 +1142,17 @@ export class User extends BaseInterface { }; } - public toVersia(): VersiaUser { + public toVersia(): VersiaEntities.User { if (this.isRemote()) { throw new Error("Cannot convert remote user to Versia format"); } const user = this.data; - return { + return new VersiaEntities.User({ id: user.id, type: "User", - uri: this.getUri().toString(), + uri: this.getUri(), bio: { "text/html": { content: user.note, @@ -1202,44 +1168,42 @@ export class User extends BaseInterface { featured: new URL( `/users/${user.id}/featured`, config.http.base_url, - ).toString(), + ), "pub.versia:likes/Likes": new URL( `/users/${user.id}/likes`, config.http.base_url, - ).toString(), + ), "pub.versia:likes/Dislikes": new URL( `/users/${user.id}/dislikes`, config.http.base_url, - ).toString(), + ), followers: new URL( `/users/${user.id}/followers`, config.http.base_url, - ).toString(), + ), following: new URL( `/users/${user.id}/following`, config.http.base_url, - ).toString(), + ), outbox: new URL( `/users/${user.id}/outbox`, config.http.base_url, - ).toString(), + ), }, - inbox: new URL( - `/users/${user.id}/inbox`, - config.http.base_url, - ).toString(), + inbox: new URL(`/users/${user.id}/inbox`, config.http.base_url), indexable: this.data.isIndexable, username: user.username, manually_approves_followers: this.data.isLocked, - avatar: this.avatar?.toVersia(), - header: this.header?.toVersia(), + avatar: this.avatar?.toVersia().data as z.infer< + typeof ImageContentFormatSchema + >, + header: this.header?.toVersia().data as z.infer< + typeof ImageContentFormatSchema + >, display_name: user.displayName, fields: user.fields, public_key: { - actor: new URL( - `/users/${user.id}`, - config.http.base_url, - ).toString(), + actor: new URL(`/users/${user.id}`, config.http.base_url), key: user.publicKey, algorithm: "ed25519", }, @@ -1250,7 +1214,7 @@ export class User extends BaseInterface { ), }, }, - }; + }); } public toMention(): z.infer { diff --git a/classes/functions/status.ts b/classes/functions/status.ts index 8ed9380a..87d5dd21 100644 --- a/classes/functions/status.ts +++ b/classes/functions/status.ts @@ -1,9 +1,9 @@ import { mentionValidator } from "@/api"; import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization"; import markdownItTaskLists from "@hackmd/markdown-it-task-lists"; -import type { ContentFormat } from "@versia/federation/types"; import { type Note, User, db } from "@versia/kit/db"; import { Instances, Users } from "@versia/kit/tables"; +import type * as VersiaEntities from "@versia/sdk/entities"; import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; import linkifyHtml from "linkify-html"; import { @@ -276,21 +276,17 @@ export const parseTextMentions = async ( // Resolve remote mentions not in database for (const person of notFoundRemoteUsers) { - const manager = await author.getFederationRequester(); - const uri = await User.webFinger( - manager, + const url = await (await author.federationRequester).resolveWebFinger( person[1] ?? "", person[2] ?? "", ); - if (!uri) { - continue; - } + if (url) { + const user = await User.resolve(url); - const user = await User.resolve(uri); - - if (user) { - finalList.push(user); + if (user) { + finalList.push(user); + } } } @@ -327,21 +323,21 @@ export const replaceTextMentions = (text: string, mentions: User[]): string => { }; export const contentToHtml = async ( - content: ContentFormat, + content: VersiaEntities.TextContentFormat, mentions: User[] = [], inline = false, ): Promise => { const sanitizer = inline ? sanitizeHtmlInline : sanitizeHtml; let htmlContent = ""; - if (content["text/html"]) { - htmlContent = await sanitizer(content["text/html"].content); - } else if (content["text/markdown"]) { + if (content.data["text/html"]) { + htmlContent = await sanitizer(content.data["text/html"].content); + } else if (content.data["text/markdown"]) { htmlContent = await sanitizer( - await markdownParse(content["text/markdown"].content), + await markdownParse(content.data["text/markdown"].content), ); - } else if (content["text/plain"]?.content) { - htmlContent = (await sanitizer(content["text/plain"].content)) + } else if (content.data["text/plain"]?.content) { + htmlContent = (await sanitizer(content.data["text/plain"].content)) .split("\n") .map((line) => `

${line}

`) .join("\n"); diff --git a/classes/inbox/processor.ts b/classes/inbox/processor.ts index 0c5f93d7..79e826b0 100644 --- a/classes/inbox/processor.ts +++ b/classes/inbox/processor.ts @@ -1,22 +1,10 @@ import { sentry } from "@/sentry"; import { type Logger, getLogger } from "@logtape/logtape"; -import { - EntityValidator, - RequestParserHandler, - SignatureValidator, -} from "@versia/federation"; -import type { - Entity, - Delete as VersiaDelete, - Follow as VersiaFollow, - FollowAccept as VersiaFollowAccept, - FollowReject as VersiaFollowReject, - LikeExtension as VersiaLikeExtension, - Note as VersiaNote, - User as VersiaUser, -} from "@versia/federation/types"; -import { Instance, Like, Note, Relationship, User } from "@versia/kit/db"; +import { type Instance, Like, Note, Relationship, User } from "@versia/kit/db"; import { Likes, Notes } from "@versia/kit/tables"; +import { EntitySorter } from "@versia/sdk"; +import { verify } from "@versia/sdk/crypto"; +import * as VersiaEntities from "@versia/sdk/entities"; import type { SocketAddress } from "bun"; import { Glob } from "bun"; import chalk from "chalk"; @@ -24,6 +12,7 @@ import { eq } from "drizzle-orm"; import { matches } from "ip-matching"; import { isValidationError } from "zod-validation-error"; import { config } from "~/config.ts"; +import type { JSONObject } from "~/packages/federation/types.ts"; import { ApiError } from "../errors/api-error.ts"; /** @@ -63,21 +52,13 @@ export class InboxProcessor { * @param requestIp Request IP address. Grabs it from the Hono context if not provided. */ public constructor( - private request: { - url: URL; - method: string; - body: string; - }, - private body: Entity, + private request: Request, + private body: JSONObject, private sender: { instance: Instance; - key: string; + key: CryptoKey; } | null, - private headers: { - signature?: string; - signedAt?: Date; - authorization?: string; - }, + private authorizationHeader?: string, private logger: Logger = getLogger(["federation", "inbox"]), private requestIp: SocketAddress | null = null, ) {} @@ -87,40 +68,12 @@ export class InboxProcessor { * * @returns {Promise} - Whether the signature is valid. */ - private async isSignatureValid(): Promise { + private isSignatureValid(): Promise { if (!this.sender) { throw new Error("Sender is not defined"); } - if (config.debug?.federation) { - this.logger.debug`Sender public key: ${chalk.gray( - this.sender.key, - )}`; - } - - const validator = await SignatureValidator.fromStringKey( - this.sender.key, - ); - - if (!(this.headers.signature && this.headers.signedAt)) { - throw new Error("Missing signature or signature timestamp"); - } - - // HACK: Making a fake Request object instead of passing the values directly is necessary because otherwise the validation breaks for some unknown reason - const isValid = await validator.validate( - new Request(this.request.url, { - method: this.request.method, - headers: { - "Versia-Signature": this.headers.signature, - "Versia-Signed-At": ( - this.headers.signedAt.getTime() / 1000 - ).toString(), - }, - body: this.request.body, - }), - ); - - return isValid; + return verify(this.sender.key, this.request); } /** @@ -131,7 +84,7 @@ export class InboxProcessor { */ private shouldCheckSignature(): boolean { if (config.federation.bridge) { - const token = this.headers.authorization?.split("Bearer ")[1]; + const token = this.authorizationHeader?.split("Bearer ")[1]; if (token) { return this.isRequestFromBridge(token); @@ -226,58 +179,48 @@ export class InboxProcessor { shouldCheckSignature && this.logger.debug`Signature is valid`; - const validator = new EntityValidator(); - const handler = new RequestParserHandler(this.body, validator); - try { - return await handler.parseBody({ - note: (): Promise => this.processNote(), - follow: (): Promise => this.processFollowRequest(), - followAccept: (): Promise => this.processFollowAccept(), - followReject: (): Promise => this.processFollowReject(), - "pub.versia:likes/Like": (): Promise => - this.processLikeRequest(), - delete: (): Promise => this.processDelete(), - user: (): Promise => this.processUserRequest(), - unknown: (): void => { + new EntitySorter(this.body) + .on(VersiaEntities.Note, async (n) => { + await Note.fromVersia(n); + }) + .on(VersiaEntities.Follow, (f) => { + this.processFollowRequest(f); + }) + .on(VersiaEntities.FollowAccept, (f) => { + this.processFollowAccept(f); + }) + .on(VersiaEntities.FollowReject, (f) => { + this.processFollowReject(f); + }) + .on(VersiaEntities.Like, (l) => { + this.processLikeRequest(l); + }) + .on(VersiaEntities.Delete, (d) => { + this.processDelete(d); + }) + .on(VersiaEntities.User, async (u) => { + await User.fromVersia(u); + }) + .sort(() => { throw new ApiError(400, "Unknown entity type"); - }, - }); + }); } catch (e) { return this.handleError(e as Error); } } - /** - * Handles Note entity processing. - * - * @returns {Promise} - */ - private async processNote(): Promise { - const note = this.body as VersiaNote; - const author = await User.resolve(new URL(note.author)); - const instance = await Instance.resolve(new URL(note.uri)); - - if (!instance) { - throw new ApiError(404, "Instance not found"); - } - - if (!author) { - throw new ApiError(404, "Author not found"); - } - - await Note.fromVersia(note, author, instance); - } - /** * Handles Follow entity processing. * + * @param {VersiaFollow} follow - The Follow entity to process. * @returns {Promise} */ - private async processFollowRequest(): Promise { - const follow = this.body as unknown as VersiaFollow; - const author = await User.resolve(new URL(follow.author)); - const followee = await User.resolve(new URL(follow.followee)); + private async processFollowRequest( + follow: VersiaEntities.Follow, + ): Promise { + const author = await User.resolve(follow.data.author); + const followee = await User.resolve(follow.data.followee); if (!author) { throw new ApiError(404, "Author not found"); @@ -318,12 +261,14 @@ export class InboxProcessor { /** * Handles FollowAccept entity processing * + * @param {VersiaFollowAccept} followAccept - The FollowAccept entity to process. * @returns {Promise} */ - private async processFollowAccept(): Promise { - const followAccept = this.body as unknown as VersiaFollowAccept; - const author = await User.resolve(new URL(followAccept.author)); - const follower = await User.resolve(new URL(followAccept.follower)); + private async processFollowAccept( + followAccept: VersiaEntities.FollowAccept, + ): Promise { + const author = await User.resolve(followAccept.data.author); + const follower = await User.resolve(followAccept.data.follower); if (!author) { throw new ApiError(404, "Author not found"); @@ -351,12 +296,14 @@ export class InboxProcessor { /** * Handles FollowReject entity processing * + * @param {VersiaFollowReject} followReject - The FollowReject entity to process. * @returns {Promise} */ - private async processFollowReject(): Promise { - const followReject = this.body as unknown as VersiaFollowReject; - const author = await User.resolve(new URL(followReject.author)); - const follower = await User.resolve(new URL(followReject.follower)); + private async processFollowReject( + followReject: VersiaEntities.FollowReject, + ): Promise { + const author = await User.resolve(followReject.data.author); + const follower = await User.resolve(followReject.data.follower); if (!author) { throw new ApiError(404, "Author not found"); @@ -384,21 +331,20 @@ export class InboxProcessor { /** * Handles Delete entity processing. * + * @param {VersiaDelete} delete_ - The Delete entity to process. * @returns {Promise} - */ - public async processDelete(): Promise { - // JS doesn't allow the use of `delete` as a variable name - const delete_ = this.body as unknown as VersiaDelete; - const toDelete = delete_.deleted; + */ // JS doesn't allow the use of `delete` as a variable name + public async processDelete(delete_: VersiaEntities.Delete): Promise { + const toDelete = delete_.data.deleted; - const author = delete_.author - ? await User.resolve(new URL(delete_.author)) + const author = delete_.data.author + ? await User.resolve(delete_.data.author) : null; - switch (delete_.deleted_type) { + switch (delete_.data.deleted_type) { case "Note": { const note = await Note.fromSql( - eq(Notes.uri, toDelete), + eq(Notes.uri, toDelete.href), author ? eq(Notes.authorId, author.id) : undefined, ); @@ -413,7 +359,7 @@ export class InboxProcessor { return; } case "User": { - const userToDelete = await User.resolve(new URL(toDelete)); + const userToDelete = await User.resolve(toDelete); if (!userToDelete) { throw new ApiError(404, "User to delete not found"); @@ -428,7 +374,7 @@ export class InboxProcessor { } case "pub.versia:likes/Like": { const like = await Like.fromSql( - eq(Likes.uri, toDelete), + eq(Likes.uri, toDelete.href), author ? eq(Likes.likerId, author.id) : undefined, ); @@ -445,7 +391,7 @@ export class InboxProcessor { default: { throw new ApiError( 400, - `Deletion of object ${toDelete} not implemented`, + `Deletion of object ${toDelete.href} not implemented`, ); } } @@ -454,12 +400,12 @@ export class InboxProcessor { /** * Handles Like entity processing. * + * @param {VersiaLikeExtension} like - The Like entity to process. * @returns {Promise} */ - private async processLikeRequest(): Promise { - const like = this.body as unknown as VersiaLikeExtension; - const author = await User.resolve(new URL(like.author)); - const likedNote = await Note.resolve(new URL(like.liked)); + private async processLikeRequest(like: VersiaEntities.Like): Promise { + const author = await User.resolve(like.data.author); + const likedNote = await Note.resolve(like.data.liked); if (!author) { throw new ApiError(404, "Author not found"); @@ -469,23 +415,7 @@ export class InboxProcessor { throw new ApiError(404, "Liked Note not found"); } - await author.like(likedNote, like.uri); - } - - /** - * Handles User entity processing (profile edits). - * - * @returns {Promise} - */ - private async processUserRequest(): Promise { - const user = this.body as unknown as VersiaUser; - const instance = await Instance.resolve(new URL(user.uri)); - - if (!instance) { - throw new ApiError(404, "Instance not found"); - } - - await User.fromVersia(user, instance); + await author.like(likedNote, like.data.uri); } /** diff --git a/classes/queues/delivery.ts b/classes/queues/delivery.ts index 0ddc9f8a..e8cbc6b0 100644 --- a/classes/queues/delivery.ts +++ b/classes/queues/delivery.ts @@ -1,9 +1,10 @@ import { User } from "@versia/kit/db"; +import * as VersiaEntities from "@versia/sdk/entities"; import { Queue } from "bullmq"; import { Worker } from "bullmq"; import chalk from "chalk"; import { config } from "~/config.ts"; -import type { KnownEntity } from "~/types/api"; +import type { JSONObject } from "~/packages/federation/types"; import { connection } from "~/utils/redis.ts"; export enum DeliveryJobType { @@ -11,7 +12,7 @@ export enum DeliveryJobType { } export type DeliveryJobData = { - entity: KnownEntity; + entity: JSONObject; recipientId: string; senderId: string; }; @@ -55,7 +56,23 @@ export const getDeliveryWorker = (): Worker< `Federating entity [${entity.id}] from @${sender.getAcct()} to @${recipient.getAcct()}`, ); - await sender.federateToUser(entity, recipient); + const type = entity.type; + const entityCtor = Object.values(VersiaEntities).find( + (ctor) => ctor.name === type, + ) as typeof VersiaEntities.Entity | undefined; + + if (!entityCtor) { + throw new Error( + `Could not resolve entity type ${chalk.gray( + type, + )} for entity [${entity.id}]`, + ); + } + + await sender.federateToUser( + await entityCtor.fromJSON(entity), + recipient, + ); await job.log( `✔ Finished federating entity [${entity.id}]`, diff --git a/classes/queues/inbox.ts b/classes/queues/inbox.ts index fb378507..bd549697 100644 --- a/classes/queues/inbox.ts +++ b/classes/queues/inbox.ts @@ -1,10 +1,10 @@ import { getLogger } from "@logtape/logtape"; -import type { Entity } from "@versia/federation/types"; import { Instance, User } from "@versia/kit/db"; import { Queue } from "bullmq"; import { Worker } from "bullmq"; import type { SocketAddress } from "bun"; import { config } from "~/config.ts"; +import type { JSONObject } from "~/packages/federation/types.ts"; import { connection } from "~/utils/redis.ts"; import { ApiError } from "../errors/api-error.ts"; import { InboxProcessor } from "../inbox/processor.ts"; @@ -14,7 +14,7 @@ export enum InboxJobType { } export type InboxJobData = { - data: Entity; + data: JSONObject; headers: { "versia-signature"?: string; "versia-signed-at"?: number; @@ -46,18 +46,25 @@ export const getInboxWorker = (): Worker => await job.log(`Processing entity [${data.id}]`); + const req = new Request(request.url, { + method: request.method, + headers: new Headers( + Object.entries(headers) + .map(([k, v]) => [k, String(v)]) + .concat([ + ["content-type", "application/json"], + ]) as [string, string][], + ), + body: request.body, + }); + if (headers.authorization) { try { const processor = new InboxProcessor( - { - ...request, - url: new URL(request.url), - }, + req, data, null, - { - authorization: headers.authorization, - }, + headers.authorization, getLogger(["federation", "inbox"]), ip, ); @@ -91,13 +98,7 @@ export const getInboxWorker = (): Worker => return; } - const { - "versia-signature": signature, - "versia-signed-at": signedAt, - "versia-signed-by": signedBy, - } = headers as { - "versia-signature": string; - "versia-signed-at": number; + const { "versia-signed-by": signedBy } = headers as { "versia-signed-by": string; }; @@ -139,24 +140,27 @@ export const getInboxWorker = (): Worker => ); } + const key = await crypto.subtle.importKey( + "spki", + Buffer.from( + sender?.data.publicKey ?? + remoteInstance.data.publicKey.key, + "base64", + ), + "Ed25519", + false, + ["verify"], + ); + try { const processor = new InboxProcessor( - { - ...request, - url: new URL(request.url), - }, + req, data, { instance: remoteInstance, - key: - sender?.data.publicKey ?? - remoteInstance.data.publicKey.key, - }, - { - signature, - signedAt: new Date(signedAt * 1000), - authorization: undefined, + key, }, + undefined, getLogger(["federation", "inbox"]), ip, ); diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 0b95d7d4..9a18f7b9 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -4,7 +4,13 @@ import type { Status as StatusSchema, } from "@versia/client/schemas"; import type { RolePermission } from "@versia/client/schemas"; -import type { ContentFormat, InstanceMetadata } from "@versia/federation/types"; +import type { + ContentFormatSchema, + ImageContentFormatSchema, + InstanceMetadataSchema, + NonTextContentFormatSchema, + TextContentFormatSchema, +} from "@versia/sdk/schemas"; import type { Challenge } from "altcha-lib/types"; import { relations, sql } from "drizzle-orm"; import { @@ -361,9 +367,13 @@ export const TokensRelations = relations(Tokens, ({ one }) => ({ export const Medias = pgTable("Medias", { id: id(), - content: jsonb("content").notNull().$type(), - originalContent: jsonb("original_content").$type(), - thumbnail: jsonb("thumbnail").$type(), + content: jsonb("content") + .notNull() + .$type>(), + originalContent: + jsonb("original_content").$type>(), + thumbnail: + jsonb("thumbnail").$type>(), blurhash: text("blurhash"), }); @@ -506,7 +516,7 @@ export const Instances = pgTable("Instances", { baseUrl: text("base_url").notNull(), name: text("name").notNull(), version: text("version").notNull(), - logo: jsonb("logo").$type(), + logo: jsonb("logo").$type(), disableAutomoderation: boolean("disable_automoderation") .default(false) .notNull(), @@ -515,8 +525,14 @@ export const Instances = pgTable("Instances", { .$type<"versia" | "activitypub">() .default("versia"), inbox: text("inbox"), - publicKey: jsonb("public_key").$type(), - extensions: jsonb("extensions").$type(), + publicKey: + jsonb("public_key").$type< + (typeof InstanceMetadataSchema._input)["public_key"] + >(), + extensions: + jsonb("extensions").$type< + (typeof InstanceMetadataSchema._input)["extensions"] + >(), }); export const InstancesRelations = relations(Instances, ({ many }) => ({ @@ -549,8 +565,8 @@ export const Users = pgTable( passwordResetToken: text("password_reset_token"), fields: jsonb("fields").notNull().default("[]").$type< { - key: ContentFormat; - value: ContentFormat; + key: z.infer; + value: z.infer; }[] >(), endpoints: jsonb("endpoints").$type => { + const buffer = new TextEncoder().encode(str); + const hashBuffer = await crypto.subtle.digest("SHA-256", buffer); + const hashArray = new Uint8Array(hashBuffer); + + return hashArray.toBase64(); +}; + +const base64ToArrayBuffer = (base64: string): ArrayBuffer => + Uint8Array.fromBase64(base64).buffer as ArrayBuffer; + +export const sign = async ( + privateKey: CryptoKey, + authorUrl: URL, + req: Request, + timestamp = new Date(), +): Promise => { + const body = await req.clone().text(); + const url = new URL(req.url); + + const digest = stringToBase64Hash(body); + const timestampSecs = Math.floor(timestamp.getTime() / 1000); + + const signedString = `${req.method.toLowerCase()} ${encodeURI( + url.pathname, + )} ${timestampSecs} ${digest}`; + + const signature = await crypto.subtle.sign( + "Ed25519", + privateKey, + new TextEncoder().encode(signedString), + ); + + const signatureBase64 = new Uint8Array(signature).toBase64(); + + const newReq = new Request(req, { + headers: { + ...req.headers, + "Versia-Signature": signatureBase64, + "Versia-Signed-At": String(timestampSecs), + "Versia-Signed-By": authorUrl.href, + }, + }); + + return newReq; +}; + +export const verify = async ( + publicKey: CryptoKey, + req: Request, +): Promise => { + const signature = req.headers.get("Versia-Signature"); + const signedAt = req.headers.get("Versia-Signed-At"); + const signedBy = req.headers.get("Versia-Signed-By"); + + if (!(signature && signedAt && signedBy)) { + return false; + } + + const body = await req.clone().text(); + const url = new URL(req.url); + + const digest = await stringToBase64Hash(body); + + const expectedSignedString = `${req.method.toLowerCase()} ${encodeURI(url.pathname)} ${signedAt} ${digest}`; + + // Check if this matches the signature + return crypto.subtle.verify( + "Ed25519", + publicKey, + base64ToArrayBuffer(signature), + new TextEncoder().encode(expectedSignedString), + ); +}; diff --git a/packages/federation/entities/collection.ts b/packages/federation/entities/collection.ts new file mode 100644 index 00000000..facc856c --- /dev/null +++ b/packages/federation/entities/collection.ts @@ -0,0 +1,29 @@ +import type { z } from "zod"; +import { + CollectionSchema, + URICollectionSchema, +} from "../schemas/collection.ts"; +import type { JSONObject } from "../types.ts"; +import { Entity } from "./entity.ts"; + +export class Collection extends Entity { + public constructor(public data: z.infer) { + super(data); + } + + public static fromJSON(json: JSONObject): Promise { + return CollectionSchema.parseAsync(json).then((u) => new Collection(u)); + } +} + +export class URICollection extends Entity { + public constructor(public data: z.infer) { + super(data); + } + + public static fromJSON(json: JSONObject): Promise { + return URICollectionSchema.parseAsync(json).then( + (u) => new URICollection(u), + ); + } +} diff --git a/packages/federation/entities/contentformat.ts b/packages/federation/entities/contentformat.ts new file mode 100644 index 00000000..fcbfd836 --- /dev/null +++ b/packages/federation/entities/contentformat.ts @@ -0,0 +1,82 @@ +import type { z } from "zod"; +import { + AudioContentFormatSchema, + ContentFormatSchema, + ImageContentFormatSchema, + NonTextContentFormatSchema, + TextContentFormatSchema, + VideoContentFormatSchema, +} from "../schemas/contentformat.ts"; +import type { JSONObject } from "../types.ts"; + +export class ContentFormat { + public static fromJSON(data: JSONObject): Promise { + return ContentFormatSchema.parseAsync(data).then( + (d) => new ContentFormat(d), + ); + } + + public constructor(public data: z.infer) {} +} + +export class TextContentFormat extends ContentFormat { + public static fromJSON(data: JSONObject): Promise { + return TextContentFormatSchema.parseAsync(data).then( + (d) => new TextContentFormat(d), + ); + } + + public constructor(public data: z.infer) { + super(data); + } +} + +export class NonTextContentFormat extends ContentFormat { + public static fromJSON(data: JSONObject): Promise { + return NonTextContentFormatSchema.parseAsync(data).then( + (d) => new NonTextContentFormat(d), + ); + } + + public constructor( + public data: z.infer, + ) { + super(data); + } +} + +export class ImageContentFormat extends ContentFormat { + public static fromJSON(data: JSONObject): Promise { + return ImageContentFormatSchema.parseAsync(data).then( + (d) => new ImageContentFormat(d), + ); + } + + public constructor(public data: z.infer) { + super(data); + } +} + +export class VideoContentFormat extends ContentFormat { + public static fromJSON(data: JSONObject): Promise { + return VideoContentFormatSchema.parseAsync(data).then( + (d) => new VideoContentFormat(d), + ); + } + + public constructor(public data: z.infer) { + super(data); + } +} + +export class AudioContentFormat extends ContentFormat { + public static fromJSON(data: JSONObject): Promise { + return AudioContentFormatSchema.parseAsync(data).then( + (d) => new AudioContentFormat(d), + ); + } + + public constructor(public data: z.infer) { + super(data); + } +} diff --git a/packages/federation/entities/delete.ts b/packages/federation/entities/delete.ts new file mode 100644 index 00000000..85b33db3 --- /dev/null +++ b/packages/federation/entities/delete.ts @@ -0,0 +1,16 @@ +import type { z } from "zod"; +import { DeleteSchema } from "../schemas/delete.ts"; +import type { JSONObject } from "../types.ts"; +import { Entity } from "./entity.ts"; + +export class Delete extends Entity { + public static name = "Delete"; + + public constructor(public data: z.infer) { + super(data); + } + + public static fromJSON(json: JSONObject): Promise { + return DeleteSchema.parseAsync(json).then((u) => new Delete(u)); + } +} diff --git a/packages/federation/entities/entity.ts b/packages/federation/entities/entity.ts new file mode 100644 index 00000000..cd495399 --- /dev/null +++ b/packages/federation/entities/entity.ts @@ -0,0 +1,17 @@ +import { EntitySchema } from "../schemas/entity.ts"; +import type { JSONObject } from "../types.ts"; + +export class Entity { + public static name = "Entity"; + + // biome-ignore lint/suspicious/noExplicitAny: This is a base class that is never instanciated directly + public constructor(public data: any) {} + + public static fromJSON(json: JSONObject): Promise { + return EntitySchema.parseAsync(json).then((u) => new Entity(u)); + } + + public toJSON(): JSONObject { + return this.data; + } +} diff --git a/packages/federation/entities/extensions/likes.ts b/packages/federation/entities/extensions/likes.ts new file mode 100644 index 00000000..f8c606b1 --- /dev/null +++ b/packages/federation/entities/extensions/likes.ts @@ -0,0 +1,28 @@ +import type { z } from "zod"; +import { DislikeSchema, LikeSchema } from "../../schemas/extensions/likes.ts"; +import type { JSONObject } from "../../types.ts"; +import { Entity } from "../entity.ts"; + +export class Like extends Entity { + public static name = "pub.versia:likes/Like"; + + public constructor(public data: z.infer) { + super(data); + } + + public static fromJSON(json: JSONObject): Promise { + return LikeSchema.parseAsync(json).then((u) => new Like(u)); + } +} + +export class Dislike extends Entity { + public static name = "pub.versia:likes/Dislike"; + + public constructor(public data: z.infer) { + super(data); + } + + public static fromJSON(json: JSONObject): Promise { + return DislikeSchema.parseAsync(json).then((u) => new Dislike(u)); + } +} diff --git a/packages/federation/entities/extensions/polls.ts b/packages/federation/entities/extensions/polls.ts new file mode 100644 index 00000000..43264f03 --- /dev/null +++ b/packages/federation/entities/extensions/polls.ts @@ -0,0 +1,16 @@ +import type { z } from "zod"; +import { VoteSchema } from "../../schemas/extensions/polls.ts"; +import type { JSONObject } from "../../types.ts"; +import { Entity } from "../entity.ts"; + +export class Vote extends Entity { + public static name = "pub.versia:polls/Vote"; + + public constructor(public data: z.infer) { + super(data); + } + + public static fromJSON(json: JSONObject): Promise { + return VoteSchema.parseAsync(json).then((u) => new Vote(u)); + } +} diff --git a/packages/federation/entities/extensions/reactions.ts b/packages/federation/entities/extensions/reactions.ts new file mode 100644 index 00000000..0d7d432f --- /dev/null +++ b/packages/federation/entities/extensions/reactions.ts @@ -0,0 +1,16 @@ +import type { z } from "zod"; +import { ReactionSchema } from "../../schemas/extensions/reactions.ts"; +import type { JSONObject } from "../../types.ts"; +import { Entity } from "../entity.ts"; + +export class Reaction extends Entity { + public static name = "pub.versia:reactions/Reaction"; + + public constructor(public data: z.infer) { + super(data); + } + + public static fromJSON(json: JSONObject): Promise { + return ReactionSchema.parseAsync(json).then((u) => new Reaction(u)); + } +} diff --git a/packages/federation/entities/extensions/reports.ts b/packages/federation/entities/extensions/reports.ts new file mode 100644 index 00000000..a3e709e4 --- /dev/null +++ b/packages/federation/entities/extensions/reports.ts @@ -0,0 +1,16 @@ +import type { z } from "zod"; +import { ReportSchema } from "../../schemas/extensions/reports.ts"; +import type { JSONObject } from "../../types.ts"; +import { Entity } from "../entity.ts"; + +export class Report extends Entity { + public static name = "pub.versia:reports/Report"; + + public constructor(public data: z.infer) { + super(data); + } + + public static fromJSON(json: JSONObject): Promise { + return ReportSchema.parseAsync(json).then((u) => new Report(u)); + } +} diff --git a/packages/federation/entities/extensions/share.ts b/packages/federation/entities/extensions/share.ts new file mode 100644 index 00000000..d49cc9fc --- /dev/null +++ b/packages/federation/entities/extensions/share.ts @@ -0,0 +1,16 @@ +import type { z } from "zod"; +import { ShareSchema } from "../../schemas/extensions/share.ts"; +import type { JSONObject } from "../../types.ts"; +import { Entity } from "../entity.ts"; + +export class Share extends Entity { + public static name = "pub.versia:share/Share"; + + public constructor(public data: z.infer) { + super(data); + } + + public static fromJSON(json: JSONObject): Promise { + return ShareSchema.parseAsync(json).then((u) => new Share(u)); + } +} diff --git a/packages/federation/entities/follow.ts b/packages/federation/entities/follow.ts new file mode 100644 index 00000000..5fe86d76 --- /dev/null +++ b/packages/federation/entities/follow.ts @@ -0,0 +1,61 @@ +import type { z } from "zod"; +import { + FollowAcceptSchema, + FollowRejectSchema, + FollowSchema, + UnfollowSchema, +} from "../schemas/follow.ts"; +import type { JSONObject } from "../types.ts"; +import { Entity } from "./entity.ts"; + +export class Follow extends Entity { + public static name = "Follow"; + + public constructor(public data: z.infer) { + super(data); + } + + public static fromJSON(json: JSONObject): Promise { + return FollowSchema.parseAsync(json).then((u) => new Follow(u)); + } +} + +export class FollowAccept extends Entity { + public static name = "FollowAccept"; + + public constructor(public data: z.infer) { + super(data); + } + + public static fromJSON(json: JSONObject): Promise { + return FollowAcceptSchema.parseAsync(json).then( + (u) => new FollowAccept(u), + ); + } +} + +export class FollowReject extends Entity { + public static name = "FollowReject"; + + public constructor(public data: z.infer) { + super(data); + } + + public static fromJSON(json: JSONObject): Promise { + return FollowRejectSchema.parseAsync(json).then( + (u) => new FollowReject(u), + ); + } +} + +export class Unfollow extends Entity { + public static name = "Unfollow"; + + public constructor(public data: z.infer) { + super(data); + } + + public static fromJSON(json: JSONObject): Promise { + return UnfollowSchema.parseAsync(json).then((u) => new Unfollow(u)); + } +} diff --git a/packages/federation/entities/index.ts b/packages/federation/entities/index.ts new file mode 100644 index 00000000..a994334a --- /dev/null +++ b/packages/federation/entities/index.ts @@ -0,0 +1,21 @@ +// biome-ignore lint/performance/noBarrelFile: +export { User } from "./user.ts"; +export { Note } from "./note.ts"; +export { Entity } from "./entity.ts"; +export { Delete } from "./delete.ts"; +export { InstanceMetadata } from "./instancemetadata.ts"; +export { + ImageContentFormat, + AudioContentFormat, + NonTextContentFormat, + TextContentFormat, + ContentFormat, + VideoContentFormat, +} from "./contentformat.ts"; +export { Follow, FollowAccept, FollowReject, Unfollow } from "./follow.ts"; +export { Collection, URICollection } from "./collection.ts"; +export { Like, Dislike } from "./extensions/likes.ts"; +export { Vote } from "./extensions/polls.ts"; +export { Reaction } from "./extensions/reactions.ts"; +export { Report } from "./extensions/reports.ts"; +export { Share } from "./extensions/share.ts"; diff --git a/packages/federation/entities/instancemetadata.ts b/packages/federation/entities/instancemetadata.ts new file mode 100644 index 00000000..6517daff --- /dev/null +++ b/packages/federation/entities/instancemetadata.ts @@ -0,0 +1,31 @@ +import type { z } from "zod"; +import { InstanceMetadataSchema } from "../schemas/instance.ts"; +import type { JSONObject } from "../types.ts"; +import { ImageContentFormat } from "./contentformat.ts"; +import { Entity } from "./entity.ts"; + +export class InstanceMetadata extends Entity { + public static name = "InstanceMetadata"; + + public constructor(public data: z.infer) { + super(data); + } + + public get logo(): ImageContentFormat | undefined { + return this.data.logo + ? new ImageContentFormat(this.data.logo) + : undefined; + } + + public get banner(): ImageContentFormat | undefined { + return this.data.banner + ? new ImageContentFormat(this.data.banner) + : undefined; + } + + public static fromJSON(json: JSONObject): Promise { + return InstanceMetadataSchema.parseAsync(json).then( + (u) => new InstanceMetadata(u), + ); + } +} diff --git a/packages/federation/entities/note.ts b/packages/federation/entities/note.ts new file mode 100644 index 00000000..df64d15b --- /dev/null +++ b/packages/federation/entities/note.ts @@ -0,0 +1,29 @@ +import type { z } from "zod"; +import { NoteSchema } from "../schemas/note.ts"; +import type { JSONObject } from "../types.ts"; +import { NonTextContentFormat, TextContentFormat } from "./contentformat.ts"; +import { Entity } from "./entity.ts"; + +export class Note extends Entity { + public static name = "Note"; + + public constructor(public data: z.infer) { + super(data); + } + + public static fromJSON(json: JSONObject): Promise { + return NoteSchema.parseAsync(json).then((n) => new Note(n)); + } + + public get attachments(): NonTextContentFormat[] { + return ( + this.data.attachments?.map((a) => new NonTextContentFormat(a)) ?? [] + ); + } + + public get content(): TextContentFormat | undefined { + return this.data.content + ? new TextContentFormat(this.data.content) + : undefined; + } +} diff --git a/packages/federation/entities/user.ts b/packages/federation/entities/user.ts new file mode 100644 index 00000000..b3d6f0d7 --- /dev/null +++ b/packages/federation/entities/user.ts @@ -0,0 +1,33 @@ +import type { z } from "zod"; +import { UserSchema } from "../schemas/user.ts"; +import type { JSONObject } from "../types.ts"; +import { ImageContentFormat, TextContentFormat } from "./contentformat.ts"; +import { Entity } from "./entity.ts"; + +export class User extends Entity { + public static name = "User"; + + public constructor(public data: z.infer) { + super(data); + } + + public static fromJSON(json: JSONObject): Promise { + return UserSchema.parseAsync(json).then((u) => new User(u)); + } + + public get avatar(): ImageContentFormat | undefined { + return this.data.avatar + ? new ImageContentFormat(this.data.avatar) + : undefined; + } + + public get header(): ImageContentFormat | undefined { + return this.data.header + ? new ImageContentFormat(this.data.header) + : undefined; + } + + public get bio(): TextContentFormat | undefined { + return this.data.bio ? new TextContentFormat(this.data.bio) : undefined; + } +} diff --git a/packages/federation/http.ts b/packages/federation/http.ts new file mode 100644 index 00000000..65b2b2ae --- /dev/null +++ b/packages/federation/http.ts @@ -0,0 +1,203 @@ +import { sign } from "./crypto.ts"; +import { Collection, URICollection } from "./entities/collection.ts"; +import type { Entity } from "./entities/entity.ts"; +import { homepage, version } from "./package.json"; +import { WebFingerSchema } from "./schemas/webfinger.ts"; + +const DEFAULT_UA = `VersiaFederationClient/${version} (+${homepage})`; + +/** + * A class that handles fetching Versia entities + * + * @example + * const requester = new FederationRequester(privateKey, authorUrl); + * + * const user = await requester.fetchEntity( + * new URL("https://example.com/users/1"), + * User, + * ); + * + * console.log(user); // => User { ... } + */ +export class FederationRequester { + public constructor( + private readonly privateKey: CryptoKey, + private readonly authorUrl: URL, + ) {} + + public async fetchEntity( + url: URL, + expectedType: T, + ): Promise> { + const req = new Request(url, { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": DEFAULT_UA, + }, + }); + + const finalReq = await sign(this.privateKey, this.authorUrl, req); + + const { ok, json, text, headers, status } = await fetch(finalReq); + + if (!ok) { + throw new Error( + `Failed to fetch entity from ${url.toString()}: got HTTP code ${status} with body "${await text()}"`, + ); + } + + const contentType = headers.get("Content-Type"); + + if (!contentType?.includes("application/json")) { + throw new Error( + `Expected JSON response from ${url.toString()}, got "${contentType}"`, + ); + } + + const jsonData = await json(); + const type = jsonData.type; + + if (type && type !== expectedType.name) { + throw new Error( + `Expected entity type "${expectedType.name}", got "${type}"`, + ); + } + + const entity = await expectedType.fromJSON(jsonData); + + return entity as InstanceType; + } + + public async postEntity(url: URL, entity: Entity): Promise { + const req = new Request(url, { + method: "POST", + headers: { + Accept: "application/json", + "User-Agent": DEFAULT_UA, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify(entity.toJSON()), + }); + + const finalReq = await sign(this.privateKey, this.authorUrl, req); + + return fetch(finalReq); + } + + /** + * Recursively go through a Collection of entities until reaching the end + * @param url URL to reach the Collection + * @param expectedType + * @param options.limit Limit the number of entities to fetch + */ + public async resolveCollection( + url: URL, + expectedType: T, + options?: { + limit?: number; + }, + ): Promise[]> { + const entities: InstanceType[] = []; + let nextUrl: URL | null = url; + let limit = options?.limit ?? Number.POSITIVE_INFINITY; + + while (nextUrl && limit > 0) { + const collection: Collection = await this.fetchEntity( + nextUrl, + Collection, + ); + + for (const entity of collection.data.items) { + if (entity.type === expectedType.name) { + entities.push( + (await expectedType.fromJSON( + entity, + )) as InstanceType, + ); + } + } + + nextUrl = collection.data.next; + limit -= collection.data.items.length; + } + + return entities; + } + + /** + * Recursively go through a URICollection of entities until reaching the end + * @param url URL to reach the Collection + * @param options.limit Limit the number of entities to fetch + */ + public async resolveURICollection( + url: URL, + options?: { + limit?: number; + }, + ): Promise { + const entities: URL[] = []; + let nextUrl: URL | null = url; + let limit = options?.limit ?? Number.POSITIVE_INFINITY; + + while (nextUrl && limit > 0) { + const collection: URICollection = await this.fetchEntity( + nextUrl, + URICollection, + ); + + entities.push(...collection.data.items); + nextUrl = collection.data.next; + limit -= collection.data.items.length; + } + + return entities; + } + + /** + * Attempt to resolve a webfinger URL to a User + * @returns {Promise} The resolved User or null if not found + */ + public async resolveWebFinger( + username: string, + hostname: string, + contentType = "application/json", + serverUrl = `https://${hostname}`, + ): Promise { + const { ok, json, text } = await fetch( + new URL( + `/.well-known/webfinger?${new URLSearchParams({ + resource: `acct:${username}@${hostname}`, + })}`, + serverUrl, + ), + { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": DEFAULT_UA, + }, + }, + ); + + if (!ok) { + throw new Error( + `Failed to fetch webfinger from ${serverUrl}: got HTTP code ${ok} with body "${await text()}"`, + ); + } + + // Validate the response + const data = await WebFingerSchema.parseAsync(await json()); + + // Get the first link with a rel of "self" + const selfLink = data.links?.find( + (link) => link.rel === "self" && link.type === contentType, + ); + + if (!selfLink?.href) { + return null; + } + + return new URL(selfLink.href); + } +} diff --git a/packages/federation/inbox-processor.ts b/packages/federation/inbox-processor.ts new file mode 100644 index 00000000..647ce820 --- /dev/null +++ b/packages/federation/inbox-processor.ts @@ -0,0 +1,54 @@ +import type { Entity } from "./entities/entity.ts"; +import type { JSONObject } from "./types.ts"; + +type EntitySorterHandlers = Map< + typeof Entity, + (entity: Entity) => MaybePromise +>; +type MaybePromise = T | Promise; + +/** + * @example + * const jsonData = { ... }; + * const processor = new EntitySorter(jsonData) + * .on(User, async (user) => { + * // Do something with the user + * }) + * .sort(); + */ +export class EntitySorter { + private handlers: EntitySorterHandlers = new Map(); + + public constructor(private jsonData: JSONObject) {} + + public on( + entity: T, + handler: (entity: InstanceType) => MaybePromise, + ): EntitySorter { + this.handlers.set( + entity, + handler as (entity: Entity) => MaybePromise, + ); + return this; + } + + /** + * Sorts the entity based on the provided JSON data. + * @param {() => MaybePromise} defaultHandler - A default handler to call if no specific handler is found. + * @throws {Error} If no handler is found for the entity type + */ + public async sort( + defaultHandler?: () => MaybePromise, + ): Promise { + const type = this.jsonData.type; + const entity = this.handlers.keys().find((key) => key.name === type); + + if (entity) { + await this.handlers.get(entity)?.( + await entity.fromJSON(this.jsonData), + ); + } else { + await defaultHandler?.(); + } + } +} diff --git a/packages/federation/package.json b/packages/federation/package.json new file mode 100644 index 00000000..38ecfb2d --- /dev/null +++ b/packages/federation/package.json @@ -0,0 +1,69 @@ +{ + "name": "@versia/sdk", + "displayName": "Versia SDK", + "version": "0.0.1", + "author": { + "email": "jesse.wierzbinski@lysand.org", + "name": "Jesse Wierzbinski (CPlusPatch)", + "url": "https://cpluspatch.com" + }, + "readme": "README.md", + "repository": { + "type": "git", + "url": "https://github.com/versia-pub/server.git", + "directory": "packages/federation" + }, + "bugs": { + "url": "https://github.com/versia-pub/server/issues" + }, + "license": "MIT", + "contributors": [ + { + "name": "Jesse Wierzbinski", + "email": "jesse.wierzbinski@lysand.org", + "url": "https://cpluspatch.com" + } + ], + "maintainers": [ + { + "name": "Jesse Wierzbinski", + "email": "jesse.wierzbinski@lysand.org", + "url": "https://cpluspatch.com" + } + ], + "description": "Versia Federation SDK", + "categories": ["Other"], + "type": "module", + "engines": { + "bun": ">=1.2.5" + }, + "exports": { + ".": { + "import": "./inbox-processor.ts", + "default": "./inbox-processor.ts" + }, + "./http": { + "import": "./http.ts", + "default": "./http.ts" + }, + "./crypto": { + "import": "./crypto.ts", + "default": "./crypto.ts" + }, + "./entities": { + "import": "./entities/index.ts", + "default": "./entities/index.ts" + }, + "./schemas": { + "import": "./schemas/index.ts", + "default": "./schemas/index.ts" + } + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/lysand" + }, + "homepage": "https://versia.pub", + "keywords": ["versia", "typescript", "sdk"], + "packageManager": "bun@1.2.5" +} diff --git a/packages/federation/regex.ts b/packages/federation/regex.ts new file mode 100644 index 00000000..e08de1ad --- /dev/null +++ b/packages/federation/regex.ts @@ -0,0 +1,64 @@ +import { + charIn, + charNotIn, + createRegExp, + digit, + exactly, + global, + letter, + not, + oneOrMore, +} from "magic-regexp"; + +export const semverRegex: RegExp = new RegExp( + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/gm, +); + +/** + * Regular expression for matching an extension_type + * @example pub.versia:custom_emojis/Emoji + */ +export const extensionTypeRegex: RegExp = createRegExp( + // org namespace, then colon, then alphanumeric/_/-, then extension name + exactly( + oneOrMore(exactly(letter.lowercase.or(digit).or(charIn("_-.")))), + exactly(":"), + oneOrMore(exactly(letter.lowercase.or(digit).or(charIn("_-")))), + exactly("/"), + oneOrMore(exactly(letter.or(digit).or(charIn("_-")))), + ), +); + +/** + * Regular expression for matching an extension + * @example pub.versia:custom_emojis + */ +export const extensionRegex: RegExp = createRegExp( + // org namespace, then colon, then alphanumeric/_/-, then extension name + exactly( + oneOrMore(exactly(letter.lowercase.or(digit).or(charIn("_-.")))), + exactly(":"), + oneOrMore(exactly(letter.lowercase.or(digit).or(charIn("_-")))), + ), +); + +/** + * Regular expression for matching emojis. + */ +export const emojiRegex: RegExp = createRegExp( + exactly( + exactly(not.letter.or(not.digit).or(charNotIn("_-"))).times(1), + oneOrMore(letter.or(digit).or(charIn("_-"))), + exactly(not.letter.or(not.digit).or(charNotIn("_-"))).times(1), + ), + [global], +); + +// This will accept a lot of stuff that isn't an ISO string +// but ISO validation is incredibly complex so fuck it +export const isISOString = (val: string | Date): boolean => { + const d = new Date(val); + return !Number.isNaN(d.valueOf()); +}; + +export const ianaTimezoneRegex = /^(?:[A-Za-z]+(?:\/[A-Za-z_]+)+|UTC)$/; diff --git a/packages/federation/schemas/collection.ts b/packages/federation/schemas/collection.ts new file mode 100644 index 00000000..fa46c2ea --- /dev/null +++ b/packages/federation/schemas/collection.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; +import { url, u64 } from "./common.ts"; + +export const CollectionSchema = z.strictObject({ + author: url.nullable(), + first: url, + last: url, + total: u64, + next: url.nullable(), + previous: url.nullable(), + items: z.array(z.any()), +}); + +export const URICollectionSchema = CollectionSchema.extend({ + items: z.array(url), +}); diff --git a/packages/federation/schemas/common.ts b/packages/federation/schemas/common.ts new file mode 100644 index 00000000..468059ef --- /dev/null +++ b/packages/federation/schemas/common.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const f64 = z + .number() + .nonnegative() + .max(2 ** 64 - 1); + +export const u64 = z + .number() + .int() + .nonnegative() + .max(2 ** 64 - 1); + +export const url = z + .string() + .url() + .transform((z) => new URL(z)); diff --git a/packages/federation/schemas/contentformat.ts b/packages/federation/schemas/contentformat.ts new file mode 100644 index 00000000..5605a9fc --- /dev/null +++ b/packages/federation/schemas/contentformat.ts @@ -0,0 +1,117 @@ +import { types } from "mime-types"; +import { z } from "zod"; +import { f64, u64 } from "./common.ts"; + +const hashSizes = { + sha256: 64, + sha512: 128, + "sha3-256": 64, + "sha3-512": 128, + "blake2b-256": 64, + "blake2b-512": 128, + "blake3-256": 64, + "blake3-512": 128, + md5: 32, + sha1: 40, + sha224: 56, + sha384: 96, + "sha3-224": 56, + "sha3-384": 96, + "blake2s-256": 64, + "blake2s-512": 128, + "blake3-224": 56, + "blake3-384": 96, +}; +const allMimeTypes = Object.values(types) as [string, ...string[]]; +const textMimeTypes = Object.values(types).filter((v) => + v.startsWith("text/"), +) as [string, ...string[]]; +const nonTextMimeTypes = Object.values(types).filter( + (v) => !v.startsWith("text/"), +) as [string, ...string[]]; +const imageMimeTypes = Object.values(types).filter((v) => + v.startsWith("image/"), +) as [string, ...string[]]; +const videoMimeTypes = Object.values(types).filter((v) => + v.startsWith("video/"), +) as [string, ...string[]]; +const audioMimeTypes = Object.values(types).filter((v) => + v.startsWith("audio/"), +) as [string, ...string[]]; + +export const ContentFormatSchema = z.record( + z.enum(allMimeTypes), + z.strictObject({ + content: z.string().or(z.string().url()), + remote: z.boolean(), + description: z.string().nullish(), + size: u64.nullish(), + hash: z + .strictObject( + Object.fromEntries( + Object.entries(hashSizes).map(([k, v]) => [ + k, + z.string().length(v).nullish(), + ]), + ), + ) + .nullish(), + thumbhash: z.string().nullish(), + width: u64.nullish(), + height: u64.nullish(), + duration: f64.nullish(), + fps: u64.nullish(), + }), +); + +export const TextContentFormatSchema = z.record( + z.enum(textMimeTypes), + ContentFormatSchema.valueSchema + .pick({ + content: true, + remote: true, + }) + .extend({ + content: z.string(), + remote: z.literal(false), + }), +); + +export const NonTextContentFormatSchema = z.record( + z.enum(nonTextMimeTypes), + ContentFormatSchema.valueSchema + .pick({ + content: true, + remote: true, + description: true, + size: true, + hash: true, + thumbhash: true, + width: true, + height: true, + }) + .extend({ + content: z.string().url(), + remote: z.literal(true), + }), +); + +export const ImageContentFormatSchema = z.record( + z.enum(imageMimeTypes), + NonTextContentFormatSchema.valueSchema, +); + +export const VideoContentFormatSchema = z.record( + z.enum(videoMimeTypes), + NonTextContentFormatSchema.valueSchema.extend({ + duration: ContentFormatSchema.valueSchema.shape.duration, + fps: ContentFormatSchema.valueSchema.shape.fps, + }), +); + +export const AudioContentFormatSchema = z.record( + z.enum(audioMimeTypes), + NonTextContentFormatSchema.valueSchema.extend({ + duration: ContentFormatSchema.valueSchema.shape.duration, + }), +); diff --git a/packages/federation/schemas/delete.ts b/packages/federation/schemas/delete.ts new file mode 100644 index 00000000..9cd7d9a8 --- /dev/null +++ b/packages/federation/schemas/delete.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; +import { url } from "./common.ts"; +import { EntitySchema } from "./entity.ts"; + +export const DeleteSchema = EntitySchema.extend({ + uri: z.null().optional(), + type: z.literal("Delete"), + author: url.nullable(), + deleted_type: z.string(), + deleted: url, +}); diff --git a/packages/federation/schemas/entity.ts b/packages/federation/schemas/entity.ts new file mode 100644 index 00000000..d5d1aa2a --- /dev/null +++ b/packages/federation/schemas/entity.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import { isISOString } from "../regex.ts"; +import { url } from "./common.ts"; +import { CustomEmojiExtensionSchema } from "./extensions/emojis.ts"; + +export const ExtensionPropertySchema = z + .object({ + "pub.versia:custom_emojis": + CustomEmojiExtensionSchema.optional().nullable(), + }) + .catchall(z.any()); + +export const EntitySchema = z.strictObject({ + // biome-ignore lint/style/useNamingConvention: + $schema: z.string().url().nullish(), + id: z.string().max(512), + created_at: z + .string() + .refine((v) => isISOString(v), "must be a valid ISO8601 datetime"), + uri: url, + type: z.string(), + extensions: ExtensionPropertySchema.nullish(), +}); diff --git a/packages/federation/schemas/extensions/emojis.ts b/packages/federation/schemas/extensions/emojis.ts new file mode 100644 index 00000000..d075f9b4 --- /dev/null +++ b/packages/federation/schemas/extensions/emojis.ts @@ -0,0 +1,25 @@ +/** + * Custom emojis extension. + * @module federation/schemas/extensions/custom_emojis + * @see module:federation/schemas/base + * @see https://versia.pub/extensions/custom-emojis + */ +import { z } from "zod"; +import { emojiRegex } from "../../regex.ts"; +import { ImageContentFormatSchema } from "../contentformat.ts"; + +export const CustomEmojiExtensionSchema = z.strictObject({ + emojis: z.array( + z.strictObject({ + name: z + .string() + .min(1) + .max(256) + .regex( + emojiRegex, + "Emoji name must be alphanumeric, underscores, or dashes, and surrounded by identifiers", + ), + url: ImageContentFormatSchema, + }), + ), +}); diff --git a/packages/federation/schemas/extensions/groups.ts b/packages/federation/schemas/extensions/groups.ts new file mode 100644 index 00000000..8c246056 --- /dev/null +++ b/packages/federation/schemas/extensions/groups.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import { url } from "../common.ts"; +import { TextContentFormatSchema } from "../contentformat.ts"; +import { EntitySchema } from "../entity.ts"; + +export const GroupSchema = EntitySchema.extend({ + type: z.literal("pub.versia:groups/Group"), + name: TextContentFormatSchema.nullish(), + description: TextContentFormatSchema.nullish(), + open: z.boolean().nullish(), + members: url, + notes: url.nullish(), +}); + +export const GroupSubscribeSchema = EntitySchema.extend({ + type: z.literal("pub.versia:groups/Subscribe"), + uri: z.null().optional(), + subscriber: url, + group: url, +}); + +export const GroupUnsubscribeSchema = EntitySchema.extend({ + type: z.literal("pub.versia:groups/Unsubscribe"), + uri: z.null().optional(), + subscriber: url, + group: url, +}); + +export const GroupSubscribeAcceptSchema = EntitySchema.extend({ + type: z.literal("pub.versia:groups/SubscribeAccept"), + uri: z.null().optional(), + subscriber: url, + group: url, +}); + +export const GroupSubscribeRejectSchema = EntitySchema.extend({ + type: z.literal("pub.versia:groups/SubscribeReject"), + uri: z.null().optional(), + subscriber: url, + group: url, +}); diff --git a/packages/federation/schemas/extensions/likes.ts b/packages/federation/schemas/extensions/likes.ts new file mode 100644 index 00000000..75208fb7 --- /dev/null +++ b/packages/federation/schemas/extensions/likes.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { url } from "../common.ts"; +import { EntitySchema } from "../entity.ts"; + +export const LikeSchema = EntitySchema.extend({ + type: z.literal("pub.versia:likes/Like"), + author: url, + liked: url, +}); + +export const DislikeSchema = EntitySchema.extend({ + type: z.literal("pub.versia:likes/Dislike"), + author: url, + disliked: url, +}); diff --git a/packages/federation/schemas/extensions/migration.ts b/packages/federation/schemas/extensions/migration.ts new file mode 100644 index 00000000..da215ce3 --- /dev/null +++ b/packages/federation/schemas/extensions/migration.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { url } from "../common.ts"; +import { EntitySchema } from "../entity.ts"; + +export const MigrationSchema = EntitySchema.extend({ + type: z.literal("pub.versia:migration/Migration"), + uri: z.null().optional(), + author: url, + destination: url, +}); + +export const MigrationExtensionSchema = z.strictObject({ + previous: url, + new: url.nullish(), +}); diff --git a/packages/federation/schemas/extensions/polls.ts b/packages/federation/schemas/extensions/polls.ts new file mode 100644 index 00000000..ba553a36 --- /dev/null +++ b/packages/federation/schemas/extensions/polls.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; +import { isISOString } from "../../regex.ts"; +import { url, u64 } from "../common.ts"; +import { TextContentFormatSchema } from "../contentformat.ts"; +import { EntitySchema } from "../entity.ts"; + +export const VoteSchema = EntitySchema.extend({ + type: z.literal("pub.versia:polls/Vote"), + author: url, + poll: url, + option: u64, +}); + +export const PollExtensionSchema = z.strictObject({ + options: z.array(TextContentFormatSchema), + votes: z.array(u64), + multiple_choice: z.boolean(), + expires_at: z + .string() + .refine((v) => isISOString(v), "must be a valid ISO8601 datetime") + .nullish(), +}); diff --git a/packages/federation/schemas/extensions/reactions.ts b/packages/federation/schemas/extensions/reactions.ts new file mode 100644 index 00000000..99b7162d --- /dev/null +++ b/packages/federation/schemas/extensions/reactions.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; +import { url } from "../common.ts"; +import { EntitySchema } from "../entity.ts"; + +export const ReactionSchema = EntitySchema.extend({ + type: z.literal("pub.versia:reactions/Reaction"), + author: url, + object: url, + content: z.string().min(1).max(256), +}); diff --git a/packages/federation/schemas/extensions/reports.ts b/packages/federation/schemas/extensions/reports.ts new file mode 100644 index 00000000..c49bc8db --- /dev/null +++ b/packages/federation/schemas/extensions/reports.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { url } from "../common.ts"; +import { EntitySchema } from "../entity.ts"; + +export const ReportSchema = EntitySchema.extend({ + type: z.literal("pub.versia:reports/Report"), + uri: z.null().optional(), + author: url.nullish(), + reported: z.array(url), + tags: z.array(z.string()), + comment: z + .string() + .max(2 ** 16) + .nullish(), +}); diff --git a/packages/federation/schemas/extensions/share.ts b/packages/federation/schemas/extensions/share.ts new file mode 100644 index 00000000..20968a08 --- /dev/null +++ b/packages/federation/schemas/extensions/share.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { url } from "../common.ts"; +import { EntitySchema } from "../entity.ts"; + +export const ShareSchema = EntitySchema.extend({ + type: z.literal("pub.versia:share/Share"), + author: url, + shared: url, +}); diff --git a/packages/federation/schemas/extensions/vanity.ts b/packages/federation/schemas/extensions/vanity.ts new file mode 100644 index 00000000..6d47136a --- /dev/null +++ b/packages/federation/schemas/extensions/vanity.ts @@ -0,0 +1,46 @@ +/** + * Vanity extension schema. + * @module federation/schemas/extensions/vanity + * @see module:federation/schemas/base + * @see https://versia.pub/extensions/vanity + */ + +import { z } from "zod"; +import { ianaTimezoneRegex, isISOString } from "../../regex.ts"; +import { url } from "../common.ts"; +import { + AudioContentFormatSchema, + ImageContentFormatSchema, +} from "../contentformat.ts"; + +export const VanityExtensionSchema = z.strictObject({ + avatar_overlays: z.array(ImageContentFormatSchema).nullish(), + avatar_mask: ImageContentFormatSchema.nullish(), + background: ImageContentFormatSchema.nullish(), + audio: AudioContentFormatSchema.nullish(), + pronouns: z.record( + z.string(), + z.array( + z.union([ + z.strictObject({ + subject: z.string(), + object: z.string(), + dependent_possessive: z.string(), + independent_possessive: z.string(), + reflexive: z.string(), + }), + z.string(), + ]), + ), + ), + birthday: z + .string() + .refine((v) => isISOString(v), "must be a valid ISO8601 datetime") + .nullish(), + location: z.string().nullish(), + aliases: z.array(url).nullish(), + timezone: z + .string() + .regex(ianaTimezoneRegex, "must be a valid IANA timezone") + .nullish(), +}); diff --git a/packages/federation/schemas/follow.ts b/packages/federation/schemas/follow.ts new file mode 100644 index 00000000..62c89c32 --- /dev/null +++ b/packages/federation/schemas/follow.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; +import { url } from "./common.ts"; +import { EntitySchema } from "./entity.ts"; + +export const FollowSchema = EntitySchema.extend({ + type: z.literal("Follow"), + uri: z.null().optional(), + author: url, + followee: url, +}); + +export const FollowAcceptSchema = EntitySchema.extend({ + type: z.literal("FollowAccept"), + uri: z.null().optional(), + author: url, + follower: url, +}); + +export const FollowRejectSchema = EntitySchema.extend({ + type: z.literal("FollowReject"), + uri: z.null().optional(), + author: url, + follower: url, +}); + +export const UnfollowSchema = EntitySchema.extend({ + type: z.literal("Unfollow"), + uri: z.null().optional(), + author: url, + followee: url, +}); diff --git a/packages/federation/schemas/index.ts b/packages/federation/schemas/index.ts new file mode 100644 index 00000000..a7faa8dc --- /dev/null +++ b/packages/federation/schemas/index.ts @@ -0,0 +1,27 @@ +// biome-ignore lint/performance/noBarrelFile: +export { UserSchema } from "./user.ts"; +export { NoteSchema } from "./note.ts"; +export { EntitySchema } from "./entity.ts"; +export { DeleteSchema } from "./delete.ts"; +export { InstanceMetadataSchema } from "./instance.ts"; +export { + ContentFormatSchema, + ImageContentFormatSchema, + AudioContentFormatSchema, + NonTextContentFormatSchema, + TextContentFormatSchema, + VideoContentFormatSchema, +} from "./contentformat.ts"; +export { + FollowSchema, + FollowAcceptSchema, + FollowRejectSchema, + UnfollowSchema, +} from "./follow.ts"; +export { CollectionSchema, URICollectionSchema } from "./collection.ts"; +export { LikeSchema, DislikeSchema } from "./extensions/likes.ts"; +export { VoteSchema } from "./extensions/polls.ts"; +export { ReactionSchema } from "./extensions/reactions.ts"; +export { ReportSchema } from "./extensions/reports.ts"; +export { ShareSchema } from "./extensions/share.ts"; +export { WebFingerSchema } from "./webfinger.ts"; diff --git a/packages/federation/schemas/instance.ts b/packages/federation/schemas/instance.ts new file mode 100644 index 00000000..e62648b5 --- /dev/null +++ b/packages/federation/schemas/instance.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; +import { extensionRegex, semverRegex } from "../regex.ts"; +import { url } from "./common.ts"; +import { ImageContentFormatSchema } from "./contentformat.ts"; +import { EntitySchema } from "./entity.ts"; + +export const InstanceMetadataSchema = EntitySchema.extend({ + type: z.literal("InstanceMetadata"), + id: z.null().optional(), + uri: z.null().optional(), + name: z.string().min(1), + software: z.strictObject({ + name: z.string().min(1), + version: z.string().min(1), + }), + compatibility: z.strictObject({ + versions: z.array( + z.string().regex(semverRegex, "must be a valid SemVer version"), + ), + extensions: z.array( + z + .string() + .min(1) + .regex( + extensionRegex, + "must be in the format 'namespaced_url:extension_name', e.g. 'pub.versia:reactions'", + ), + ), + }), + description: z.string().nullish(), + host: z.string(), + shared_inbox: url.nullish(), + public_key: z.strictObject({ + key: z.string().min(1), + algorithm: z.literal("ed25519"), + }), + moderators: url.nullish(), + admins: url.nullish(), + logo: ImageContentFormatSchema.nullish(), + banner: ImageContentFormatSchema.nullish(), +}); diff --git a/packages/federation/schemas/note.ts b/packages/federation/schemas/note.ts new file mode 100644 index 00000000..9d58beca --- /dev/null +++ b/packages/federation/schemas/note.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; +import { url } from "./common.ts"; +import { + NonTextContentFormatSchema, + TextContentFormatSchema, +} from "./contentformat.ts"; +import { EntitySchema } from "./entity.ts"; +import { PollExtensionSchema } from "./extensions/polls.ts"; + +export const NoteSchema = EntitySchema.extend({ + type: z.literal("Note"), + attachments: z.array(NonTextContentFormatSchema).nullish(), + author: url, + category: z + .enum([ + "microblog", + "forum", + "blog", + "image", + "video", + "audio", + "messaging", + ]) + .nullish(), + content: TextContentFormatSchema.nullish(), + collections: z + .strictObject({ + replies: url, + quotes: url, + "pub.versia:reactions/Reactions": url.nullish(), + "pub.versia:share/Shares": url.nullish(), + "pub.versia:likes/Likes": url.nullish(), + "pub.versia:likes/Dislikes": url.nullish(), + }) + .catchall(url), + device: z + .strictObject({ + name: z.string(), + version: z.string().nullish(), + url: url.nullish(), + }) + .nullish(), + group: url.or(z.enum(["public", "followers"])).nullish(), + is_sensitive: z.boolean().nullish(), + mentions: z.array(url).nullish(), + previews: z + .array( + z.strictObject({ + link: url, + title: z.string(), + description: z.string().nullish(), + image: url.nullish(), + icon: url.nullish(), + }), + ) + .nullish(), + quotes: url.nullish(), + replies_to: url.nullish(), + subject: z.string().nullish(), + extensions: EntitySchema.shape.extensions + .unwrap() + .unwrap() + .extend({ + "pub.versia:polls": PollExtensionSchema.nullish(), + }) + .nullish(), +}); diff --git a/packages/federation/schemas/user.ts b/packages/federation/schemas/user.ts new file mode 100644 index 00000000..aeaf1256 --- /dev/null +++ b/packages/federation/schemas/user.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; +import { url } from "./common.ts"; +import { + ImageContentFormatSchema, + TextContentFormatSchema, +} from "./contentformat.ts"; +import { EntitySchema } from "./entity.ts"; +import { MigrationExtensionSchema } from "./extensions/migration.ts"; +import { VanityExtensionSchema } from "./extensions/vanity.ts"; + +export const PublicKeyDataSchema = z.strictObject({ + key: z.string().min(1), + actor: url, + algorithm: z.literal("ed25519"), +}); + +export const UserSchema = EntitySchema.extend({ + type: z.literal("User"), + avatar: ImageContentFormatSchema.nullish(), + bio: TextContentFormatSchema.nullish(), + display_name: z.string().nullish(), + fields: z + .array( + z.strictObject({ + key: TextContentFormatSchema, + value: TextContentFormatSchema, + }), + ) + .nullish(), + username: z + .string() + .min(1) + .regex( + /^[a-zA-Z0-9_-]+$/, + "must be alphanumeric, and may contain _ or -", + ), + header: ImageContentFormatSchema.nullish(), + public_key: PublicKeyDataSchema, + manually_approves_followers: z.boolean().nullish(), + indexable: z.boolean().nullish(), + inbox: url, + collections: z + .object({ + featured: url, + followers: url, + following: url, + outbox: url, + "pub.versia:likes/Likes": url.nullish(), + "pub.versia:likes/Dislikes": url.nullish(), + }) + .catchall(url), + extensions: EntitySchema.shape.extensions + .unwrap() + .unwrap() + .extend({ + "pub.versia:vanity": VanityExtensionSchema.nullish(), + "pub.versia:migration": MigrationExtensionSchema.nullish(), + }) + .nullish(), +}); diff --git a/packages/federation/schemas/webfinger.ts b/packages/federation/schemas/webfinger.ts new file mode 100644 index 00000000..524d2f00 --- /dev/null +++ b/packages/federation/schemas/webfinger.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; +import { url } from "./common.ts"; + +export const WebFingerSchema = z.object({ + subject: url, + aliases: z.array(url).optional(), + properties: z.record(url, z.string().or(z.null())).optional(), + links: z + .array( + z.object({ + rel: z.string(), + type: z.string().optional(), + href: url.optional(), + titles: z.record(z.string(), z.string()).optional(), + properties: z.record(url, z.string().or(z.null())).optional(), + }), + ) + .optional(), +}); diff --git a/packages/federation/types.ts b/packages/federation/types.ts new file mode 100644 index 00000000..217deada --- /dev/null +++ b/packages/federation/types.ts @@ -0,0 +1,11 @@ +type JSONValue = + | string + | number + | boolean + | null + | JSONValue[] + | { [key: string]: JSONValue }; + +export interface JSONObject { + [k: string]: JSONValue; +} diff --git a/types/api.ts b/types/api.ts index e6cb1a46..38953d73 100644 --- a/types/api.ts +++ b/types/api.ts @@ -1,14 +1,4 @@ -import type { - Delete, - Follow, - FollowAccept, - FollowReject, - InstanceMetadata, - LikeExtension, - Note, - Unfollow, - User, -} from "@versia/federation/types"; +import type * as VersiaEntities from "@versia/sdk/entities"; import type { SocketAddress } from "bun"; import type { Hono } from "hono"; import type { RouterRoute } from "hono/types"; @@ -33,12 +23,12 @@ export interface ApiRouteExports { } export type KnownEntity = - | Note - | InstanceMetadata - | User - | Follow - | FollowAccept - | FollowReject - | Unfollow - | Delete - | LikeExtension; + | VersiaEntities.Note + | VersiaEntities.InstanceMetadata + | VersiaEntities.User + | VersiaEntities.Follow + | VersiaEntities.FollowAccept + | VersiaEntities.FollowReject + | VersiaEntities.Unfollow + | VersiaEntities.Delete + | VersiaEntities.Like; diff --git a/utils/content_types.ts b/utils/content_types.ts index fb95caa3..eebe7791 100644 --- a/utils/content_types.ts +++ b/utils/content_types.ts @@ -1,10 +1,11 @@ -import type { ContentFormat } from "@versia/federation/types"; +import type { ContentFormatSchema } from "@versia/sdk/schemas"; import { htmlToText as htmlToTextLib } from "html-to-text"; import { lookup } from "mime-types"; +import type { z } from "zod"; import { config } from "~/config.ts"; export const getBestContentType = ( - content?: ContentFormat | null, + content?: z.infer | null, ): { content: string; format: string; @@ -32,7 +33,7 @@ export const getBestContentType = ( export const urlToContentFormat = ( url: URL, contentType?: string, -): ContentFormat | null => { +): z.infer | null => { if (url.href.startsWith("https://api.dicebear.com/")) { return { "image/svg+xml": {