From d63861036144ec50b2d28dc1264a0013768aeb11 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Tue, 8 Apr 2025 16:01:10 +0200 Subject: [PATCH 1/6] 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": { From 9ff9b90f6b4e54383550cf95f01a8290328c15b2 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Tue, 8 Apr 2025 16:59:18 +0200 Subject: [PATCH 2/6] refactor(federation): :recycle: Refactor User federation code --- api/api/v1/accounts/index.ts | 3 +- .../v1/accounts/update_credentials/index.ts | 28 +- .../accounts/verify_credentials/index.test.ts | 2 +- classes/database/user.ts | 281 ++++++++---------- classes/search/search-manager.ts | 2 +- cli/user/create.ts | 5 +- drizzle/schema.ts | 4 +- plugins/openid/routes/oauth/callback.ts | 4 +- tests/utils.ts | 3 +- 9 files changed, 152 insertions(+), 180 deletions(-) diff --git a/api/api/v1/accounts/index.ts b/api/api/v1/accounts/index.ts index 31a38585..3eac0054 100644 --- a/api/api/v1/accounts/index.ts +++ b/api/api/v1/accounts/index.ts @@ -353,8 +353,7 @@ export default apiRoute((app) => ); } - await User.fromDataLocal({ - username, + await User.register(username, { password, email, }); diff --git a/api/api/v1/accounts/update_credentials/index.ts b/api/api/v1/accounts/update_credentials/index.ts index 12ac5abd..34ab21c0 100644 --- a/api/api/v1/accounts/update_credentials/index.ts +++ b/api/api/v1/accounts/update_credentials/index.ts @@ -176,6 +176,15 @@ export default apiRoute((app) => } = context.req.valid("json"); const self = user.data; + if (!self.source) { + self.source = { + fields: [], + privacy: "public", + language: "en", + sensitive: false, + note: "", + }; + } const sanitizedDisplayName = await sanitizedHtmlStrip( display_name ?? "", @@ -185,7 +194,7 @@ export default apiRoute((app) => self.displayName = sanitizedDisplayName; } - if (note && self.source) { + if (note) { self.source.note = note; self.note = await contentToHtml( new VersiaEntities.TextContentFormat({ @@ -197,16 +206,13 @@ export default apiRoute((app) => ); } - if (source?.privacy) { - self.source.privacy = source.privacy; - } - - if (source?.sensitive) { - self.source.sensitive = source.sensitive; - } - - if (source?.language) { - self.source.language = source.language; + if (source) { + self.source = { + ...self.source, + privacy: source.privacy ?? self.source.privacy, + sensitive: source.sensitive ?? self.source.sensitive, + language: source.language ?? self.source.language, + }; } if (username) { diff --git a/api/api/v1/accounts/verify_credentials/index.test.ts b/api/api/v1/accounts/verify_credentials/index.test.ts index cadc7498..79b015f4 100644 --- a/api/api/v1/accounts/verify_credentials/index.test.ts +++ b/api/api/v1/accounts/verify_credentials/index.test.ts @@ -28,7 +28,7 @@ describe("/api/v1/accounts/verify_credentials", () => { expect(data.id).toBe(users[0].id); expect(data.username).toBe(users[0].data.username); expect(data.acct).toBe(users[0].data.username); - expect(data.display_name).toBe(users[0].data.displayName); + expect(data.display_name).toBe(users[0].data.displayName ?? ""); expect(data.note).toBe(users[0].data.note); expect(data.url).toBe( new URL( diff --git a/classes/database/user.ts b/classes/database/user.ts index 522d80c4..aacb65d0 100644 --- a/classes/database/user.ts +++ b/classes/database/user.ts @@ -679,120 +679,106 @@ export class User extends BaseInterface { ); } - public static async fromVersia(user: VersiaEntities.User): Promise { - const instance = await Instance.resolve(user.data.uri); - - const data = { - username: user.data.username, - uri: user.data.uri.href, - createdAt: new Date(user.data.created_at).toISOString(), - endpoints: { - dislikes: - 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.data.fields ?? [], - updatedAt: new Date(user.data.created_at).toISOString(), - instanceId: instance.id, - displayName: user.data.display_name ?? "", - note: getBestContentType(user.data.bio).content, - publicKey: user.data.public_key.key, - source: { - language: "en", - note: "", - privacy: "public", - sensitive: false, - fields: [], - } as z.infer, - }; - - const userEmojis = - user.data.extensions?.["pub.versia:custom_emojis"]?.emojis ?? []; - - const emojis = await Promise.all( - userEmojis.map((emoji) => Emoji.fromVersia(emoji, instance)), + /** + * Takes a Versia User representation, and serializes it to the database. + * + * If the user already exists, it will update it. + * @param user + */ + public static async fromVersia( + versiaUser: VersiaEntities.User, + ): Promise { + const { + username, + inbox, + avatar, + header, + display_name, + fields, + collections, + created_at, + bio, + public_key, + uri, + extensions, + } = versiaUser.data; + const instance = await Instance.resolve(versiaUser.data.uri); + const existingUser = await User.fromSql( + eq(Users.uri, versiaUser.data.uri.href), ); - // Check if new user already exists - const foundUser = await User.fromSql(eq(Users.uri, user.data.uri.href)); + const user = + existingUser ?? + (await User.insert({ + username, + id: randomUUIDv7(), + publicKey: public_key.key, + uri: uri.href, + instanceId: instance.id, + })); - // If it exists, simply update it - if (foundUser) { - let avatar: Media | null = null; - let header: Media | null = null; + // Avatars and headers are stored in a separate table, so we need to update them separately + let userAvatar: Media | null = null; + let userHeader: Media | null = null; - if (user.data.avatar) { - if (foundUser.avatar) { - avatar = new Media( - await foundUser.avatar.update({ - content: user.data.avatar, - }), - ); - } else { - avatar = await Media.insert({ - id: randomUUIDv7(), - content: user.data.avatar, - }); - } + if (avatar) { + if (user.avatar) { + userAvatar = new Media( + await user.avatar.update({ + content: avatar, + }), + ); + } else { + userAvatar = await Media.insert({ + id: randomUUIDv7(), + content: avatar, + }); } - - if (user.data.header) { - if (foundUser.header) { - header = new Media( - await foundUser.header.update({ - content: user.data.header, - }), - ); - } else { - header = await Media.insert({ - id: randomUUIDv7(), - content: user.data.header, - }); - } - } - - await foundUser.update({ - ...data, - avatarId: avatar?.id, - headerId: header?.id, - }); - await foundUser.updateEmojis(emojis); - - return foundUser; } - // Else, create a new user - const avatar = user.data.avatar - ? await Media.insert({ - id: randomUUIDv7(), - content: user.data.avatar, - }) - : null; + if (header) { + if (user.header) { + userHeader = new Media( + await user.header.update({ + content: header, + }), + ); + } else { + userHeader = await Media.insert({ + id: randomUUIDv7(), + content: header, + }); + } + } - const header = user.data.header - ? await Media.insert({ - id: randomUUIDv7(), - content: user.data.header, - }) - : null; - - const newUser = await User.insert({ - id: randomUUIDv7(), - ...data, - avatarId: avatar?.id, - headerId: header?.id, + await user.update({ + createdAt: new Date(created_at).toISOString(), + endpoints: { + inbox: inbox.href, + outbox: collections.outbox.href, + followers: collections.followers.href, + following: collections.following.href, + featured: collections.featured.href, + likes: collections["pub.versia:likes/Likes"]?.href, + dislikes: collections["pub.versia:likes/Dislikes"]?.href, + }, + avatarId: userAvatar?.id, + headerId: userHeader?.id, + fields: fields ?? [], + displayName: display_name, + note: getBestContentType(bio).content, }); - await newUser.updateEmojis(emojis); - return newUser; + // Emojis are stored in a separate table, so we need to update them separately + const emojis = await Promise.all( + extensions?.["pub.versia:custom_emojis"]?.emojis.map((e) => + Emoji.fromVersia(e, instance), + ) ?? [], + ); + + await user.updateEmojis(emojis); + + return user; } public static async insert( @@ -879,60 +865,45 @@ export class User extends BaseInterface { }; } - public static async fromDataLocal(data: { - username: string; - display_name?: string; - password: string | undefined; - email: string | undefined; - bio?: string; - avatar?: Media; - header?: Media; - admin?: boolean; - skipPasswordHash?: boolean; - }): Promise { + public static async register( + username: string, + options?: Partial<{ + email: string; + password: string; + avatar: Media; + isAdmin: boolean; + }>, + ): Promise { const keys = await User.generateKeys(); - const newUser = ( - await db - .insert(Users) - .values({ - id: randomUUIDv7(), - username: data.username, - displayName: data.display_name ?? data.username, - password: - data.skipPasswordHash || !data.password - ? data.password - : await bunPassword.hash(data.password), - email: data.email, - note: data.bio ?? "", - avatarId: data.avatar?.id, - headerId: data.header?.id, - isAdmin: data.admin ?? false, - publicKey: keys.public_key, - fields: [], - privateKey: keys.private_key, - updatedAt: new Date().toISOString(), - source: { - language: "en", - note: "", - privacy: "public", - sensitive: false, - fields: [], - } as z.infer, - }) - .returning() - )[0]; - - const finalUser = await User.fromId(newUser.id); - - if (!finalUser) { - throw new Error("Failed to create user"); - } + const user = await User.insert({ + id: randomUUIDv7(), + username: username, + displayName: username, + password: options?.password + ? await bunPassword.hash(options.password) + : null, + email: options?.email, + note: "", + avatarId: options?.avatar?.id, + isAdmin: options?.isAdmin, + publicKey: keys.public_key, + fields: [], + privateKey: keys.private_key, + updatedAt: new Date().toISOString(), + source: { + language: "en", + note: "", + privacy: "public", + sensitive: false, + fields: [], + } as z.infer, + }); // Add to search index - await searchManager.addUser(finalUser); + await searchManager.addUser(user); - return finalUser; + return user; } /** @@ -1093,7 +1064,7 @@ export class User extends BaseInterface { return { id: user.id, username: user.username, - display_name: user.displayName, + display_name: user.displayName || user.username, note: user.note, uri: this.getUri().toString(), url: @@ -1119,7 +1090,7 @@ export class User extends BaseInterface { verified_at: null, })), bot: user.isBot, - source: isOwnAccount ? user.source : undefined, + source: isOwnAccount ? (user.source ?? undefined) : undefined, // TODO: Add static avatar and header avatar_static: this.getAvatarUrl().proxied, header_static: this.getHeaderUrl()?.proxied ?? "", diff --git a/classes/search/search-manager.ts b/classes/search/search-manager.ts index 25689381..db97f006 100644 --- a/classes/search/search-manager.ts +++ b/classes/search/search-manager.ts @@ -155,7 +155,7 @@ export class SonicSearchManager { private static getNthDatabaseAccountBatch( n: number, batchSize = 1000, - ): Promise[]> { + ): Promise[]> { return db.query.Users.findMany({ offset: n * batchSize, limit: batchSize, diff --git a/cli/user/create.ts b/cli/user/create.ts index 4d840b25..6a5fd696 100644 --- a/cli/user/create.ts +++ b/cli/user/create.ts @@ -48,11 +48,10 @@ export const createUserCommand = defineCommand( throw new Error(`User ${chalk.gray(username)} is taken.`); } - const user = await User.fromDataLocal({ + const user = await User.register(username, { email, password, - username, - admin, + isAdmin: admin, }); if (!user) { diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 9a18f7b9..4574e567 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -556,7 +556,7 @@ export const Users = pgTable( id: id(), uri: uri(), username: text("username").notNull(), - displayName: text("display_name").notNull(), + displayName: text("display_name"), password: text("password"), email: text("email"), note: text("note").default("").notNull(), @@ -578,7 +578,7 @@ export const Users = pgTable( inbox: string; outbox: string; }> | null>(), - source: jsonb("source").notNull().$type>(), + source: jsonb("source").$type>(), avatarId: uuid("avatarId").references(() => Medias.id, { onDelete: "set null", onUpdate: "cascade", diff --git a/plugins/openid/routes/oauth/callback.ts b/plugins/openid/routes/oauth/callback.ts index 2f7c6bff..23207ca0 100644 --- a/plugins/openid/routes/oauth/callback.ts +++ b/plugins/openid/routes/oauth/callback.ts @@ -235,11 +235,9 @@ export default (plugin: PluginType): void => { : null; // Create new user - const user = await User.fromDataLocal({ + const user = await User.register(username, { email: doesEmailExist ? undefined : email, - username, avatar: avatar ?? undefined, - password: undefined, }); // Link account diff --git a/tests/utils.ts b/tests/utils.ts index 508d92d1..3058d501 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -103,8 +103,7 @@ export const getTestUsers = async ( for (let i = 0; i < count; i++) { const password = randomString(32, "hex"); - const user = await User.fromDataLocal({ - username: `test-${randomString(8, "hex")}`, + const user = await User.register(`test-${randomString(8, "hex")}`, { email: `${randomString(16, "hex")}@test.com`, password, }); From 54b2dfb78dfaca9f728bb3efe20d080c7a45ad12 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Tue, 8 Apr 2025 17:27:08 +0200 Subject: [PATCH 3/6] refactor(federation): :fire: Remove confusing User federation methods --- api/api/v1/accounts/[id]/refetch.ts | 3 +- .../follow_requests/[account_id]/authorize.ts | 2 +- .../v1/follow_requests/[account_id]/reject.ts | 2 +- api/api/v1/statuses/index.test.ts | 4 +- api/notes/[uuid]/quotes.ts | 2 +- api/notes/[uuid]/replies.ts | 2 +- api/users/[uuid]/outbox/index.ts | 2 +- classes/database/note.ts | 4 +- classes/database/user.ts | 138 ++++++++---------- classes/functions/status.ts | 2 +- classes/inbox/processor.ts | 2 +- cli/user/refetch.ts | 3 +- 12 files changed, 72 insertions(+), 94 deletions(-) diff --git a/api/api/v1/accounts/[id]/refetch.ts b/api/api/v1/accounts/[id]/refetch.ts index 954e4577..c089c8a4 100644 --- a/api/api/v1/accounts/[id]/refetch.ts +++ b/api/api/v1/accounts/[id]/refetch.ts @@ -3,6 +3,7 @@ import { Account as AccountSchema } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas"; import { describeRoute } from "hono-openapi"; import { resolver } from "hono-openapi/zod"; +import { User } from "~/classes/database/user"; import { ApiError } from "~/classes/errors/api-error"; export default apiRoute((app) => @@ -48,7 +49,7 @@ export default apiRoute((app) => throw new ApiError(400, "Cannot refetch a local user"); } - const newUser = await otherUser.updateFromRemote(); + const newUser = await User.fromVersia(otherUser.uri); return context.json(newUser.toApi(false), 200); }, diff --git a/api/api/v1/follow_requests/[account_id]/authorize.ts b/api/api/v1/follow_requests/[account_id]/authorize.ts index 901e261c..70acad50 100644 --- a/api/api/v1/follow_requests/[account_id]/authorize.ts +++ b/api/api/v1/follow_requests/[account_id]/authorize.ts @@ -73,7 +73,7 @@ export default apiRoute((app) => // Check if accepting remote follow if (account.isRemote()) { // Federate follow accept - await user.sendFollowAccept(account); + await user.acceptFollowRequest(account); } return context.json(foundRelationship.toApi(), 200); diff --git a/api/api/v1/follow_requests/[account_id]/reject.ts b/api/api/v1/follow_requests/[account_id]/reject.ts index 1ee1b858..a1d8354e 100644 --- a/api/api/v1/follow_requests/[account_id]/reject.ts +++ b/api/api/v1/follow_requests/[account_id]/reject.ts @@ -74,7 +74,7 @@ export default apiRoute((app) => // Check if rejecting remote follow if (account.isRemote()) { // Federate follow reject - await user.sendFollowReject(account); + await user.rejectFollowRequest(account); } return context.json(foundRelationship.toApi(), 200); diff --git a/api/api/v1/statuses/index.test.ts b/api/api/v1/statuses/index.test.ts index 6f6cc6e2..d2571b9e 100644 --- a/api/api/v1/statuses/index.test.ts +++ b/api/api/v1/statuses/index.test.ts @@ -218,7 +218,7 @@ describe("/api/v1/statuses", () => { expect(ok).toBe(true); expect(data).toMatchObject({ - content: `

Hello, @${users[1].data.username}!

`, + content: `

Hello, @${users[1].data.username}!

`, }); expect((data as z.infer).mentions).toBeArrayOfSize( 1, @@ -241,7 +241,7 @@ describe("/api/v1/statuses", () => { expect(ok).toBe(true); expect(data).toMatchObject({ - content: `

Hello, @${users[1].data.username}!

`, + content: `

Hello, @${users[1].data.username}!

`, }); expect((data as z.infer).mentions).toBeArrayOfSize( 1, diff --git a/api/notes/[uuid]/quotes.ts b/api/notes/[uuid]/quotes.ts index f62e0bfb..1ebfaa36 100644 --- a/api/notes/[uuid]/quotes.ts +++ b/api/notes/[uuid]/quotes.ts @@ -89,7 +89,7 @@ export default apiRoute((app) => ); const uriCollection = new VersiaEntities.URICollection({ - author: note.author.getUri(), + author: note.author.uri, first: new URL( `/notes/${note.id}/quotes?offset=0`, config.http.base_url, diff --git a/api/notes/[uuid]/replies.ts b/api/notes/[uuid]/replies.ts index 4499d61b..e95b49e7 100644 --- a/api/notes/[uuid]/replies.ts +++ b/api/notes/[uuid]/replies.ts @@ -87,7 +87,7 @@ export default apiRoute((app) => ); const uriCollection = new VersiaEntities.URICollection({ - author: note.author.getUri(), + author: note.author.uri, first: new URL( `/notes/${note.id}/replies?offset=0`, config.http.base_url, diff --git a/api/users/[uuid]/outbox/index.ts b/api/users/[uuid]/outbox/index.ts index 9f2dee9b..50ae6925 100644 --- a/api/users/[uuid]/outbox/index.ts +++ b/api/users/[uuid]/outbox/index.ts @@ -106,7 +106,7 @@ export default apiRoute((app) => config.http.base_url, ), total: totalNotes, - author: author.getUri(), + author: author.uri, next: notes.length === NOTES_PER_PAGE ? new URL( diff --git a/classes/database/note.ts b/classes/database/note.ts index 6b2b418a..f83cbacf 100644 --- a/classes/database/note.ts +++ b/classes/database/note.ts @@ -861,7 +861,7 @@ export class Note extends BaseInterface { return new VersiaEntities.Delete({ type: "Delete", id, - author: this.author.getUri(), + author: this.author.uri, deleted_type: "Note", deleted: this.getUri(), created_at: new Date().toISOString(), @@ -878,7 +878,7 @@ export class Note extends BaseInterface { type: "Note", created_at: new Date(status.createdAt).toISOString(), id: status.id, - author: this.author.getUri(), + author: this.author.uri, uri: this.getUri(), content: { "text/html": { diff --git a/classes/database/user.ts b/classes/database/user.ts index aacb65d0..8b75b612 100644 --- a/classes/database/user.ts +++ b/classes/database/user.ts @@ -156,7 +156,7 @@ export class User extends BaseInterface { return !this.isLocal(); } - public getUri(): URL { + public get uri(): URL { return this.data.uri ? new URL(this.data.uri) : new URL(`/users/${this.data.id}`, config.http.base_url); @@ -208,8 +208,8 @@ export class User extends BaseInterface { entity: { type: "Follow", id: crypto.randomUUID(), - author: this.getUri().toString(), - followee: otherUser.getUri().toString(), + author: this.uri.href, + followee: otherUser.uri.href, created_at: new Date().toISOString(), }, recipientId: otherUser.id, @@ -247,13 +247,13 @@ export class User extends BaseInterface { return new VersiaEntities.Unfollow({ type: "Unfollow", id, - author: this.getUri(), + author: this.uri, created_at: new Date().toISOString(), - followee: followee.getUri(), + followee: followee.uri, }); } - public async sendFollowAccept(follower: User): Promise { + public async acceptFollowRequest(follower: User): Promise { if (!follower.isRemote()) { throw new Error("Follower must be a remote user"); } @@ -265,9 +265,9 @@ export class User extends BaseInterface { const entity = new VersiaEntities.FollowAccept({ type: "FollowAccept", id: crypto.randomUUID(), - author: this.getUri(), + author: this.uri, created_at: new Date().toISOString(), - follower: follower.getUri(), + follower: follower.uri, }); await deliveryQueue.add(DeliveryJobType.FederateEntity, { @@ -277,7 +277,7 @@ export class User extends BaseInterface { }); } - public async sendFollowReject(follower: User): Promise { + public async rejectFollowRequest(follower: User): Promise { if (!follower.isRemote()) { throw new Error("Follower must be a remote user"); } @@ -289,9 +289,9 @@ export class User extends BaseInterface { const entity = new VersiaEntities.FollowReject({ type: "FollowReject", id: crypto.randomUUID(), - author: this.getUri(), + author: this.uri, created_at: new Date().toISOString(), - follower: follower.getUri(), + follower: follower.uri, }); await deliveryQueue.add(DeliveryJobType.FederateEntity, { @@ -326,7 +326,7 @@ export class User extends BaseInterface { const { headers } = await sign( privateKey, - this.getUri(), + this.uri, new Request(signatureUrl, { method: signatureMethod, body: JSON.stringify(entity), @@ -600,66 +600,6 @@ export class User extends BaseInterface { ); } - public async updateFromRemote(): Promise { - if (!this.isRemote()) { - throw new Error( - "Cannot refetch a local user (they are not remote)", - ); - } - - const updated = await User.fetchFromRemote(this.getUri()); - - if (!updated) { - throw new Error("Failed to update user from remote"); - } - - this.data = updated.data; - - return this; - } - - public static async fetchFromRemote(uri: URL): Promise { - const instance = await Instance.resolve(uri); - - if (!instance) { - return null; - } - - if (instance.data.protocol === "versia") { - return await User.saveFromVersia(uri); - } - - if (instance.data.protocol === "activitypub") { - if (!config.federation.bridge) { - throw new Error("ActivityPub bridge is not enabled"); - } - - const bridgeUri = new URL( - `/apbridge/versia/query?${new URLSearchParams({ - user_url: uri.toString(), - })}`, - config.federation.bridge.url, - ); - - return await User.saveFromVersia(bridgeUri); - } - - throw new Error(`Unsupported protocol: ${instance.data.protocol}`); - } - - private static async saveFromVersia(uri: URL): Promise { - const userData = await User.federationRequester.fetchEntity( - uri, - VersiaEntities.User, - ); - - const user = await User.fromVersia(userData); - - await searchManager.addUser(user); - - return user; - } - /** * Change the emojis linked to this user in database * @param emojis @@ -679,6 +619,13 @@ export class User extends BaseInterface { ); } + /** + * Tries to fetch a Versia user from the given URL. + * + * @param url The URL to fetch the user from + */ + public static async fromVersia(url: URL): Promise; + /** * Takes a Versia User representation, and serializes it to the database. * @@ -687,7 +634,36 @@ export class User extends BaseInterface { */ public static async fromVersia( versiaUser: VersiaEntities.User, + ): Promise; + + public static async fromVersia( + versiaUser: VersiaEntities.User | URL, ): Promise { + if (versiaUser instanceof URL) { + let uri = versiaUser; + const instance = await Instance.resolve(uri); + + if (instance.data.protocol === "activitypub") { + if (!config.federation.bridge) { + throw new Error("ActivityPub bridge is not enabled"); + } + + uri = new URL( + `/apbridge/versia/query?${new URLSearchParams({ + user_url: uri.href, + })}`, + config.federation.bridge.url, + ); + } + + const user = await User.federationRequester.fetchEntity( + uri, + VersiaEntities.User, + ); + + return User.fromVersia(user); + } + const { username, inbox, @@ -799,7 +775,7 @@ export class User extends BaseInterface { getLogger(["federation", "resolvers"]) .debug`Resolving user ${chalk.gray(uri)}`; // Check if user not already in database - const foundUser = await User.fromSql(eq(Users.uri, uri.toString())); + const foundUser = await User.fromSql(eq(Users.uri, uri.href)); if (foundUser) { return foundUser; @@ -821,7 +797,7 @@ export class User extends BaseInterface { getLogger(["federation", "resolvers"]) .debug`User not found in database, fetching from remote`; - return await User.fetchFromRemote(uri); + return User.fromVersia(uri); } /** @@ -983,7 +959,7 @@ export class User extends BaseInterface { ["sign"], ) .then((k) => { - return new FederationRequester(k, this.getUri()); + return new FederationRequester(k, this.uri); }); } @@ -1037,7 +1013,7 @@ export class User extends BaseInterface { if (!inbox) { throw new Error( - `User ${chalk.gray(user.getUri())} does not have an inbox endpoint`, + `User ${chalk.gray(user.uri)} does not have an inbox endpoint`, ); } @@ -1048,7 +1024,7 @@ export class User extends BaseInterface { ); } catch (e) { getLogger(["federation", "delivery"]) - .error`Federating ${chalk.gray(entity.data.type)} to ${user.getUri()} ${chalk.bold.red("failed")}`; + .error`Federating ${chalk.gray(entity.data.type)} to ${user.uri} ${chalk.bold.red("failed")}`; getLogger(["federation", "delivery"]).error`${e}`; sentry?.captureException(e); @@ -1066,10 +1042,10 @@ export class User extends BaseInterface { username: user.username, display_name: user.displayName || user.username, note: user.note, - uri: this.getUri().toString(), + uri: this.uri.href, url: user.uri || - new URL(`/@${user.username}`, config.http.base_url).toString(), + new URL(`/@${user.username}`, config.http.base_url).href, avatar: this.getAvatarUrl().proxied, header: this.getHeaderUrl()?.proxied ?? "", locked: user.isLocked, @@ -1123,7 +1099,7 @@ export class User extends BaseInterface { return new VersiaEntities.User({ id: user.id, type: "User", - uri: this.getUri(), + uri: this.uri, bio: { "text/html": { content: user.note, @@ -1190,7 +1166,7 @@ export class User extends BaseInterface { public toMention(): z.infer { return { - url: this.getUri().toString(), + url: this.uri.href, username: this.data.username, acct: this.getAcct(), id: this.id, diff --git a/classes/functions/status.ts b/classes/functions/status.ts index 87d5dd21..42efcf0f 100644 --- a/classes/functions/status.ts +++ b/classes/functions/status.ts @@ -296,7 +296,7 @@ export const parseTextMentions = async ( export const replaceTextMentions = (text: string, mentions: User[]): string => { return mentions.reduce((finalText, mention) => { const { username, instance } = mention.data; - const uri = mention.getUri(); + const { uri } = mention; const baseHost = config.http.base_url.host; const linkTemplate = (displayText: string): string => `${displayText}`; diff --git a/classes/inbox/processor.ts b/classes/inbox/processor.ts index 79e826b0..7f74da3c 100644 --- a/classes/inbox/processor.ts +++ b/classes/inbox/processor.ts @@ -254,7 +254,7 @@ export class InboxProcessor { ); if (!followee.data.isLocked) { - await followee.sendFollowAccept(author); + await followee.acceptFollowRequest(author); } } diff --git a/cli/user/refetch.ts b/cli/user/refetch.ts index c6ff0f9d..43d782fc 100644 --- a/cli/user/refetch.ts +++ b/cli/user/refetch.ts @@ -3,6 +3,7 @@ import chalk from "chalk"; // biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work import { type Root, defineCommand } from "clerc"; import ora from "ora"; +import { User } from "~/classes/database/user.ts"; import { retrieveUser } from "../utils.ts"; export const refetchUserCommand = defineCommand( @@ -29,7 +30,7 @@ export const refetchUserCommand = defineCommand( const spinner = ora("Refetching user").start(); try { - await user.updateFromRemote(); + await User.fromVersia(user.uri); } catch (error) { spinner.fail( `Failed to refetch user ${chalk.gray(user.data.username)}`, From f79b0bc999b4da6f86d57093f1bff4890ffbcab6 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Tue, 8 Apr 2025 18:13:30 +0200 Subject: [PATCH 4/6] refactor(federation): :fire: Refactor Note federation and creation code --- api/api/v1/accounts/[id]/refetch.ts | 2 +- .../follow_requests/[account_id]/authorize.ts | 2 +- .../v1/follow_requests/[account_id]/reject.ts | 2 +- api/api/v1/statuses/[id]/index.ts | 58 ++- api/api/v1/statuses/[id]/reblog.ts | 2 +- api/api/v1/statuses/index.ts | 63 ++- api/likes/[uuid]/index.ts | 2 +- api/notes/[uuid]/index.ts | 5 +- api/notes/[uuid]/quotes.ts | 5 +- api/notes/[uuid]/replies.ts | 5 +- api/users/[uuid]/index.ts | 2 +- api/users/[uuid]/outbox/index.ts | 2 +- classes/database/note.ts | 395 +++++------------- classes/database/reaction.ts | 6 +- classes/database/user.ts | 38 +- classes/functions/status.ts | 2 +- classes/queues/inbox.ts | 2 +- cli/user/refetch.ts | 2 +- drizzle/schema.ts | 2 +- 19 files changed, 243 insertions(+), 354 deletions(-) diff --git a/api/api/v1/accounts/[id]/refetch.ts b/api/api/v1/accounts/[id]/refetch.ts index c089c8a4..d096d53e 100644 --- a/api/api/v1/accounts/[id]/refetch.ts +++ b/api/api/v1/accounts/[id]/refetch.ts @@ -45,7 +45,7 @@ export default apiRoute((app) => async (context) => { const otherUser = context.get("user"); - if (otherUser.isLocal()) { + if (otherUser.local) { throw new ApiError(400, "Cannot refetch a local user"); } diff --git a/api/api/v1/follow_requests/[account_id]/authorize.ts b/api/api/v1/follow_requests/[account_id]/authorize.ts index 70acad50..29abd259 100644 --- a/api/api/v1/follow_requests/[account_id]/authorize.ts +++ b/api/api/v1/follow_requests/[account_id]/authorize.ts @@ -71,7 +71,7 @@ export default apiRoute((app) => ); // Check if accepting remote follow - if (account.isRemote()) { + if (account.remote) { // Federate follow accept await user.acceptFollowRequest(account); } diff --git a/api/api/v1/follow_requests/[account_id]/reject.ts b/api/api/v1/follow_requests/[account_id]/reject.ts index a1d8354e..a7e0bf7d 100644 --- a/api/api/v1/follow_requests/[account_id]/reject.ts +++ b/api/api/v1/follow_requests/[account_id]/reject.ts @@ -72,7 +72,7 @@ export default apiRoute((app) => ); // Check if rejecting remote follow - if (account.isRemote()) { + if (account.remote) { // Federate follow reject await user.rejectFollowRequest(account); } diff --git a/api/api/v1/statuses/[id]/index.ts b/api/api/v1/statuses/[id]/index.ts index ae699bb6..229a1b2c 100644 --- a/api/api/v1/statuses/[id]/index.ts +++ b/api/api/v1/statuses/[id]/index.ts @@ -5,6 +5,7 @@ import { jsonOrForm, withNoteParam, } from "@/api"; +import { sanitizedHtmlStrip } from "@/sanitization"; import { Attachment as AttachmentSchema, PollOption, @@ -13,12 +14,13 @@ import { zBoolean, } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas"; -import { Media } from "@versia/kit/db"; +import { Emoji, 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"; import { ApiError } from "~/classes/errors/api-error"; +import { contentToHtml, parseTextMentions } from "~/classes/functions/status"; import { config } from "~/config.ts"; const schema = z @@ -226,22 +228,50 @@ export default apiRoute((app) => { ); } - const newNote = await note.updateFromData({ - author: user, - content: statusText - ? new VersiaEntities.TextContentFormat({ - [content_type]: { - content: statusText, - remote: false, - }, - }) + const sanitizedSpoilerText = spoiler_text + ? await sanitizedHtmlStrip(spoiler_text) + : undefined; + + const content = statusText + ? new VersiaEntities.TextContentFormat({ + [content_type]: { + content: statusText, + remote: false, + }, + }) + : undefined; + + const parsedMentions = statusText + ? await parseTextMentions(statusText, user) + : []; + + const parsedEmojis = statusText + ? await Emoji.parseFromText(statusText) + : []; + + await note.update({ + spoilerText: sanitizedSpoilerText, + sensitive, + content: content + ? await contentToHtml(content, parsedMentions) : undefined, - isSensitive: sensitive, - spoilerText: spoiler_text, - mediaAttachments: foundAttachments, }); - return context.json(await newNote.toApi(user), 200); + // Emojis, mentions, and attachments are stored in a different table, so update them there too + await note.updateEmojis(parsedEmojis); + await note.updateMentions(parsedMentions); + await note.updateAttachments(foundAttachments); + + await note.reload(); + + // Send notifications for mentioned local users + for (const mentioned of parsedMentions) { + if (mentioned.local) { + await mentioned.notify("mention", user, note); + } + } + + return context.json(await note.toApi(user), 200); }, ); }); diff --git a/api/api/v1/statuses/[id]/reblog.ts b/api/api/v1/statuses/[id]/reblog.ts index 433419e5..45253d0a 100644 --- a/api/api/v1/statuses/[id]/reblog.ts +++ b/api/api/v1/statuses/[id]/reblog.ts @@ -83,7 +83,7 @@ export default apiRoute((app) => throw new Error("Failed to reblog"); } - if (note.author.isLocal() && user.isLocal()) { + if (note.author.local && user.local) { await note.author.notify("reblog", user, newReblog); } diff --git a/api/api/v1/statuses/index.ts b/api/api/v1/statuses/index.ts index 8ea0180a..8669df4a 100644 --- a/api/api/v1/statuses/index.ts +++ b/api/api/v1/statuses/index.ts @@ -1,4 +1,5 @@ import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api"; +import { sanitizedHtmlStrip } from "@/sanitization"; import { Attachment as AttachmentSchema, PollOption, @@ -7,12 +8,14 @@ import { zBoolean, } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas"; -import { Media, Note } from "@versia/kit/db"; +import { Emoji, Media, Note } from "@versia/kit/db"; import * as VersiaEntities from "@versia/sdk/entities"; +import { randomUUIDv7 } from "bun"; import { describeRoute } from "hono-openapi"; import { resolver, validator } from "hono-openapi/zod"; import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; +import { contentToHtml, parseTextMentions } from "~/classes/functions/status"; import { config } from "~/config.ts"; const schema = z @@ -175,27 +178,59 @@ export default apiRoute((app) => ); } - const newNote = await Note.fromData({ - author: user, - content: new VersiaEntities.TextContentFormat({ - [content_type]: { - content: status ?? "", - remote: false, - }, - }), + const sanitizedSpoilerText = spoiler_text + ? await sanitizedHtmlStrip(spoiler_text) + : undefined; + + const content = status + ? new VersiaEntities.TextContentFormat({ + [content_type]: { + content: status, + remote: false, + }, + }) + : undefined; + + const parsedMentions = status + ? await parseTextMentions(status, user) + : []; + + const parsedEmojis = status + ? await Emoji.parseFromText(status) + : []; + + const newNote = await Note.insert({ + id: randomUUIDv7(), + authorId: user.id, visibility, - isSensitive: sensitive ?? false, - spoilerText: spoiler_text ?? "", - mediaAttachments: foundAttachments, + content: content + ? await contentToHtml(content, parsedMentions) + : undefined, + sensitive, + spoilerText: sanitizedSpoilerText, replyId: in_reply_to_id ?? undefined, - quoteId: quote_id ?? undefined, - application: application ?? undefined, + quotingId: quote_id ?? undefined, + applicationId: application?.id, }); + // Emojis, mentions, and attachments are stored in a different table, so update them there too + await newNote.updateEmojis(parsedEmojis); + await newNote.updateMentions(parsedMentions); + await newNote.updateAttachments(foundAttachments); + + await newNote.reload(); + if (!local_only) { await newNote.federateToUsers(); } + // Send notifications for mentioned local users + for (const mentioned of parsedMentions) { + if (mentioned.local) { + await mentioned.notify("mention", user, newNote); + } + } + return context.json(await newNote.toApi(user), 200); }, ), diff --git a/api/likes/[uuid]/index.ts b/api/likes/[uuid]/index.ts index 9e4df8f2..2527f6bf 100644 --- a/api/likes/[uuid]/index.ts +++ b/api/likes/[uuid]/index.ts @@ -59,7 +59,7 @@ export default apiRoute((app) => const liker = await User.fromId(like.data.likerId); - if (!liker || liker.isRemote()) { + if (!liker || liker.remote) { throw ApiError.accountNotFound(); } diff --git a/api/notes/[uuid]/index.ts b/api/notes/[uuid]/index.ts index b8d2f229..c680fc70 100644 --- a/api/notes/[uuid]/index.ts +++ b/api/notes/[uuid]/index.ts @@ -53,10 +53,7 @@ export default apiRoute((app) => ), ); - if ( - !(note && (await note.isViewableByUser(null))) || - note.isRemote() - ) { + if (!(note && (await note.isViewableByUser(null))) || note.remote) { throw ApiError.noteNotFound(); } diff --git a/api/notes/[uuid]/quotes.ts b/api/notes/[uuid]/quotes.ts index 1ebfaa36..cd896e51 100644 --- a/api/notes/[uuid]/quotes.ts +++ b/api/notes/[uuid]/quotes.ts @@ -63,10 +63,7 @@ export default apiRoute((app) => ), ); - if ( - !(note && (await note.isViewableByUser(null))) || - note.isRemote() - ) { + if (!(note && (await note.isViewableByUser(null))) || note.remote) { throw ApiError.noteNotFound(); } diff --git a/api/notes/[uuid]/replies.ts b/api/notes/[uuid]/replies.ts index e95b49e7..f2243ed5 100644 --- a/api/notes/[uuid]/replies.ts +++ b/api/notes/[uuid]/replies.ts @@ -61,10 +61,7 @@ export default apiRoute((app) => ), ); - if ( - !(note && (await note.isViewableByUser(null))) || - note.isRemote() - ) { + if (!(note && (await note.isViewableByUser(null))) || note.remote) { throw ApiError.noteNotFound(); } diff --git a/api/users/[uuid]/index.ts b/api/users/[uuid]/index.ts index 1dfdb3f9..63182daf 100644 --- a/api/users/[uuid]/index.ts +++ b/api/users/[uuid]/index.ts @@ -53,7 +53,7 @@ export default apiRoute((app) => throw ApiError.accountNotFound(); } - if (user.isRemote()) { + if (user.remote) { throw new ApiError(403, "User is not on this instance"); } diff --git a/api/users/[uuid]/outbox/index.ts b/api/users/[uuid]/outbox/index.ts index 50ae6925..a72e31ca 100644 --- a/api/users/[uuid]/outbox/index.ts +++ b/api/users/[uuid]/outbox/index.ts @@ -70,7 +70,7 @@ export default apiRoute((app) => throw new ApiError(404, "User not found"); } - if (author.isRemote()) { + if (author.remote) { throw new ApiError(403, "User is not on this instance"); } diff --git a/classes/database/note.ts b/classes/database/note.ts index f83cbacf..1aff1277 100644 --- a/classes/database/note.ts +++ b/classes/database/note.ts @@ -1,9 +1,7 @@ import { idValidator } from "@/api"; import { mergeAndDeduplicate } from "@/lib.ts"; import { sanitizedHtmlStrip } from "@/sanitization"; -import { sentry } from "@/sentry"; -import { getLogger } from "@logtape/logtape"; -import type { Status, Status as StatusSchema } from "@versia/client/schemas"; +import type { Status } from "@versia/client/schemas"; import { Instance, db } from "@versia/kit/db"; import { EmojiToNote, @@ -28,11 +26,7 @@ import { import { htmlToText } from "html-to-text"; import { createRegExp, exactly, global } from "magic-regexp"; import type { z } from "zod"; -import { - contentToHtml, - findManyNotes, - parseTextMentions, -} from "~/classes/functions/status"; +import { contentToHtml, findManyNotes } 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"; @@ -306,179 +300,12 @@ export class Note extends BaseInterface { ); } - public isRemote(): boolean { - return this.author.isRemote(); + public get remote(): boolean { + return this.author.remote; } - /** - * Update a note from remote federated servers - * @returns The updated note - */ - public async updateFromRemote(): Promise { - if (!this.isRemote()) { - throw new Error("Cannot refetch a local note (it is not remote)"); - } - - 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"); - } - - this.data = updated.data; - - return this; - } - - /** - * Create a new note from user input - * @param data - The data to create the note from - * @returns The created note - */ - public static async fromData(data: { - author: User; - content: VersiaEntities.TextContentFormat; - visibility: z.infer; - isSensitive: boolean; - spoilerText: string; - emojis?: Emoji[]; - uri?: URL; - mentions?: User[]; - /** List of IDs of database Attachment objects */ - mediaAttachments?: Media[]; - replyId?: string; - quoteId?: string; - application?: Application; - }): Promise { - const plaintextContent = - data.content.data["text/plain"]?.content ?? - Object.entries(data.content.data)[0][1].content; - - const parsedMentions = mergeAndDeduplicate( - data.mentions ?? [], - await parseTextMentions(plaintextContent, data.author), - ); - const parsedEmojis = mergeAndDeduplicate( - data.emojis ?? [], - await Emoji.parseFromText(plaintextContent), - ); - - const htmlContent = await contentToHtml(data.content, parsedMentions); - - const newNote = await Note.insert({ - id: randomUUIDv7(), - authorId: data.author.id, - content: htmlContent, - contentSource: - 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?.href || null, - replyId: data.replyId ?? null, - quotingId: data.quoteId ?? null, - applicationId: data.application?.id ?? null, - }); - - // Connect emojis - await newNote.updateEmojis(parsedEmojis); - - // Connect mentions - await newNote.updateMentions(parsedMentions); - - // Set attachment parents - await newNote.updateAttachments(data.mediaAttachments ?? []); - - // Send notifications for mentioned local users - for (const mention of parsedMentions) { - if (mention.isLocal()) { - await mention.notify("mention", data.author, newNote); - } - } - - await newNote.reload(data.author.id); - - return newNote; - } - - /** - * Update a note from user input - * @param data - The data to update the note from - * @returns The updated note - */ - public async updateFromData(data: { - author: User; - content?: VersiaEntities.TextContentFormat; - visibility?: z.infer; - isSensitive?: boolean; - spoilerText?: string; - emojis?: Emoji[]; - uri?: URL; - mentions?: User[]; - mediaAttachments?: Media[]; - replyId?: string; - quoteId?: string; - application?: Application; - }): Promise { - const plaintextContent = data.content - ? (data.content.data["text/plain"]?.content ?? - Object.entries(data.content.data)[0][1].content) - : undefined; - - const parsedMentions = mergeAndDeduplicate( - data.mentions ?? [], - plaintextContent - ? await parseTextMentions(plaintextContent, data.author) - : [], - ); - const parsedEmojis = mergeAndDeduplicate( - data.emojis ?? [], - plaintextContent ? await Emoji.parseFromText(plaintextContent) : [], - ); - - const htmlContent = data.content - ? await contentToHtml(data.content, parsedMentions) - : undefined; - - await this.update({ - content: htmlContent, - contentSource: data.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, - }); - - // Connect emojis - await this.updateEmojis(parsedEmojis); - - // Connect mentions - await this.updateMentions(parsedMentions); - - // Set attachment parents - await this.updateAttachments(data.mediaAttachments ?? []); - - await this.reload(data.author.id); - - return this; + public get local(): boolean { + return this.author.local; } /** @@ -558,7 +385,7 @@ export class Note extends BaseInterface { */ public static async resolve(uri: URL): Promise { // Check if note not already in database - const foundNote = await Note.fromSql(eq(Notes.uri, uri.toString())); + const foundNote = await Note.fromSql(eq(Notes.uri, uri.href)); if (foundNote) { return foundNote; @@ -577,118 +404,124 @@ export class Note extends BaseInterface { return await Note.fromId(uuid[0]); } - const note = await User.federationRequester.fetchEntity( - uri, - VersiaEntities.Note, - ); - - return Note.fromVersia(note); + return Note.fromVersia(uri); } /** - * Turns a Versia Note into a database note (saved) - * @param note Versia Note - * @param author Author of the note - * @param instance Instance of the note - * @returns The saved note + * Tries to fetch a Versia Note from the given URL. + * + * @param url The URL to fetch the note from */ - public static async fromVersia(note: VersiaEntities.Note): Promise { - const emojis: Emoji[] = []; - const logger = getLogger(["federation", "resolvers"]); + public static async fromVersia(url: URL): Promise; - const author = await User.resolve(note.data.author); - if (!author) { - throw new Error("Invalid object author"); - } + /** + * Takes a Versia Note representation, and serializes it to the database. + * + * If the note already exists, it will update it. + * @param versiaNote + */ + public static async fromVersia( + versiaNote: VersiaEntities.Note, + ): Promise; - 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, - instance, - ).catch((e) => { - logger.error`${e}`; - sentry?.captureException(e); - return null; - }); - - if (resolvedEmoji) { - emojis.push(resolvedEmoji); - } - } - - const attachments: Media[] = []; - - for (const attachment of note.attachments) { - const resolvedAttachment = await Media.fromVersia(attachment).catch( - (e) => { - logger.error`${e}`; - sentry?.captureException(e); - return null; - }, + public static async fromVersia( + versiaNote: VersiaEntities.Note | URL, + ): Promise { + if (versiaNote instanceof URL) { + // No bridge support for notes yet + const note = await User.federationRequester.fetchEntity( + versiaNote, + VersiaEntities.Note, ); - if (resolvedAttachment) { - attachments.push(resolvedAttachment); + return Note.fromVersia(note); + } + + const { + author: authorUrl, + created_at, + uri, + extensions, + group, + is_sensitive, + mentions: noteMentions, + quotes, + replies_to, + subject, + } = versiaNote.data; + const instance = await Instance.resolve(authorUrl); + const author = await User.resolve(authorUrl); + + if (!author) { + throw new Error("Entity author could not be resolved"); + } + + const existingNote = await Note.fromSql(eq(Notes.uri, uri.href)); + + const note = + existingNote ?? + (await Note.insert({ + id: randomUUIDv7(), + authorId: author.id, + visibility: "public", + uri: uri.href, + createdAt: new Date(created_at).toISOString(), + })); + + const attachments = await Promise.all( + versiaNote.attachments.map((a) => Media.fromVersia(a)), + ); + + const emojis = await Promise.all( + extensions?.["pub.versia:custom_emojis"]?.emojis.map((emoji) => + Emoji.fetchFromRemote(emoji, instance), + ) ?? [], + ); + + const mentions = ( + await Promise.all( + noteMentions?.map((mention) => User.resolve(mention)) ?? [], + ) + ).filter((m) => m !== null); + + // TODO: Implement groups + const visibility = !group || group instanceof URL ? "direct" : group; + + const reply = replies_to ? await Note.resolve(replies_to) : null; + const quote = quotes ? await Note.resolve(quotes) : null; + const spoiler = subject ? await sanitizedHtmlStrip(subject) : undefined; + + await note.update({ + content: versiaNote.content + ? await contentToHtml(versiaNote.content, mentions) + : undefined, + contentSource: versiaNote.content + ? versiaNote.content.data["text/plain"]?.content || + versiaNote.content.data["text/markdown"]?.content + : undefined, + contentType: "text/html", + visibility: visibility === "followers" ? "private" : visibility, + sensitive: is_sensitive ?? false, + spoilerText: spoiler, + replyId: reply?.id, + quotingId: quote?.id, + }); + + // Emojis, mentions, and attachments are stored in a different table, so update them there too + await note.updateEmojis(emojis); + await note.updateMentions(mentions); + await note.updateAttachments(attachments); + + await note.reload(author.id); + + // Send notifications for mentioned local users + for (const mentioned of mentions) { + if (mentioned.local) { + await mentioned.notify("mention", author, note); } } - 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); - - if (visibility === "url") { - // TODO: Implement groups - visibility = "direct"; - } - - const newData = { - author, - content: - note.content ?? - new VersiaEntities.TextContentFormat({ - "text/plain": { - content: "", - remote: false, - }, - }), - visibility, - isSensitive: note.data.is_sensitive ?? false, - spoilerText: note.data.subject ?? "", - emojis, - 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.data.replies_to - ? (await Note.resolve(note.data.replies_to))?.data.id - : undefined, - 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.data.uri.href)); - - // If it exists, simply update it - if (foundNote) { - await foundNote.updateFromData(newData); - - return foundNote; - } - - // Else, create a new note - return await Note.fromData(newData); + return note; } public async delete(ids?: string[]): Promise { diff --git a/classes/database/reaction.ts b/classes/database/reaction.ts index d8825318..87f01529 100644 --- a/classes/database/reaction.ts +++ b/classes/database/reaction.ts @@ -165,7 +165,7 @@ export class Reaction extends BaseInterface { ); } - public isLocal(): boolean { + public get local(): boolean { return this.data.author.instanceId === null; } @@ -174,7 +174,7 @@ export class Reaction extends BaseInterface { } public toVersia(): VersiaEntities.Reaction { - if (!this.isLocal()) { + if (!this.local) { throw new Error("Cannot convert a non-local reaction to Versia"); } @@ -212,7 +212,7 @@ export class Reaction extends BaseInterface { author: User, note: Note, ): Promise { - if (author.isLocal()) { + if (author.local) { throw new Error("Cannot process a reaction from a local user"); } diff --git a/classes/database/user.ts b/classes/database/user.ts index 8b75b612..0268e3d3 100644 --- a/classes/database/user.ts +++ b/classes/database/user.ts @@ -148,12 +148,12 @@ export class User extends BaseInterface { return this.data.id; } - public isLocal(): boolean { + public get local(): boolean { return this.data.instanceId === null; } - public isRemote(): boolean { - return !this.isLocal(); + public get remote(): boolean { + return !this.local; } public get uri(): URL { @@ -196,14 +196,14 @@ export class User extends BaseInterface { ); await foundRelationship.update({ - following: otherUser.isRemote() ? false : !otherUser.data.isLocked, - requested: otherUser.isRemote() ? true : otherUser.data.isLocked, + following: otherUser.remote ? false : !otherUser.data.isLocked, + requested: otherUser.remote ? true : otherUser.data.isLocked, showingReblogs: options?.reblogs, notifying: options?.notify, languages: options?.languages, }); - if (otherUser.isRemote()) { + if (otherUser.remote) { await deliveryQueue.add(DeliveryJobType.FederateEntity, { entity: { type: "Follow", @@ -229,7 +229,7 @@ export class User extends BaseInterface { followee: User, relationship: Relationship, ): Promise { - if (followee.isRemote()) { + if (followee.remote) { await deliveryQueue.add(DeliveryJobType.FederateEntity, { entity: this.unfollowToVersia(followee).toJSON(), recipientId: followee.id, @@ -254,11 +254,11 @@ export class User extends BaseInterface { } public async acceptFollowRequest(follower: User): Promise { - if (!follower.isRemote()) { + if (!follower.remote) { throw new Error("Follower must be a remote user"); } - if (this.isRemote()) { + if (this.remote) { throw new Error("Followee must be a local user"); } @@ -278,11 +278,11 @@ export class User extends BaseInterface { } public async rejectFollowRequest(follower: User): Promise { - if (!follower.isRemote()) { + if (!follower.remote) { throw new Error("Follower must be a remote user"); } - if (this.isRemote()) { + if (this.remote) { throw new Error("Followee must be a local user"); } @@ -497,10 +497,10 @@ export class User extends BaseInterface { uri: uri?.href, }); - if (this.isLocal() && note.author.isLocal()) { + if (this.local && note.author.local) { // Notify the user that their post has been favourited await note.author.notify("favourite", this, note); - } else if (this.isLocal() && note.author.isRemote()) { + } else if (this.local && note.author.remote) { // Federate the like this.federateToFollowers(newLike.toVersia()); } @@ -526,10 +526,10 @@ export class User extends BaseInterface { await likeToDelete.delete(); - if (this.isLocal() && note.author.isLocal()) { + if (this.local && note.author.local) { // Remove any eventual notifications for this like await likeToDelete.clearRelatedNotifications(); - } else if (this.isLocal() && note.author.isRemote()) { + } else if (this.local && note.author.remote) { // User is local, federate the delete this.federateToFollowers(likeToDelete.unlikeToVersia(this)); } @@ -630,7 +630,7 @@ export class User extends BaseInterface { * Takes a Versia User representation, and serializes it to the database. * * If the user already exists, it will update it. - * @param user + * @param versiaUser */ public static async fromVersia( versiaUser: VersiaEntities.User, @@ -895,7 +895,7 @@ export class User extends BaseInterface { } public getAcct(): string { - return this.isLocal() + return this.local ? this.data.username : `${this.data.username}@${this.data.instance?.baseUrl}`; } @@ -921,7 +921,7 @@ export class User extends BaseInterface { // If something important is updated, federate it if ( - this.isLocal() && + this.local && (newUser.username || newUser.displayName || newUser.note || @@ -1090,7 +1090,7 @@ export class User extends BaseInterface { } public toVersia(): VersiaEntities.User { - if (this.isRemote()) { + if (this.remote) { throw new Error("Cannot convert remote user to Versia format"); } diff --git a/classes/functions/status.ts b/classes/functions/status.ts index 42efcf0f..97eba1e7 100644 --- a/classes/functions/status.ts +++ b/classes/functions/status.ts @@ -301,7 +301,7 @@ export const replaceTextMentions = (text: string, mentions: User[]): string => { const linkTemplate = (displayText: string): string => `${displayText}`; - if (mention.isRemote()) { + if (mention.remote) { return finalText.replaceAll( `@${username}@${instance?.baseUrl}`, linkTemplate(`@${username}@${instance?.baseUrl}`), diff --git a/classes/queues/inbox.ts b/classes/queues/inbox.ts index bd549697..2062bb52 100644 --- a/classes/queues/inbox.ts +++ b/classes/queues/inbox.ts @@ -112,7 +112,7 @@ export const getInboxWorker = (): Worker => return; } - if (sender?.isLocal()) { + if (sender?.local) { throw new Error( "Cannot process federation requests from local users", ); diff --git a/cli/user/refetch.ts b/cli/user/refetch.ts index 43d782fc..1eef6b61 100644 --- a/cli/user/refetch.ts +++ b/cli/user/refetch.ts @@ -21,7 +21,7 @@ export const refetchUserCommand = defineCommand( throw new Error(`User ${chalk.gray(handle)} not found.`); } - if (user.isLocal()) { + if (user.local) { throw new Error( "This user is local and as such cannot be refetched.", ); diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 4574e567..7f2e5174 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -458,7 +458,7 @@ export const Notes = pgTable("Notes", { onDelete: "cascade", onUpdate: "cascade", }), - sensitive: boolean("sensitive").notNull(), + sensitive: boolean("sensitive").notNull().default(false), spoilerText: text("spoiler_text").default("").notNull(), applicationId: uuid("applicationId").references(() => Applications.id, { onDelete: "set null", From 45e5460975612376a8b62ddd4b0bb18301e71962 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Tue, 8 Apr 2025 21:54:55 +0200 Subject: [PATCH 5/6] docs(federation): :memo: Update SDK documentation --- .../v1/accounts/update_credentials/index.ts | 2 +- api/api/v1/statuses/[id]/index.ts | 2 +- api/api/v1/statuses/index.ts | 2 +- api/likes/[uuid]/index.ts | 2 +- api/notes/[uuid]/index.ts | 2 +- api/notes/[uuid]/quotes.ts | 16 +- api/notes/[uuid]/replies.ts | 16 +- api/users/[uuid]/inbox/index.ts | 2 +- api/users/[uuid]/index.ts | 2 +- api/users/[uuid]/outbox/index.ts | 4 +- api/well-known/versia.ts | 2 +- api/well-known/webfinger/index.ts | 6 +- classes/database/emoji.ts | 4 +- classes/database/instance.ts | 6 +- classes/database/like.ts | 2 +- classes/database/media.ts | 14 +- classes/database/note.ts | 4 +- classes/database/reaction.ts | 2 +- classes/database/user.ts | 13 +- classes/functions/status.ts | 2 +- classes/inbox/processor.ts | 8 +- classes/queues/delivery.ts | 16 +- classes/queues/inbox.ts | 6 +- drizzle/schema.ts | 14 +- packages/sdk/README.md | 220 ++++++++++++++++++ packages/{federation => sdk}/crypto.ts | 22 +- .../entities/collection.ts | 0 .../entities/contentformat.ts | 0 .../{federation => sdk}/entities/delete.ts | 0 .../{federation => sdk}/entities/entity.ts | 0 .../entities/extensions/likes.ts | 0 .../entities/extensions/polls.ts | 0 .../entities/extensions/reactions.ts | 0 .../entities/extensions/reports.ts | 0 .../entities/extensions/share.ts | 0 .../{federation => sdk}/entities/follow.ts | 0 .../{federation => sdk}/entities/index.ts | 0 .../entities/instancemetadata.ts | 0 packages/{federation => sdk}/entities/note.ts | 0 packages/{federation => sdk}/entities/user.ts | 0 packages/{federation => sdk}/http.ts | 0 .../{federation => sdk}/inbox-processor.ts | 2 +- packages/{federation => sdk}/package.json | 0 packages/{federation => sdk}/regex.ts | 0 .../{federation => sdk}/schemas/collection.ts | 0 .../{federation => sdk}/schemas/common.ts | 0 .../schemas/contentformat.ts | 0 .../{federation => sdk}/schemas/delete.ts | 0 .../{federation => sdk}/schemas/entity.ts | 0 .../schemas/extensions/emojis.ts | 0 .../schemas/extensions/groups.ts | 0 .../schemas/extensions/likes.ts | 0 .../schemas/extensions/migration.ts | 0 .../schemas/extensions/polls.ts | 0 .../schemas/extensions/reactions.ts | 0 .../schemas/extensions/reports.ts | 0 .../schemas/extensions/share.ts | 0 .../schemas/extensions/vanity.ts | 0 .../{federation => sdk}/schemas/follow.ts | 0 packages/{federation => sdk}/schemas/index.ts | 0 .../{federation => sdk}/schemas/instance.ts | 0 packages/{federation => sdk}/schemas/note.ts | 0 packages/{federation => sdk}/schemas/user.ts | 0 .../{federation => sdk}/schemas/webfinger.ts | 0 packages/{federation => sdk}/types.ts | 0 types/api.ts | 2 +- utils/content_types.ts | 2 +- 67 files changed, 332 insertions(+), 65 deletions(-) create mode 100644 packages/sdk/README.md rename packages/{federation => sdk}/crypto.ts (71%) rename packages/{federation => sdk}/entities/collection.ts (100%) rename packages/{federation => sdk}/entities/contentformat.ts (100%) rename packages/{federation => sdk}/entities/delete.ts (100%) rename packages/{federation => sdk}/entities/entity.ts (100%) rename packages/{federation => sdk}/entities/extensions/likes.ts (100%) rename packages/{federation => sdk}/entities/extensions/polls.ts (100%) rename packages/{federation => sdk}/entities/extensions/reactions.ts (100%) rename packages/{federation => sdk}/entities/extensions/reports.ts (100%) rename packages/{federation => sdk}/entities/extensions/share.ts (100%) rename packages/{federation => sdk}/entities/follow.ts (100%) rename packages/{federation => sdk}/entities/index.ts (100%) rename packages/{federation => sdk}/entities/instancemetadata.ts (100%) rename packages/{federation => sdk}/entities/note.ts (100%) rename packages/{federation => sdk}/entities/user.ts (100%) rename packages/{federation => sdk}/http.ts (100%) rename packages/{federation => sdk}/inbox-processor.ts (96%) rename packages/{federation => sdk}/package.json (100%) rename packages/{federation => sdk}/regex.ts (100%) rename packages/{federation => sdk}/schemas/collection.ts (100%) rename packages/{federation => sdk}/schemas/common.ts (100%) rename packages/{federation => sdk}/schemas/contentformat.ts (100%) rename packages/{federation => sdk}/schemas/delete.ts (100%) rename packages/{federation => sdk}/schemas/entity.ts (100%) rename packages/{federation => sdk}/schemas/extensions/emojis.ts (100%) rename packages/{federation => sdk}/schemas/extensions/groups.ts (100%) rename packages/{federation => sdk}/schemas/extensions/likes.ts (100%) rename packages/{federation => sdk}/schemas/extensions/migration.ts (100%) rename packages/{federation => sdk}/schemas/extensions/polls.ts (100%) rename packages/{federation => sdk}/schemas/extensions/reactions.ts (100%) rename packages/{federation => sdk}/schemas/extensions/reports.ts (100%) rename packages/{federation => sdk}/schemas/extensions/share.ts (100%) rename packages/{federation => sdk}/schemas/extensions/vanity.ts (100%) rename packages/{federation => sdk}/schemas/follow.ts (100%) rename packages/{federation => sdk}/schemas/index.ts (100%) rename packages/{federation => sdk}/schemas/instance.ts (100%) rename packages/{federation => sdk}/schemas/note.ts (100%) rename packages/{federation => sdk}/schemas/user.ts (100%) rename packages/{federation => sdk}/schemas/webfinger.ts (100%) rename packages/{federation => sdk}/types.ts (100%) diff --git a/api/api/v1/accounts/update_credentials/index.ts b/api/api/v1/accounts/update_credentials/index.ts index 34ab21c0..496c0ae9 100644 --- a/api/api/v1/accounts/update_credentials/index.ts +++ b/api/api/v1/accounts/update_credentials/index.ts @@ -5,7 +5,6 @@ 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"; @@ -14,6 +13,7 @@ import { ApiError } from "~/classes/errors/api-error"; import { contentToHtml } from "~/classes/functions/status"; import { config } from "~/config.ts"; import { rateLimit } from "~/middlewares/rate-limit"; +import * as VersiaEntities from "~/packages/sdk/entities"; export default apiRoute((app) => app.patch( diff --git a/api/api/v1/statuses/[id]/index.ts b/api/api/v1/statuses/[id]/index.ts index 229a1b2c..52d65441 100644 --- a/api/api/v1/statuses/[id]/index.ts +++ b/api/api/v1/statuses/[id]/index.ts @@ -15,13 +15,13 @@ import { } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas"; import { Emoji, 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"; import { ApiError } from "~/classes/errors/api-error"; import { contentToHtml, parseTextMentions } from "~/classes/functions/status"; import { config } from "~/config.ts"; +import * as VersiaEntities from "~/packages/sdk/entities"; const schema = z .object({ diff --git a/api/api/v1/statuses/index.ts b/api/api/v1/statuses/index.ts index 8669df4a..0c6c0dc5 100644 --- a/api/api/v1/statuses/index.ts +++ b/api/api/v1/statuses/index.ts @@ -9,7 +9,6 @@ import { } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas"; import { Emoji, Media, Note } from "@versia/kit/db"; -import * as VersiaEntities from "@versia/sdk/entities"; import { randomUUIDv7 } from "bun"; import { describeRoute } from "hono-openapi"; import { resolver, validator } from "hono-openapi/zod"; @@ -17,6 +16,7 @@ import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { contentToHtml, parseTextMentions } from "~/classes/functions/status"; import { config } from "~/config.ts"; +import * as VersiaEntities from "~/packages/sdk/entities"; const schema = z .object({ diff --git a/api/likes/[uuid]/index.ts b/api/likes/[uuid]/index.ts index 2527f6bf..82871d25 100644 --- a/api/likes/[uuid]/index.ts +++ b/api/likes/[uuid]/index.ts @@ -2,13 +2,13 @@ import { apiRoute, handleZodError } from "@/api"; import { Status as StatusSchema } from "@versia/client/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"; import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/config.ts"; +import { LikeSchema } from "~/packages/sdk/schemas"; export default apiRoute((app) => app.get( diff --git a/api/notes/[uuid]/index.ts b/api/notes/[uuid]/index.ts index c680fc70..2057abda 100644 --- a/api/notes/[uuid]/index.ts +++ b/api/notes/[uuid]/index.ts @@ -2,13 +2,13 @@ import { apiRoute, handleZodError } from "@/api"; import { Status as StatusSchema } from "@versia/client/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"; import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/config.ts"; +import { NoteSchema } from "~/packages/sdk/schemas"; export default apiRoute((app) => app.get( diff --git a/api/notes/[uuid]/quotes.ts b/api/notes/[uuid]/quotes.ts index cd896e51..b9de0f7c 100644 --- a/api/notes/[uuid]/quotes.ts +++ b/api/notes/[uuid]/quotes.ts @@ -2,14 +2,14 @@ import { apiRoute, handleZodError } from "@/api"; import { Status as StatusSchema } from "@versia/client/schemas"; 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"; import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/config.ts"; +import * as VersiaEntities from "~/packages/sdk/entities"; +import { URICollectionSchema } from "~/packages/sdk/schemas"; export default apiRoute((app) => app.get( @@ -94,7 +94,9 @@ export default apiRoute((app) => last: replyCount > limit ? new URL( - `/notes/${note.id}/quotes?offset=${replyCount - limit}`, + `/notes/${note.id}/quotes?offset=${ + replyCount - limit + }`, config.http.base_url, ) : new URL( @@ -104,14 +106,18 @@ export default apiRoute((app) => next: offset + limit < replyCount ? new URL( - `/notes/${note.id}/quotes?offset=${offset + limit}`, + `/notes/${note.id}/quotes?offset=${ + offset + limit + }`, config.http.base_url, ) : null, previous: offset - limit >= 0 ? new URL( - `/notes/${note.id}/quotes?offset=${offset - limit}`, + `/notes/${note.id}/quotes?offset=${ + offset - limit + }`, config.http.base_url, ) : null, diff --git a/api/notes/[uuid]/replies.ts b/api/notes/[uuid]/replies.ts index f2243ed5..2bdd377e 100644 --- a/api/notes/[uuid]/replies.ts +++ b/api/notes/[uuid]/replies.ts @@ -2,14 +2,14 @@ import { apiRoute, handleZodError } from "@/api"; import { Status as StatusSchema } from "@versia/client/schemas"; 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"; import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/config.ts"; +import * as VersiaEntities from "~/packages/sdk/entities"; +import { URICollectionSchema } from "~/packages/sdk/schemas"; export default apiRoute((app) => app.get( @@ -92,7 +92,9 @@ export default apiRoute((app) => last: replyCount > limit ? new URL( - `/notes/${note.id}/replies?offset=${replyCount - limit}`, + `/notes/${note.id}/replies?offset=${ + replyCount - limit + }`, config.http.base_url, ) : new URL( @@ -102,14 +104,18 @@ export default apiRoute((app) => next: offset + limit < replyCount ? new URL( - `/notes/${note.id}/replies?offset=${offset + limit}`, + `/notes/${note.id}/replies?offset=${ + offset + limit + }`, config.http.base_url, ) : null, previous: offset - limit >= 0 ? new URL( - `/notes/${note.id}/replies?offset=${offset - limit}`, + `/notes/${note.id}/replies?offset=${ + offset - limit + }`, config.http.base_url, ) : null, diff --git a/api/users/[uuid]/inbox/index.ts b/api/users/[uuid]/inbox/index.ts index ba1583ad..4becb342 100644 --- a/api/users/[uuid]/inbox/index.ts +++ b/api/users/[uuid]/inbox/index.ts @@ -4,7 +4,7 @@ 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"; +import type { JSONObject } from "~/packages/sdk/types"; export default apiRoute((app) => app.post( diff --git a/api/users/[uuid]/index.ts b/api/users/[uuid]/index.ts index 63182daf..a03d9eb4 100644 --- a/api/users/[uuid]/index.ts +++ b/api/users/[uuid]/index.ts @@ -1,10 +1,10 @@ import { apiRoute, handleZodError } from "@/api"; 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"; import { ApiError } from "~/classes/errors/api-error"; +import { UserSchema } from "~/packages/sdk/schemas"; export default apiRoute((app) => app.get( diff --git a/api/users/[uuid]/outbox/index.ts b/api/users/[uuid]/outbox/index.ts index a72e31ca..1eb452dd 100644 --- a/api/users/[uuid]/outbox/index.ts +++ b/api/users/[uuid]/outbox/index.ts @@ -1,14 +1,14 @@ import { apiRoute, handleZodError } from "@/api"; 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"; import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/config.ts"; +import * as VersiaEntities from "~/packages/sdk/entities"; +import { CollectionSchema, NoteSchema } from "~/packages/sdk/schemas"; const NOTES_PER_PAGE = 20; diff --git a/api/well-known/versia.ts b/api/well-known/versia.ts index a325b486..a36cd128 100644 --- a/api/well-known/versia.ts +++ b/api/well-known/versia.ts @@ -2,12 +2,12 @@ import { apiRoute } from "@/api"; import { urlToContentFormat } from "@/content_types"; 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"; import { config } from "~/config.ts"; import pkg from "~/package.json"; +import { InstanceMetadataSchema } from "~/packages/sdk/schemas"; export default apiRoute((app) => app.get( diff --git a/api/well-known/webfinger/index.ts b/api/well-known/webfinger/index.ts index c788c83f..60da40a4 100644 --- a/api/well-known/webfinger/index.ts +++ b/api/well-known/webfinger/index.ts @@ -8,13 +8,13 @@ import { import { getLogger } from "@logtape/logtape"; 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"; import { z } from "zod"; import { ApiError } from "~/classes/errors/api-error"; import { config } from "~/config.ts"; +import { WebFingerSchema } from "~/packages/sdk/schemas"; export default apiRoute((app) => app.get( @@ -101,7 +101,9 @@ export default apiRoute((app) => return context.json( { - subject: `acct:${isUuid ? user.id : user.data.username}@${host}`, + subject: `acct:${ + isUuid ? user.id : user.data.username + }@${host}`, links: [ // Keep the ActivityPub link first, because Misskey only searches diff --git a/classes/database/emoji.ts b/classes/database/emoji.ts index 5267f37c..b7e2c857 100644 --- a/classes/database/emoji.ts +++ b/classes/database/emoji.ts @@ -2,8 +2,6 @@ import { emojiValidatorWithColons, emojiValidatorWithIdentifiers } from "@/api"; import type { CustomEmoji } from "@versia/client/schemas"; 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, @@ -16,6 +14,8 @@ import { isNull, } from "drizzle-orm"; import type { z } from "zod"; +import * as VersiaEntities from "~/packages/sdk/entities/index.ts"; +import type { ImageContentFormatSchema } from "~/packages/sdk/schemas/index.ts"; import { BaseInterface } from "./base.ts"; type EmojiType = InferSelectModel & { diff --git a/classes/database/instance.ts b/classes/database/instance.ts index eaf8987b..1cd26abe 100644 --- a/classes/database/instance.ts +++ b/classes/database/instance.ts @@ -1,7 +1,6 @@ import { getLogger } from "@logtape/logtape"; 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 { @@ -13,6 +12,7 @@ import { inArray, } from "drizzle-orm"; import { config } from "~/config.ts"; +import * as VersiaEntities from "~/packages/sdk/entities/index.ts"; import { ApiError } from "../errors/api-error.ts"; import { BaseInterface } from "./base.ts"; import { User } from "./user.ts"; @@ -319,7 +319,9 @@ export class Instance extends BaseInterface { ); if (!output) { - logger.error`Failed to update instance ${chalk.bold(this.data.baseUrl)}`; + logger.error`Failed to update instance ${chalk.bold( + this.data.baseUrl, + )}`; throw new Error("Failed to update instance"); } diff --git a/classes/database/like.ts b/classes/database/like.ts index dc0808c3..e681eb61 100644 --- a/classes/database/like.ts +++ b/classes/database/like.ts @@ -5,7 +5,6 @@ import { Notifications, type Users, } from "@versia/kit/tables"; -import * as VersiaEntities from "@versia/sdk/entities"; import { type InferInsertModel, type InferSelectModel, @@ -16,6 +15,7 @@ import { inArray, } from "drizzle-orm"; import { config } from "~/config.ts"; +import * as VersiaEntities from "~/packages/sdk/entities/index.ts"; import { BaseInterface } from "./base.ts"; import { User } from "./user.ts"; diff --git a/classes/database/media.ts b/classes/database/media.ts index c28258b4..9bce13f9 100644 --- a/classes/database/media.ts +++ b/classes/database/media.ts @@ -3,11 +3,6 @@ import { mimeLookup } from "@/content_types.ts"; import type { Attachment as AttachmentSchema } from "@versia/client/schemas"; 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, @@ -21,6 +16,11 @@ import sharp from "sharp"; import type { z } from "zod"; import { MediaBackendType } from "~/classes/config/schema.ts"; import { config } from "~/config.ts"; +import * as VersiaEntities from "~/packages/sdk/entities/index.ts"; +import type { + ContentFormatSchema, + ImageContentFormatSchema, +} from "~/packages/sdk/schemas/index.ts"; import { ApiError } from "../errors/api-error.ts"; import { getMediaHash } from "../media/media-hasher.ts"; import { ProxiableUrl } from "../media/url.ts"; @@ -278,7 +278,9 @@ export class Media extends BaseInterface { throw new ApiError( 415, `File type ${file.type} is not allowed`, - `Allowed types: ${config.validation.media.allowed_mime_types.join(", ")}`, + `Allowed types: ${config.validation.media.allowed_mime_types.join( + ", ", + )}`, ); } } diff --git a/classes/database/note.ts b/classes/database/note.ts index 1aff1277..402df3f0 100644 --- a/classes/database/note.ts +++ b/classes/database/note.ts @@ -10,7 +10,6 @@ import { Notes, Users, } from "@versia/kit/tables"; -import * as VersiaEntities from "@versia/sdk/entities"; import { randomUUIDv7 } from "bun"; import { type InferInsertModel, @@ -28,7 +27,8 @@ import { createRegExp, exactly, global } from "magic-regexp"; import type { z } from "zod"; import { contentToHtml, findManyNotes } from "~/classes/functions/status"; import { config } from "~/config.ts"; -import type { NonTextContentFormatSchema } from "~/packages/federation/schemas/contentformat.ts"; +import * as VersiaEntities from "~/packages/sdk/entities/index.ts"; +import type { NonTextContentFormatSchema } from "~/packages/sdk/schemas/contentformat.ts"; import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts"; import { Application } from "./application.ts"; import { BaseInterface } from "./base.ts"; diff --git a/classes/database/reaction.ts b/classes/database/reaction.ts index 87f01529..491cc3b7 100644 --- a/classes/database/reaction.ts +++ b/classes/database/reaction.ts @@ -1,6 +1,5 @@ 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, @@ -11,6 +10,7 @@ import { inArray, } from "drizzle-orm"; import { config } from "~/config.ts"; +import * as VersiaEntities from "~/packages/sdk/entities/index.ts"; import { BaseInterface } from "./base.ts"; type ReactionType = InferSelectModel & { diff --git a/classes/database/user.ts b/classes/database/user.ts index 0268e3d3..4458936c 100644 --- a/classes/database/user.ts +++ b/classes/database/user.ts @@ -19,10 +19,6 @@ 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"; @@ -45,6 +41,10 @@ import type { z } from "zod"; import { findManyUsers } from "~/classes/functions/user"; import { searchManager } from "~/classes/search/search-manager"; import { config } from "~/config.ts"; +import { sign } from "~/packages/sdk/crypto.ts"; +import * as VersiaEntities from "~/packages/sdk/entities/index.ts"; +import { FederationRequester } from "~/packages/sdk/http.ts"; +import type { ImageContentFormatSchema } from "~/packages/sdk/schemas/index.ts"; import type { HttpVerb, KnownEntity } from "~/types/api.ts"; import { ProxiableUrl } from "../media/url.ts"; import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts"; @@ -1023,8 +1023,9 @@ export class User extends BaseInterface { entity, ); } catch (e) { - getLogger(["federation", "delivery"]) - .error`Federating ${chalk.gray(entity.data.type)} to ${user.uri} ${chalk.bold.red("failed")}`; + getLogger(["federation", "delivery"]).error`Federating ${chalk.gray( + entity.data.type, + )} to ${user.uri} ${chalk.bold.red("failed")}`; getLogger(["federation", "delivery"]).error`${e}`; sentry?.captureException(e); diff --git a/classes/functions/status.ts b/classes/functions/status.ts index 97eba1e7..3992df86 100644 --- a/classes/functions/status.ts +++ b/classes/functions/status.ts @@ -3,7 +3,6 @@ import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization"; import markdownItTaskLists from "@hackmd/markdown-it-task-lists"; 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 { @@ -19,6 +18,7 @@ import MarkdownIt from "markdown-it"; import markdownItContainer from "markdown-it-container"; import markdownItTocDoneRight from "markdown-it-toc-done-right"; import { config } from "~/config.ts"; +import type * as VersiaEntities from "~/packages/sdk/entities/index.ts"; import { transformOutputToUserWithRelations, userExtrasTemplate, diff --git a/classes/inbox/processor.ts b/classes/inbox/processor.ts index 7f74da3c..44bfb815 100644 --- a/classes/inbox/processor.ts +++ b/classes/inbox/processor.ts @@ -2,9 +2,6 @@ import { sentry } from "@/sentry"; import { type Logger, getLogger } from "@logtape/logtape"; 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"; @@ -12,7 +9,10 @@ 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 { verify } from "~/packages/sdk/crypto.ts"; +import * as VersiaEntities from "~/packages/sdk/entities/index.ts"; +import { EntitySorter } from "~/packages/sdk/inbox-processor.ts"; +import type { JSONObject } from "~/packages/sdk/types.ts"; import { ApiError } from "../errors/api-error.ts"; /** diff --git a/classes/queues/delivery.ts b/classes/queues/delivery.ts index e8cbc6b0..d8605649 100644 --- a/classes/queues/delivery.ts +++ b/classes/queues/delivery.ts @@ -1,10 +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 { JSONObject } from "~/packages/federation/types"; +import * as VersiaEntities from "~/packages/sdk/entities"; +import type { JSONObject } from "~/packages/sdk/types"; import { connection } from "~/utils/redis.ts"; export enum DeliveryJobType { @@ -40,7 +40,9 @@ export const getDeliveryWorker = (): Worker< if (!sender) { throw new Error( - `Could not resolve sender ID ${chalk.gray(senderId)}`, + `Could not resolve sender ID ${chalk.gray( + senderId, + )}`, ); } @@ -48,12 +50,16 @@ export const getDeliveryWorker = (): Worker< if (!recipient) { throw new Error( - `Could not resolve recipient ID ${chalk.gray(recipientId)}`, + `Could not resolve recipient ID ${chalk.gray( + recipientId, + )}`, ); } await job.log( - `Federating entity [${entity.id}] from @${sender.getAcct()} to @${recipient.getAcct()}`, + `Federating entity [${ + entity.id + }] from @${sender.getAcct()} to @${recipient.getAcct()}`, ); const type = entity.type; diff --git a/classes/queues/inbox.ts b/classes/queues/inbox.ts index 2062bb52..31be17ae 100644 --- a/classes/queues/inbox.ts +++ b/classes/queues/inbox.ts @@ -4,7 +4,7 @@ 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 type { JSONObject } from "~/packages/sdk/types.ts"; import { connection } from "~/utils/redis.ts"; import { ApiError } from "../errors/api-error.ts"; import { InboxProcessor } from "../inbox/processor.ts"; @@ -182,7 +182,9 @@ export const getInboxWorker = (): Worker => ); await remoteInstance.sendMessage( - `Failed processing entity [${data.uri}] delivered to inbox. Returned error:\n\n${JSON.stringify( + `Failed processing entity [${ + data.uri + }] delivered to inbox. Returned error:\n\n${JSON.stringify( e.message, null, 4, diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 7f2e5174..350fc851 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -4,13 +4,6 @@ import type { Status as StatusSchema, } from "@versia/client/schemas"; import type { RolePermission } from "@versia/client/schemas"; -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 { @@ -26,6 +19,13 @@ import { uuid, } from "drizzle-orm/pg-core"; import type { z } from "zod"; +import type { + ContentFormatSchema, + ImageContentFormatSchema, + InstanceMetadataSchema, + NonTextContentFormatSchema, + TextContentFormatSchema, +} from "~/packages/sdk/schemas"; // biome-ignore lint/nursery/useExplicitType: Type is too complex const createdAt = () => diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 00000000..2b863508 --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,220 @@ +

+ Versia Logo +

+ +

@versia/sdk

+ +Federation types, validators and cryptography for Versia server implementations. + +## Usage + +## Entities + +The `@versia/sdk/entities` module provides TypeScript classes for working with Versia entities. These classes provide type-safe access to entity properties and methods for serialization/deserialization. + +```ts +import { Note, User } from "@versia/sdk/entities"; + +const note = new Note({ + id: "00000000-0000-0000-0000-000000000000", + type: "Note", +}); + +// You can also parse from JSON, which will apply the schema validation +const invalidJson = { + id: "00000000-0000-0000-0000-000000000000", + invalid: "property", +}; + +// Will throw an error +const invalidNote = await Note.fromJSON(invalidJson); + +const validJson = { + id: "00000000-0000-0000-0000-000000000000", + type: "Note", +}; + +const validNote = await Note.fromJSON(validJson); +``` + +Some entities like `Note` have additional properties, like `content` or `attachments`, which are automatically calculated from the relevant properties. + +```ts +import { TextContentFormat, Note } from "@versia/sdk/entities"; + +const note = new Note({ + id: "00000000-0000-0000-0000-000000000000", + type: "Note", + content: { + "text/plain": { + content: "Hello, world!", + remote: false, + }, + }, +}); + +const content = note.content; +// Is equivalent to +const content = new TextContentFormat(note.data.content); +``` + +## Schemas + +Additionally, the [**Zod**](https://zod.dev) schemas used for validation are available in the `@versia/sdk/schemas` module. You can use these to directly validate incoming data, without using the entity classes. + +```ts +import { NoteSchema, UserSchema } from "@versia/sdk/schemas"; + +const response = await fetch("https://example.com/notes/123"); +const json = await response.json(); + +const noteSchema = NoteSchema.parse(json); +``` + +## Sorter + +The `@versia/sdk/sorter` module provides a class for inbox request handling. It allows you to automatically sort and process incoming entities based on their type. + +```ts +import { EntitySorter } from "@versia/sdk"; +import { Note, User } from "@versia/sdk/entities"; + +app.post("/inbox", async (req, res) => { + const json = await req.json(); + + const sorter = new EntitySorter(json); + + await sorter + .on(Note, (note) => { + console.log(note); + }) + .on(User, (user) => { + console.log(user); + }) + .sort(); +}); +``` + +## Cryptography + +The `@versia/sdk/crypto` module provides functions for signing and verifying requests using the [**Ed25519**](https://en.wikipedia.org/wiki/EdDSA) algorithm. + +```ts +import { sign, verify } from "@versia/sdk/crypto"; + +const keys = await crypto.subtle.generateKey("Ed25519", true, [ + "sign", + "verify", +]); + +// URI of the User that is signing the request +const authorUrl = new URL("https://example.com"); + +const req = new Request("https://example.com/notes/123", { + method: "POST", + body: JSON.stringify({ + id: "00000000-0000-0000-0000-000000000000", + type: "Note", + }), +}); + +const signedReq = await sign(keys.privateKey, authorUrl, req); + +const verified = await verify(keys.publicKey, signedReq); +``` + +### Prerequisites + +#### For Usage + +See the [**Compatibility**](#compatibility) section for the supported environments. Any package manager can be used to install the packages. + +#### For Development + +- [**Bun**](https://bun.sh) version `1.1.8` or higher. +- Either the [**Linux**](https://www.linux.org) or [**macOS**](https://www.apple.com/macos) operating systems. ([**Windows**](https://www.microsoft.com/windows) will work, but is not officially supported.) + +### Compatibility + +This library is built for JavaScript runtimes with the support for: + +- [**ES Modules**](https://nodejs.org/api/esm.html) +- [**ECMAScript 2020**](https://www.ecma-international.org/ecma-262/11.0/index.html) +- (only required for cryptography) [**Ed25519**](https://en.wikipedia.org/wiki/EdDSA) cryptography in the [**WebCrypto API**](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) + +#### Runtimes + +- **Node.js**: 14.0+ is the minimum (18.0+ for cryptography), but only Node.js 20.0+ (LTS) is officially supported. +- **Deno**: Support is unknown. 1.0+ is expected to work. +- **Bun**: Bun 1.1.8 is the minimum-supported version. As Bun is rapidly evolving, this may change. Previous versions may also work. + +#### Browsers + +Consequently, this library is compatible without any bundling in the following browser versions: + +- **Chrome**: 80+ +- **Edge**: 80+ +- **Firefox**: 74+ +- **Safari**: 13.1+ +- **Opera**: 67+ +- **Internet Explorer**: None + +Cryptography functions are supported in the following browsers: + +- **Safari**: 17.0+ +- **Firefox**: 129.0+ +- **Chrome**: 113.0+ with `#enable-experimental-web-platform-features` enabled + +If you are targeting older browsers, please don't, you are doing yourself a disservice. + +Transpilation to non-ES Module environments is not officially supported, but should be simple with the use of a bundler like [**Parcel**](https://parceljs.org) or [**Rollup**](https://rollupjs.org). + +### Installation + +Package is distributed as a scoped package on the NPM registry and [JSR](https://jsr.io). + +We strongly recommend using JSR over NPM for all your packages that are available on it. + +```bash +# NPM version +deno add npm:@versia/sdk # For Deno +npm install @versia/sdk # For NPM +yarn add @versia/sdk # For Yarn +pnpm add @versia/sdk # For PNPM +bun add @versia/sdk # For Bun + +# JSR version +deno add @versia/sdk # For Deno +npx jsr add @versia/sdk # For JSR +yarn dlx jsr add @versia/sdk # For Yarn +pnpm dlx jsr add @versia/sdk # For PNPM +bunx jsr add @versia/sdk # For Bun +``` + +#### From Source + +If you want to install from source, you can clone [this repository](https://github.com/versia-pub/api) and run the following commands: + +```bash +bun install # Install dependencies + +bun run build # Build the packages +``` + +The built package will be in the `sdk/dist` folder. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +### Projects + +- [**Bun**](https://bun.sh): Thanks to the Bun team for creating an amazing JavaScript runtime. +- [**TypeScript**](https://www.typescriptlang.org): TypeScript is the backbone of this project. +- [**Node.js**](https://nodejs.org): Node.js created the idea of JavaScript on the server. + +### People + +- [**April John**](https://github.com/cutestnekoaqua): Creator and maintainer of the Versia Server ActivityPub bridge. diff --git a/packages/federation/crypto.ts b/packages/sdk/crypto.ts similarity index 71% rename from packages/federation/crypto.ts rename to packages/sdk/crypto.ts index f207af03..541cc4fd 100644 --- a/packages/federation/crypto.ts +++ b/packages/sdk/crypto.ts @@ -9,6 +9,16 @@ const stringToBase64Hash = async (str: string): Promise => { const base64ToArrayBuffer = (base64: string): ArrayBuffer => Uint8Array.fromBase64(base64).buffer as ArrayBuffer; +/** + * Signs a request using the Ed25519 algorithm, according to the [**Versia**](https://versia.pub/signatures) specification. + * + * @see https://versia.pub/signatures + * @param privateKey - Private key of the User that is signing the request. + * @param authorUrl - URL of the User that is signing the request. + * @param req - Request to sign. + * @param timestamp - (optional) Timestamp of the request. + * @returns The signed request. + */ export const sign = async ( privateKey: CryptoKey, authorUrl: URL, @@ -45,6 +55,14 @@ export const sign = async ( return newReq; }; +/** + * Verifies a signed request using the Ed25519 algorithm, according to the [**Versia**](https://versia.pub/signatures) specification. + * + * @see https://versia.pub/signatures + * @param publicKey - Public key of the User that is verifying the request. + * @param req - Request to verify. + * @returns Whether the request signature is valid or not. + */ export const verify = async ( publicKey: CryptoKey, req: Request, @@ -62,7 +80,9 @@ export const verify = async ( const digest = await stringToBase64Hash(body); - const expectedSignedString = `${req.method.toLowerCase()} ${encodeURI(url.pathname)} ${signedAt} ${digest}`; + const expectedSignedString = `${req.method.toLowerCase()} ${encodeURI( + url.pathname, + )} ${signedAt} ${digest}`; // Check if this matches the signature return crypto.subtle.verify( diff --git a/packages/federation/entities/collection.ts b/packages/sdk/entities/collection.ts similarity index 100% rename from packages/federation/entities/collection.ts rename to packages/sdk/entities/collection.ts diff --git a/packages/federation/entities/contentformat.ts b/packages/sdk/entities/contentformat.ts similarity index 100% rename from packages/federation/entities/contentformat.ts rename to packages/sdk/entities/contentformat.ts diff --git a/packages/federation/entities/delete.ts b/packages/sdk/entities/delete.ts similarity index 100% rename from packages/federation/entities/delete.ts rename to packages/sdk/entities/delete.ts diff --git a/packages/federation/entities/entity.ts b/packages/sdk/entities/entity.ts similarity index 100% rename from packages/federation/entities/entity.ts rename to packages/sdk/entities/entity.ts diff --git a/packages/federation/entities/extensions/likes.ts b/packages/sdk/entities/extensions/likes.ts similarity index 100% rename from packages/federation/entities/extensions/likes.ts rename to packages/sdk/entities/extensions/likes.ts diff --git a/packages/federation/entities/extensions/polls.ts b/packages/sdk/entities/extensions/polls.ts similarity index 100% rename from packages/federation/entities/extensions/polls.ts rename to packages/sdk/entities/extensions/polls.ts diff --git a/packages/federation/entities/extensions/reactions.ts b/packages/sdk/entities/extensions/reactions.ts similarity index 100% rename from packages/federation/entities/extensions/reactions.ts rename to packages/sdk/entities/extensions/reactions.ts diff --git a/packages/federation/entities/extensions/reports.ts b/packages/sdk/entities/extensions/reports.ts similarity index 100% rename from packages/federation/entities/extensions/reports.ts rename to packages/sdk/entities/extensions/reports.ts diff --git a/packages/federation/entities/extensions/share.ts b/packages/sdk/entities/extensions/share.ts similarity index 100% rename from packages/federation/entities/extensions/share.ts rename to packages/sdk/entities/extensions/share.ts diff --git a/packages/federation/entities/follow.ts b/packages/sdk/entities/follow.ts similarity index 100% rename from packages/federation/entities/follow.ts rename to packages/sdk/entities/follow.ts diff --git a/packages/federation/entities/index.ts b/packages/sdk/entities/index.ts similarity index 100% rename from packages/federation/entities/index.ts rename to packages/sdk/entities/index.ts diff --git a/packages/federation/entities/instancemetadata.ts b/packages/sdk/entities/instancemetadata.ts similarity index 100% rename from packages/federation/entities/instancemetadata.ts rename to packages/sdk/entities/instancemetadata.ts diff --git a/packages/federation/entities/note.ts b/packages/sdk/entities/note.ts similarity index 100% rename from packages/federation/entities/note.ts rename to packages/sdk/entities/note.ts diff --git a/packages/federation/entities/user.ts b/packages/sdk/entities/user.ts similarity index 100% rename from packages/federation/entities/user.ts rename to packages/sdk/entities/user.ts diff --git a/packages/federation/http.ts b/packages/sdk/http.ts similarity index 100% rename from packages/federation/http.ts rename to packages/sdk/http.ts diff --git a/packages/federation/inbox-processor.ts b/packages/sdk/inbox-processor.ts similarity index 96% rename from packages/federation/inbox-processor.ts rename to packages/sdk/inbox-processor.ts index 647ce820..6c6e000a 100644 --- a/packages/federation/inbox-processor.ts +++ b/packages/sdk/inbox-processor.ts @@ -10,7 +10,7 @@ type MaybePromise = T | Promise; /** * @example * const jsonData = { ... }; - * const processor = new EntitySorter(jsonData) + * const processor = await new EntitySorter(jsonData) * .on(User, async (user) => { * // Do something with the user * }) diff --git a/packages/federation/package.json b/packages/sdk/package.json similarity index 100% rename from packages/federation/package.json rename to packages/sdk/package.json diff --git a/packages/federation/regex.ts b/packages/sdk/regex.ts similarity index 100% rename from packages/federation/regex.ts rename to packages/sdk/regex.ts diff --git a/packages/federation/schemas/collection.ts b/packages/sdk/schemas/collection.ts similarity index 100% rename from packages/federation/schemas/collection.ts rename to packages/sdk/schemas/collection.ts diff --git a/packages/federation/schemas/common.ts b/packages/sdk/schemas/common.ts similarity index 100% rename from packages/federation/schemas/common.ts rename to packages/sdk/schemas/common.ts diff --git a/packages/federation/schemas/contentformat.ts b/packages/sdk/schemas/contentformat.ts similarity index 100% rename from packages/federation/schemas/contentformat.ts rename to packages/sdk/schemas/contentformat.ts diff --git a/packages/federation/schemas/delete.ts b/packages/sdk/schemas/delete.ts similarity index 100% rename from packages/federation/schemas/delete.ts rename to packages/sdk/schemas/delete.ts diff --git a/packages/federation/schemas/entity.ts b/packages/sdk/schemas/entity.ts similarity index 100% rename from packages/federation/schemas/entity.ts rename to packages/sdk/schemas/entity.ts diff --git a/packages/federation/schemas/extensions/emojis.ts b/packages/sdk/schemas/extensions/emojis.ts similarity index 100% rename from packages/federation/schemas/extensions/emojis.ts rename to packages/sdk/schemas/extensions/emojis.ts diff --git a/packages/federation/schemas/extensions/groups.ts b/packages/sdk/schemas/extensions/groups.ts similarity index 100% rename from packages/federation/schemas/extensions/groups.ts rename to packages/sdk/schemas/extensions/groups.ts diff --git a/packages/federation/schemas/extensions/likes.ts b/packages/sdk/schemas/extensions/likes.ts similarity index 100% rename from packages/federation/schemas/extensions/likes.ts rename to packages/sdk/schemas/extensions/likes.ts diff --git a/packages/federation/schemas/extensions/migration.ts b/packages/sdk/schemas/extensions/migration.ts similarity index 100% rename from packages/federation/schemas/extensions/migration.ts rename to packages/sdk/schemas/extensions/migration.ts diff --git a/packages/federation/schemas/extensions/polls.ts b/packages/sdk/schemas/extensions/polls.ts similarity index 100% rename from packages/federation/schemas/extensions/polls.ts rename to packages/sdk/schemas/extensions/polls.ts diff --git a/packages/federation/schemas/extensions/reactions.ts b/packages/sdk/schemas/extensions/reactions.ts similarity index 100% rename from packages/federation/schemas/extensions/reactions.ts rename to packages/sdk/schemas/extensions/reactions.ts diff --git a/packages/federation/schemas/extensions/reports.ts b/packages/sdk/schemas/extensions/reports.ts similarity index 100% rename from packages/federation/schemas/extensions/reports.ts rename to packages/sdk/schemas/extensions/reports.ts diff --git a/packages/federation/schemas/extensions/share.ts b/packages/sdk/schemas/extensions/share.ts similarity index 100% rename from packages/federation/schemas/extensions/share.ts rename to packages/sdk/schemas/extensions/share.ts diff --git a/packages/federation/schemas/extensions/vanity.ts b/packages/sdk/schemas/extensions/vanity.ts similarity index 100% rename from packages/federation/schemas/extensions/vanity.ts rename to packages/sdk/schemas/extensions/vanity.ts diff --git a/packages/federation/schemas/follow.ts b/packages/sdk/schemas/follow.ts similarity index 100% rename from packages/federation/schemas/follow.ts rename to packages/sdk/schemas/follow.ts diff --git a/packages/federation/schemas/index.ts b/packages/sdk/schemas/index.ts similarity index 100% rename from packages/federation/schemas/index.ts rename to packages/sdk/schemas/index.ts diff --git a/packages/federation/schemas/instance.ts b/packages/sdk/schemas/instance.ts similarity index 100% rename from packages/federation/schemas/instance.ts rename to packages/sdk/schemas/instance.ts diff --git a/packages/federation/schemas/note.ts b/packages/sdk/schemas/note.ts similarity index 100% rename from packages/federation/schemas/note.ts rename to packages/sdk/schemas/note.ts diff --git a/packages/federation/schemas/user.ts b/packages/sdk/schemas/user.ts similarity index 100% rename from packages/federation/schemas/user.ts rename to packages/sdk/schemas/user.ts diff --git a/packages/federation/schemas/webfinger.ts b/packages/sdk/schemas/webfinger.ts similarity index 100% rename from packages/federation/schemas/webfinger.ts rename to packages/sdk/schemas/webfinger.ts diff --git a/packages/federation/types.ts b/packages/sdk/types.ts similarity index 100% rename from packages/federation/types.ts rename to packages/sdk/types.ts diff --git a/types/api.ts b/types/api.ts index 38953d73..444d4b4e 100644 --- a/types/api.ts +++ b/types/api.ts @@ -1,10 +1,10 @@ -import type * as VersiaEntities from "@versia/sdk/entities"; import type { SocketAddress } from "bun"; import type { Hono } from "hono"; import type { RouterRoute } from "hono/types"; import type { z } from "zod"; import type { ConfigSchema } from "~/classes/config/schema"; import type { AuthData } from "~/classes/functions/user"; +import type * as VersiaEntities from "~/packages/sdk/entities"; export type HttpVerb = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS"; diff --git a/utils/content_types.ts b/utils/content_types.ts index eebe7791..f1f0daa1 100644 --- a/utils/content_types.ts +++ b/utils/content_types.ts @@ -1,8 +1,8 @@ -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"; +import type { ContentFormatSchema } from "~/packages/sdk/schemas"; export const getBestContentType = ( content?: z.infer | null, From 1d301d72ae6c6b5f41e26594d1a7f002125d869c Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Wed, 9 Apr 2025 02:15:00 +0200 Subject: [PATCH 6/6] fix: :rotating_light: Fix DeepSource linter warnings --- api/api/v1/statuses/[id]/index.ts | 2 +- api/api/v1/statuses/index.ts | 2 +- api/users/[uuid]/index.ts | 2 +- api/well-known/webfinger/index.ts | 14 +++++++------- bun.lock | 10 +++++----- classes/database/user.ts | 9 +++------ classes/functions/status.ts | 8 +++----- classes/inbox/processor.ts | 24 ++++++++++++++---------- packages/sdk/http.ts | 2 +- packages/sdk/regex.ts | 6 +++--- 10 files changed, 39 insertions(+), 40 deletions(-) diff --git a/api/api/v1/statuses/[id]/index.ts b/api/api/v1/statuses/[id]/index.ts index 52d65441..a6fa0873 100644 --- a/api/api/v1/statuses/[id]/index.ts +++ b/api/api/v1/statuses/[id]/index.ts @@ -242,7 +242,7 @@ export default apiRoute((app) => { : undefined; const parsedMentions = statusText - ? await parseTextMentions(statusText, user) + ? await parseTextMentions(statusText) : []; const parsedEmojis = statusText diff --git a/api/api/v1/statuses/index.ts b/api/api/v1/statuses/index.ts index 0c6c0dc5..a65b893f 100644 --- a/api/api/v1/statuses/index.ts +++ b/api/api/v1/statuses/index.ts @@ -192,7 +192,7 @@ export default apiRoute((app) => : undefined; const parsedMentions = status - ? await parseTextMentions(status, user) + ? await parseTextMentions(status) : []; const parsedEmojis = status diff --git a/api/users/[uuid]/index.ts b/api/users/[uuid]/index.ts index a03d9eb4..c4c2ad22 100644 --- a/api/users/[uuid]/index.ts +++ b/api/users/[uuid]/index.ts @@ -43,7 +43,7 @@ export default apiRoute((app) => }), handleZodError, ), - // @ts-expect-error + // @ts-expect-error idk why this is happening and I don't care async (context) => { const { uuid } = context.req.valid("param"); diff --git a/api/well-known/webfinger/index.ts b/api/well-known/webfinger/index.ts index 60da40a4..158164b5 100644 --- a/api/well-known/webfinger/index.ts +++ b/api/well-known/webfinger/index.ts @@ -8,6 +8,7 @@ import { import { getLogger } from "@logtape/logtape"; import { User } from "@versia/kit/db"; import { Users } from "@versia/kit/tables"; +import { FederationRequester } from "@versia/sdk/http"; import { and, eq, isNull } from "drizzle-orm"; import { describeRoute } from "hono-openapi"; import { resolver, validator } from "hono-openapi/zod"; @@ -84,13 +85,12 @@ export default apiRoute((app) => if (config.federation.bridge) { try { - activityPubUrl = - await User.federationRequester.resolveWebFinger( - user.data.username, - config.http.base_url.host, - "application/activity+json", - config.federation.bridge.url.origin, - ); + activityPubUrl = await FederationRequester.resolveWebFinger( + user.data.username, + config.http.base_url.host, + "application/activity+json", + config.federation.bridge.url.origin, + ); } catch (e) { const error = e as ApiError; diff --git a/bun.lock b/bun.lock index 53a168db..6d699e09 100644 --- a/bun.lock +++ b/bun.lock @@ -95,10 +95,6 @@ "@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", @@ -109,6 +105,10 @@ "zod-validation-error": "^3.3.0", }, }, + "packages/sdk": { + "name": "@versia/sdk", + "version": "0.0.1", + }, }, "trustedDependencies": [ "sharp", @@ -574,7 +574,7 @@ "@versia/kit": ["@versia/kit@workspace:packages/plugin-kit"], - "@versia/sdk": ["@versia/sdk@workspace:packages/federation"], + "@versia/sdk": ["@versia/sdk@workspace:packages/sdk"], "@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.3", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-IYSLEQj4LgZZuoVpdSUCw3dIynTWQgPlaRP6iAvMle4My0HdYwr5g5wQAfwOeHQBmYwEkqF70nRpSilr6PoUDg=="], diff --git a/classes/database/user.ts b/classes/database/user.ts index 4458936c..d57f6236 100644 --- a/classes/database/user.ts +++ b/classes/database/user.ts @@ -347,13 +347,10 @@ export class User extends BaseInterface { hostname: string, ): Promise { try { - return User.federationRequester.resolveWebFinger( - username, - hostname, - ); + return FederationRequester.resolveWebFinger(username, hostname); } catch { try { - return User.federationRequester.resolveWebFinger( + return FederationRequester.resolveWebFinger( username, hostname, "application/activity+json", @@ -854,7 +851,7 @@ export class User extends BaseInterface { const user = await User.insert({ id: randomUUIDv7(), - username: username, + username, displayName: username, password: options?.password ? await bunPassword.hash(options.password) diff --git a/classes/functions/status.ts b/classes/functions/status.ts index 3992df86..68827dc5 100644 --- a/classes/functions/status.ts +++ b/classes/functions/status.ts @@ -3,6 +3,7 @@ import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization"; import markdownItTaskLists from "@hackmd/markdown-it-task-lists"; import { type Note, User, db } from "@versia/kit/db"; import { Instances, Users } from "@versia/kit/tables"; +import { FederationRequester } from "@versia/sdk/http"; import { and, eq, inArray, isNull, or, sql } from "drizzle-orm"; import linkifyHtml from "linkify-html"; import { @@ -222,10 +223,7 @@ export const findManyNotes = async ( * @param text The text to parse mentions from. * @returns An array of users mentioned in the text. */ -export const parseTextMentions = async ( - text: string, - author: User, -): Promise => { +export const parseTextMentions = async (text: string): Promise => { const mentionedPeople = [...text.matchAll(mentionValidator)]; if (mentionedPeople.length === 0) { return []; @@ -276,7 +274,7 @@ export const parseTextMentions = async ( // Resolve remote mentions not in database for (const person of notFoundRemoteUsers) { - const url = await (await author.federationRequester).resolveWebFinger( + const url = await FederationRequester.resolveWebFinger( person[1] ?? "", person[2] ?? "", ); diff --git a/classes/inbox/processor.ts b/classes/inbox/processor.ts index 44bfb815..3baaf383 100644 --- a/classes/inbox/processor.ts +++ b/classes/inbox/processor.ts @@ -185,19 +185,19 @@ export class InboxProcessor { await Note.fromVersia(n); }) .on(VersiaEntities.Follow, (f) => { - this.processFollowRequest(f); + InboxProcessor.processFollowRequest(f); }) .on(VersiaEntities.FollowAccept, (f) => { - this.processFollowAccept(f); + InboxProcessor.processFollowAccept(f); }) .on(VersiaEntities.FollowReject, (f) => { - this.processFollowReject(f); + InboxProcessor.processFollowReject(f); }) .on(VersiaEntities.Like, (l) => { - this.processLikeRequest(l); + InboxProcessor.processLikeRequest(l); }) .on(VersiaEntities.Delete, (d) => { - this.processDelete(d); + InboxProcessor.processDelete(d); }) .on(VersiaEntities.User, async (u) => { await User.fromVersia(u); @@ -216,7 +216,7 @@ export class InboxProcessor { * @param {VersiaFollow} follow - The Follow entity to process. * @returns {Promise} */ - private async processFollowRequest( + private static async processFollowRequest( follow: VersiaEntities.Follow, ): Promise { const author = await User.resolve(follow.data.author); @@ -264,7 +264,7 @@ export class InboxProcessor { * @param {VersiaFollowAccept} followAccept - The FollowAccept entity to process. * @returns {Promise} */ - private async processFollowAccept( + private static async processFollowAccept( followAccept: VersiaEntities.FollowAccept, ): Promise { const author = await User.resolve(followAccept.data.author); @@ -299,7 +299,7 @@ export class InboxProcessor { * @param {VersiaFollowReject} followReject - The FollowReject entity to process. * @returns {Promise} */ - private async processFollowReject( + private static async processFollowReject( followReject: VersiaEntities.FollowReject, ): Promise { const author = await User.resolve(followReject.data.author); @@ -334,7 +334,9 @@ export class InboxProcessor { * @param {VersiaDelete} delete_ - The Delete entity to process. * @returns {Promise} */ // JS doesn't allow the use of `delete` as a variable name - public async processDelete(delete_: VersiaEntities.Delete): Promise { + public static async processDelete( + delete_: VersiaEntities.Delete, + ): Promise { const toDelete = delete_.data.deleted; const author = delete_.data.author @@ -403,7 +405,9 @@ export class InboxProcessor { * @param {VersiaLikeExtension} like - The Like entity to process. * @returns {Promise} */ - private async processLikeRequest(like: VersiaEntities.Like): Promise { + private static async processLikeRequest( + like: VersiaEntities.Like, + ): Promise { const author = await User.resolve(like.data.author); const likedNote = await Note.resolve(like.data.liked); diff --git a/packages/sdk/http.ts b/packages/sdk/http.ts index 65b2b2ae..86e8a43b 100644 --- a/packages/sdk/http.ts +++ b/packages/sdk/http.ts @@ -158,7 +158,7 @@ export class FederationRequester { * Attempt to resolve a webfinger URL to a User * @returns {Promise} The resolved User or null if not found */ - public async resolveWebFinger( + public static async resolveWebFinger( username: string, hostname: string, contentType = "application/json", diff --git a/packages/sdk/regex.ts b/packages/sdk/regex.ts index e08de1ad..602eccfc 100644 --- a/packages/sdk/regex.ts +++ b/packages/sdk/regex.ts @@ -10,7 +10,7 @@ import { oneOrMore, } from "magic-regexp"; -export const semverRegex: RegExp = new RegExp( +export const semverRegex = 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, ); @@ -57,8 +57,8 @@ export const emojiRegex: RegExp = createRegExp( // 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()); + const date = new Date(val); + return !Number.isNaN(date.valueOf()); }; export const ianaTimezoneRegex = /^(?:[A-Za-z]+(?:\/[A-Za-z_]+)+|UTC)$/;