refactor(federation): ♻️ Rewrite federation SDK

This commit is contained in:
Jesse Wierzbinski 2025-04-08 16:01:10 +02:00
parent ad1dc13a51
commit d638610361
No known key found for this signature in database
72 changed files with 2137 additions and 738 deletions

View file

@ -49,7 +49,6 @@ export default apiRoute((app) =>
), ),
async (context) => { async (context) => {
const { acct } = context.req.valid("query"); const { acct } = context.req.valid("query");
const { user } = context.get("auth");
// Check if acct is matching format username@domain.com or @username@domain.com // Check if acct is matching format username@domain.com or @username@domain.com
const { username, domain } = parseUserAddress(acct); const { username, domain } = parseUserAddress(acct);
@ -93,9 +92,7 @@ export default apiRoute((app) =>
} }
// Fetch from remote instance // Fetch from remote instance
const manager = await (user ?? User).getFederationRequester(); const uri = await User.webFinger(username, domain);
const uri = await User.webFinger(manager, username, domain);
if (!uri) { if (!uri) {
throw ApiError.accountNotFound(); throw ApiError.accountNotFound();

View file

@ -91,9 +91,7 @@ export default apiRoute((app) =>
const accounts: User[] = []; const accounts: User[] = [];
if (resolve && domain) { if (resolve && domain) {
const manager = await (user ?? User).getFederationRequester(); const uri = await User.webFinger(username, domain);
const uri = await User.webFinger(manager, username, domain);
if (uri) { if (uri) {
const resolvedUser = await User.resolve(uri); const resolvedUser = await User.resolve(uri);

View file

@ -5,6 +5,7 @@ import { Account as AccountSchema, zBoolean } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { Emoji, Media, User } from "@versia/kit/db"; import { Emoji, Media, User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import * as VersiaEntities from "@versia/sdk/entities";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod"; import { resolver, validator } from "hono-openapi/zod";
@ -186,12 +187,14 @@ export default apiRoute((app) =>
if (note && self.source) { if (note && self.source) {
self.source.note = note; self.source.note = note;
self.note = await contentToHtml({ self.note = await contentToHtml(
new VersiaEntities.TextContentFormat({
"text/markdown": { "text/markdown": {
content: note, content: note,
remote: false, remote: false,
}, },
}); }),
);
} }
if (source?.privacy) { if (source?.privacy) {
@ -275,23 +278,23 @@ export default apiRoute((app) =>
for (const field of fields_attributes) { for (const field of fields_attributes) {
// Can be Markdown or plaintext, also has emojis // Can be Markdown or plaintext, also has emojis
const parsedName = await contentToHtml( const parsedName = await contentToHtml(
{ new VersiaEntities.TextContentFormat({
"text/markdown": { "text/markdown": {
content: field.name, content: field.name,
remote: false, remote: false,
}, },
}, }),
undefined, undefined,
true, true,
); );
const parsedValue = await contentToHtml( const parsedValue = await contentToHtml(
{ new VersiaEntities.TextContentFormat({
"text/markdown": { "text/markdown": {
content: field.value, content: field.value,
remote: false, remote: false,
}, },
}, }),
undefined, undefined,
true, true,
); );

View file

@ -14,6 +14,7 @@ import {
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { Media } from "@versia/kit/db"; import { Media } from "@versia/kit/db";
import * as VersiaEntities from "@versia/sdk/entities";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod"; import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod"; import { z } from "zod";
@ -228,12 +229,12 @@ export default apiRoute((app) => {
const newNote = await note.updateFromData({ const newNote = await note.updateFromData({
author: user, author: user,
content: statusText content: statusText
? { ? new VersiaEntities.TextContentFormat({
[content_type]: { [content_type]: {
content: statusText, content: statusText,
remote: false, remote: false,
}, },
} })
: undefined, : undefined,
isSensitive: sensitive, isSensitive: sensitive,
spoilerText: spoiler_text, spoilerText: spoiler_text,

View file

@ -8,6 +8,7 @@ import {
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { Media, Note } from "@versia/kit/db"; import { Media, Note } from "@versia/kit/db";
import * as VersiaEntities from "@versia/sdk/entities";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod"; import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod"; import { z } from "zod";
@ -176,12 +177,12 @@ export default apiRoute((app) =>
const newNote = await Note.fromData({ const newNote = await Note.fromData({
author: user, author: user,
content: { content: new VersiaEntities.TextContentFormat({
[content_type]: { [content_type]: {
content: status ?? "", content: status ?? "",
remote: false, remote: false,
}, },
}, }),
visibility, visibility,
isSensitive: sensitive ?? false, isSensitive: sensitive ?? false,
spoilerText: spoiler_text ?? "", spoilerText: spoiler_text ?? "",

View file

@ -198,15 +198,7 @@ export default apiRoute((app) =>
} }
if (resolve && domain) { if (resolve && domain) {
const manager = await ( const uri = await User.webFinger(username, domain);
user ?? User
).getFederationRequester();
const uri = await User.webFinger(
manager,
username,
domain,
);
if (uri) { if (uri) {
const newUser = await User.resolve(uri); const newUser = await User.resolve(uri);

View file

@ -1,5 +1,4 @@
import { apiRoute, handleZodError } from "@/api"; import { apiRoute, handleZodError } from "@/api";
import type { Entity } from "@versia/federation/types";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
import { validator } from "hono-openapi/zod"; import { validator } from "hono-openapi/zod";
import { z } from "zod"; import { z } from "zod";
@ -33,7 +32,7 @@ export default apiRoute((app) =>
handleZodError, handleZodError,
), ),
async (context) => { async (context) => {
const body: Entity = await context.req.valid("json"); const body = await context.req.valid("json");
await inboxQueue.add(InboxJobType.ProcessEntity, { await inboxQueue.add(InboxJobType.ProcessEntity, {
data: body, data: body,

View file

@ -1,8 +1,8 @@
import { apiRoute, handleZodError } from "@/api"; import { apiRoute, handleZodError } from "@/api";
import { Status as StatusSchema } from "@versia/client/schemas"; import { Status as StatusSchema } from "@versia/client/schemas";
import { LikeExtension as LikeSchema } from "@versia/federation/schemas";
import { Like, User } from "@versia/kit/db"; import { Like, User } from "@versia/kit/db";
import { Likes } from "@versia/kit/tables"; import { Likes } from "@versia/kit/tables";
import { LikeSchema } from "@versia/sdk/schemas";
import { and, eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod"; import { resolver, validator } from "hono-openapi/zod";

View file

@ -1,8 +1,8 @@
import { apiRoute, handleZodError } from "@/api"; import { apiRoute, handleZodError } from "@/api";
import { Status as StatusSchema } from "@versia/client/schemas"; import { Status as StatusSchema } from "@versia/client/schemas";
import { Note as NoteSchema } from "@versia/federation/schemas";
import { Note } from "@versia/kit/db"; import { Note } from "@versia/kit/db";
import { Notes } from "@versia/kit/tables"; import { Notes } from "@versia/kit/tables";
import { NoteSchema } from "@versia/sdk/schemas";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod"; import { resolver, validator } from "hono-openapi/zod";

View file

@ -1,9 +1,9 @@
import { apiRoute, handleZodError } from "@/api"; import { apiRoute, handleZodError } from "@/api";
import { Status as StatusSchema } from "@versia/client/schemas"; 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 { Note, db } from "@versia/kit/db";
import { Notes } from "@versia/kit/tables"; 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 { and, eq, inArray } from "drizzle-orm";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod"; import { resolver, validator } from "hono-openapi/zod";
@ -88,39 +88,39 @@ export default apiRoute((app) =>
), ),
); );
const uriCollection = { const uriCollection = new VersiaEntities.URICollection({
author: note.author.getUri().href, author: note.author.getUri(),
first: new URL( first: new URL(
`/notes/${note.id}/quotes?offset=0`, `/notes/${note.id}/quotes?offset=0`,
config.http.base_url, config.http.base_url,
).href, ),
last: last:
replyCount > limit replyCount > limit
? new URL( ? new URL(
`/notes/${note.id}/quotes?offset=${replyCount - limit}`, `/notes/${note.id}/quotes?offset=${replyCount - limit}`,
config.http.base_url, config.http.base_url,
).href )
: new URL( : new URL(
`/notes/${note.id}/quotes`, `/notes/${note.id}/quotes`,
config.http.base_url, config.http.base_url,
).href, ),
next: next:
offset + limit < replyCount offset + limit < replyCount
? new URL( ? new URL(
`/notes/${note.id}/quotes?offset=${offset + limit}`, `/notes/${note.id}/quotes?offset=${offset + limit}`,
config.http.base_url, config.http.base_url,
).href )
: null, : null,
previous: previous:
offset - limit >= 0 offset - limit >= 0
? new URL( ? new URL(
`/notes/${note.id}/quotes?offset=${offset - limit}`, `/notes/${note.id}/quotes?offset=${offset - limit}`,
config.http.base_url, config.http.base_url,
).href )
: null, : null,
total: replyCount, total: replyCount,
items: replies.map((reply) => reply.getUri().href), items: replies.map((reply) => reply.getUri()),
} satisfies URICollection; });
// If base_url uses https and request uses http, rewrite request to use https // If base_url uses https and request uses http, rewrite request to use https
// This fixes reverse proxy errors // This fixes reverse proxy errors

View file

@ -1,9 +1,9 @@
import { apiRoute, handleZodError } from "@/api"; import { apiRoute, handleZodError } from "@/api";
import { Status as StatusSchema } from "@versia/client/schemas"; 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 { Note, db } from "@versia/kit/db";
import { Notes } from "@versia/kit/tables"; 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 { and, eq, inArray } from "drizzle-orm";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod"; import { resolver, validator } from "hono-openapi/zod";
@ -86,39 +86,39 @@ export default apiRoute((app) =>
), ),
); );
const uriCollection = { const uriCollection = new VersiaEntities.URICollection({
author: note.author.getUri().href, author: note.author.getUri(),
first: new URL( first: new URL(
`/notes/${note.id}/replies?offset=0`, `/notes/${note.id}/replies?offset=0`,
config.http.base_url, config.http.base_url,
).href, ),
last: last:
replyCount > limit replyCount > limit
? new URL( ? new URL(
`/notes/${note.id}/replies?offset=${replyCount - limit}`, `/notes/${note.id}/replies?offset=${replyCount - limit}`,
config.http.base_url, config.http.base_url,
).href )
: new URL( : new URL(
`/notes/${note.id}/replies`, `/notes/${note.id}/replies`,
config.http.base_url, config.http.base_url,
).href, ),
next: next:
offset + limit < replyCount offset + limit < replyCount
? new URL( ? new URL(
`/notes/${note.id}/replies?offset=${offset + limit}`, `/notes/${note.id}/replies?offset=${offset + limit}`,
config.http.base_url, config.http.base_url,
).href )
: null, : null,
previous: previous:
offset - limit >= 0 offset - limit >= 0
? new URL( ? new URL(
`/notes/${note.id}/replies?offset=${offset - limit}`, `/notes/${note.id}/replies?offset=${offset - limit}`,
config.http.base_url, config.http.base_url,
).href )
: null, : null,
total: replyCount, total: replyCount,
items: replies.map((reply) => reply.getUri().href), items: replies.map((reply) => reply.getUri()),
} satisfies URICollection; });
// If base_url uses https and request uses http, rewrite request to use https // If base_url uses https and request uses http, rewrite request to use https
// This fixes reverse proxy errors // This fixes reverse proxy errors

View file

@ -1,10 +1,10 @@
import { apiRoute, handleZodError } from "@/api"; import { apiRoute, handleZodError } from "@/api";
import type { Entity } from "@versia/federation/types";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod"; import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod"; import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { InboxJobType, inboxQueue } from "~/classes/queues/inbox"; import { InboxJobType, inboxQueue } from "~/classes/queues/inbox";
import type { JSONObject } from "~/packages/federation/types";
export default apiRoute((app) => export default apiRoute((app) =>
app.post( app.post(
@ -89,7 +89,7 @@ export default apiRoute((app) =>
), ),
validator("json", z.any(), handleZodError), validator("json", z.any(), handleZodError),
async (context) => { async (context) => {
const body: Entity = await context.req.valid("json"); const body: JSONObject = await context.req.valid("json");
await inboxQueue.add(InboxJobType.ProcessEntity, { await inboxQueue.add(InboxJobType.ProcessEntity, {
data: body, data: body,

View file

@ -1,6 +1,6 @@
import { apiRoute, handleZodError } from "@/api"; import { apiRoute, handleZodError } from "@/api";
import { User as UserSchema } from "@versia/federation/schemas";
import { User } from "@versia/kit/db"; import { User } from "@versia/kit/db";
import { UserSchema } from "@versia/sdk/schemas";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod"; import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod"; import { z } from "zod";
@ -43,6 +43,7 @@ export default apiRoute((app) =>
}), }),
handleZodError, handleZodError,
), ),
// @ts-expect-error
async (context) => { async (context) => {
const { uuid } = context.req.valid("param"); const { uuid } = context.req.valid("param");

View file

@ -1,10 +1,8 @@
import { apiRoute, handleZodError } from "@/api"; 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 { Note, User, db } from "@versia/kit/db";
import { Notes } from "@versia/kit/tables"; 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 { and, eq, inArray } from "drizzle-orm";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod"; 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( first: new URL(
`/users/${uuid}/outbox?page=1`, `/users/${uuid}/outbox?page=1`,
config.http.base_url, config.http.base_url,
).toString(), ),
last: new URL( last: new URL(
`/users/${uuid}/outbox?page=${Math.ceil( `/users/${uuid}/outbox?page=${Math.ceil(
totalNotes / NOTES_PER_PAGE, totalNotes / NOTES_PER_PAGE,
)}`, )}`,
config.http.base_url, config.http.base_url,
).toString(), ),
total: totalNotes, total: totalNotes,
author: author.getUri().toString(), author: author.getUri(),
next: next:
notes.length === NOTES_PER_PAGE notes.length === NOTES_PER_PAGE
? new URL( ? new URL(
`/users/${uuid}/outbox?page=${pageNumber + 1}`, `/users/${uuid}/outbox?page=${pageNumber + 1}`,
config.http.base_url, config.http.base_url,
).toString() )
: null, : null,
previous: previous:
pageNumber > 1 pageNumber > 1
? new URL( ? new URL(
`/users/${uuid}/outbox?page=${pageNumber - 1}`, `/users/${uuid}/outbox?page=${pageNumber - 1}`,
config.http.base_url, config.http.base_url,
).toString() )
: null, : null,
items: notes.map((note) => note.toVersia()), items: notes.map((note) => note.toVersia()),
}; });
const { headers } = await author.sign( const { headers } = await author.sign(
json, json,

View file

@ -1,8 +1,8 @@
import { apiRoute } from "@/api"; import { apiRoute } from "@/api";
import { urlToContentFormat } from "@/content_types"; import { urlToContentFormat } from "@/content_types";
import { InstanceMetadata as InstanceMetadataSchema } from "@versia/federation/schemas";
import { User } from "@versia/kit/db"; import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import { InstanceMetadataSchema } from "@versia/sdk/schemas";
import { asc } from "drizzle-orm"; import { asc } from "drizzle-orm";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod"; import { resolver } from "hono-openapi/zod";

View file

@ -6,10 +6,9 @@ import {
webfingerMention, webfingerMention,
} from "@/api"; } from "@/api";
import { getLogger } from "@logtape/logtape"; import { getLogger } from "@logtape/logtape";
import type { ResponseError } from "@versia/federation";
import { WebFinger } from "@versia/federation/schemas";
import { User } from "@versia/kit/db"; import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import { WebFingerSchema } from "@versia/sdk/schemas";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod"; import { resolver, validator } from "hono-openapi/zod";
@ -28,7 +27,7 @@ export default apiRoute((app) =>
description: "User information", description: "User information",
content: { content: {
"application/json": { "application/json": {
schema: resolver(WebFinger), schema: resolver(WebFingerSchema),
}, },
}, },
}, },
@ -81,23 +80,22 @@ export default apiRoute((app) =>
throw ApiError.accountNotFound(); throw ApiError.accountNotFound();
} }
let activityPubUrl = ""; let activityPubUrl: URL | null = null;
if (config.federation.bridge) { if (config.federation.bridge) {
const manager = await User.getFederationRequester();
try { try {
activityPubUrl = await manager.webFinger( activityPubUrl =
await User.federationRequester.resolveWebFinger(
user.data.username, user.data.username,
config.http.base_url.host, config.http.base_url.host,
"application/activity+json", "application/activity+json",
config.federation.bridge.url.origin, config.federation.bridge.url.origin,
); );
} catch (e) { } catch (e) {
const error = e as ResponseError; const error = e as ApiError;
getLogger(["federation", "bridge"]) 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", rel: "self",
type: "application/activity+json", type: "application/activity+json",
href: activityPubUrl, href: activityPubUrl.href,
} }
: undefined, : undefined,
{ {

View file

@ -20,8 +20,8 @@
"@scalar/hono-api-reference": "^0.8.0", "@scalar/hono-api-reference": "^0.8.0",
"@sentry/bun": "^9.11.0", "@sentry/bun": "^9.11.0",
"@versia/client": "workspace:*", "@versia/client": "workspace:*",
"@versia/federation": "^0.2.1",
"@versia/kit": "workspace:*", "@versia/kit": "workspace:*",
"@versia/sdk": "workspace:*",
"altcha-lib": "^1.2.0", "altcha-lib": "^1.2.0",
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"bullmq": "^5.47.2", "bullmq": "^5.47.2",
@ -95,6 +95,10 @@
"@badgateway/oauth2-client": "^2.4.2", "@badgateway/oauth2-client": "^2.4.2",
}, },
}, },
"packages/federation": {
"name": "@versia/sdk",
"version": "0.0.1",
},
"packages/plugin-kit": { "packages/plugin-kit": {
"name": "@versia/kit", "name": "@versia/kit",
"version": "0.0.0", "version": "0.0.0",
@ -568,10 +572,10 @@
"@versia/client": ["@versia/client@workspace:packages/client"], "@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/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=="], "@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=="], "@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=="], "@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-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=="], "@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=="], "@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-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=="], "cheerio/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],

View file

@ -1,8 +1,9 @@
import { emojiValidatorWithColons, emojiValidatorWithIdentifiers } from "@/api"; import { emojiValidatorWithColons, emojiValidatorWithIdentifiers } from "@/api";
import type { CustomEmoji } from "@versia/client/schemas"; import type { CustomEmoji } from "@versia/client/schemas";
import type { CustomEmojiExtension } from "@versia/federation/types";
import { type Instance, Media, db } from "@versia/kit/db"; import { type Instance, Media, db } from "@versia/kit/db";
import { Emojis, type Instances, type Medias } from "@versia/kit/tables"; 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 { randomUUIDv7 } from "bun";
import { import {
type InferInsertModel, type InferInsertModel,
@ -130,7 +131,10 @@ export class Emoji extends BaseInterface<typeof Emojis, EmojiType> {
} }
public static async fetchFromRemote( public static async fetchFromRemote(
emojiToFetch: CustomEmojiExtension["emojis"][0], emojiToFetch: {
name: string;
url: z.infer<typeof ImageContentFormatSchema>;
},
instance: Instance, instance: Instance,
): Promise<Emoji> { ): Promise<Emoji> {
const existingEmoji = await Emoji.fromSql( const existingEmoji = await Emoji.fromSql(
@ -189,15 +193,23 @@ export class Emoji extends BaseInterface<typeof Emojis, EmojiType> {
}; };
} }
public toVersia(): CustomEmojiExtension["emojis"][0] { public toVersia(): {
name: string;
url: z.infer<typeof ImageContentFormatSchema>;
} {
return { return {
name: `:${this.data.shortcode}:`, name: `:${this.data.shortcode}:`,
url: this.media.toVersia(), url: this.media.toVersia().data as z.infer<
typeof ImageContentFormatSchema
>,
}; };
} }
public static async fromVersia( public static async fromVersia(
emoji: CustomEmojiExtension["emojis"][0], emoji: {
name: string;
url: z.infer<typeof ImageContentFormatSchema>;
},
instance: Instance, instance: Instance,
): Promise<Emoji> { ): Promise<Emoji> {
// Extracts the shortcode from the emoji name (e.g. :shortcode: -> shortcode) // Extracts the shortcode from the emoji name (e.g. :shortcode: -> shortcode)
@ -209,7 +221,9 @@ export class Emoji extends BaseInterface<typeof Emojis, EmojiType> {
throw new Error("Could not extract shortcode from emoji name"); 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({ return Emoji.insert({
id: randomUUIDv7(), id: randomUUIDv7(),

View file

@ -1,8 +1,7 @@
import { getLogger } from "@logtape/logtape"; 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 { db } from "@versia/kit/db";
import { Instances } from "@versia/kit/tables"; import { Instances } from "@versia/kit/tables";
import * as VersiaEntities from "@versia/sdk/entities";
import { randomUUIDv7 } from "bun"; import { randomUUIDv7 } from "bun";
import chalk from "chalk"; import chalk from "chalk";
import { import {
@ -137,24 +136,20 @@ export class Instance extends BaseInterface<typeof Instances> {
} }
public static async fetchMetadata(url: URL): Promise<{ public static async fetchMetadata(url: URL): Promise<{
metadata: InstanceMetadata; metadata: VersiaEntities.InstanceMetadata;
protocol: "versia" | "activitypub"; protocol: "versia" | "activitypub";
}> { }> {
const origin = new URL(url).origin; const origin = new URL(url).origin;
const wellKnownUrl = new URL("/.well-known/versia", 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 return { metadata, protocol: "versia" };
.get(wellKnownUrl, { } catch {
// @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"))) {
// If the server doesn't have a Versia well-known endpoint, it's not a Versia instance // If the server doesn't have a Versia well-known endpoint, it's not a Versia instance
// Try to resolve ActivityPub metadata instead // Try to resolve ActivityPub metadata instead
const data = await Instance.fetchActivityPubMetadata(url); const data = await Instance.fetchActivityPubMetadata(url);
@ -171,57 +166,35 @@ export class Instance extends BaseInterface<typeof Instances> {
protocol: "activitypub", 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( private static async fetchActivityPubMetadata(
url: URL, url: URL,
): Promise<InstanceMetadata | null> { ): Promise<VersiaEntities.InstanceMetadata | null> {
const origin = new URL(url).origin; const origin = new URL(url).origin;
const wellKnownUrl = new URL("/.well-known/nodeinfo", origin); const wellKnownUrl = new URL("/.well-known/nodeinfo", origin);
// Go to endpoint, then follow the links to the actual metadata // Go to endpoint, then follow the links to the actual metadata
const logger = getLogger(["federation", "resolvers"]); const logger = getLogger(["federation", "resolvers"]);
const requester = await User.getFederationRequester();
try { try {
const { const { json, ok, status } = await fetch(wellKnownUrl, {
raw: response,
ok,
data: wellKnown,
} = await requester
.get<{
links: { rel: string; href: string }[];
}>(wellKnownUrl, {
// @ts-expect-error Bun extension // @ts-expect-error Bun extension
proxy: config.http.proxy_address, proxy: config.http.proxy_address,
}) });
.catch((e) => ({
...(
e as ResponseError<{
links: { rel: string; href: string }[];
}>
).response,
}));
if (!ok) { if (!ok) {
logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold( logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
origin, origin,
)} - HTTP ${response.status}`; )} - HTTP ${status}`;
return null; return null;
} }
const wellKnown = (await json()) as {
links: { rel: string; href: string }[];
};
if (!wellKnown.links) { if (!wellKnown.links) {
logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold( logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
origin, origin,
@ -243,44 +216,32 @@ export class Instance extends BaseInterface<typeof Instances> {
} }
const { const {
raw: metadataResponse, json: json2,
ok: ok2, ok: ok2,
data: metadata, status: status2,
} = await requester } = await fetch(metadataUrl.href, {
.get<{
metadata: {
nodeName?: string;
title?: string;
nodeDescription?: string;
description?: string;
};
software: { version: string };
}>(metadataUrl.href, {
// @ts-expect-error Bun extension // @ts-expect-error Bun extension
proxy: config.http.proxy_address, proxy: config.http.proxy_address,
}) });
.catch((e) => ({
...(
e as ResponseError<{
metadata: {
nodeName?: string;
title?: string;
nodeDescription?: string;
description?: string;
};
software: { version: string };
}>
).response,
}));
if (!ok2) { if (!ok2) {
logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold( logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
origin, origin,
)} - HTTP ${metadataResponse.status}`; )} - HTTP ${status2}`;
return null; 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: name:
metadata.metadata.nodeName || metadata.metadata.title || "", metadata.metadata.nodeName || metadata.metadata.title || "",
description: description:
@ -301,7 +262,7 @@ export class Instance extends BaseInterface<typeof Instances> {
extensions: [], extensions: [],
versions: [], versions: [],
}, },
}; });
} catch (error) { } catch (error) {
logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold( logger.error`Failed to fetch ActivityPub metadata for instance ${chalk.bold(
origin, origin,
@ -340,13 +301,13 @@ export class Instance extends BaseInterface<typeof Instances> {
return Instance.insert({ return Instance.insert({
id: randomUUIDv7(), id: randomUUIDv7(),
baseUrl: host, baseUrl: host,
name: metadata.name, name: metadata.data.name,
version: metadata.software.version, version: metadata.data.software.version,
logo: metadata.logo, logo: metadata.data.logo,
protocol, protocol,
publicKey: metadata.public_key, publicKey: metadata.data.public_key,
inbox: metadata.shared_inbox ?? null, inbox: metadata.data.shared_inbox?.href ?? null,
extensions: metadata.extensions ?? null, extensions: metadata.data.extensions ?? null,
}); });
} }
@ -365,13 +326,13 @@ export class Instance extends BaseInterface<typeof Instances> {
const { metadata, protocol } = output; const { metadata, protocol } = output;
await this.update({ await this.update({
name: metadata.name, name: metadata.data.name,
version: metadata.software.version, version: metadata.data.software.version,
logo: metadata.logo, logo: metadata.data.logo,
protocol, protocol,
publicKey: metadata.public_key, publicKey: metadata.data.public_key,
inbox: metadata.shared_inbox ?? null, inbox: metadata.data.shared_inbox?.href ?? null,
extensions: metadata.extensions ?? null, extensions: metadata.data.extensions ?? null,
}); });
return this; return this;

View file

@ -1,4 +1,3 @@
import type { Delete, LikeExtension } from "@versia/federation/types";
import { db } from "@versia/kit/db"; import { db } from "@versia/kit/db";
import { import {
Likes, Likes,
@ -6,6 +5,7 @@ import {
Notifications, Notifications,
type Users, type Users,
} from "@versia/kit/tables"; } from "@versia/kit/tables";
import * as VersiaEntities from "@versia/sdk/entities";
import { import {
type InferInsertModel, type InferInsertModel,
type InferSelectModel, type InferSelectModel,
@ -149,25 +149,24 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
return new URL(`/likes/${this.data.id}`, config.http.base_url); return new URL(`/likes/${this.data.id}`, config.http.base_url);
} }
public toVersia(): LikeExtension { public toVersia(): VersiaEntities.Like {
return { return new VersiaEntities.Like({
id: this.data.id, id: this.data.id,
author: User.getUri( author: User.getUri(
this.data.liker.id, this.data.liker.id,
this.data.liker.uri ? new URL(this.data.liker.uri) : null, this.data.liker.uri ? new URL(this.data.liker.uri) : null,
).toString(), ),
type: "pub.versia:likes/Like", type: "pub.versia:likes/Like",
created_at: new Date(this.data.createdAt).toISOString(), created_at: new Date(this.data.createdAt).toISOString(),
liked: liked: this.data.liked.uri
this.data.liked.uri ?? ? new URL(this.data.liked.uri)
new URL(`/notes/${this.data.liked.id}`, config.http.base_url) : new URL(`/notes/${this.data.liked.id}`, config.http.base_url),
.href, uri: this.getUri(),
uri: this.getUri().toString(), });
};
} }
public unlikeToVersia(unliker?: User): Delete { public unlikeToVersia(unliker?: User): VersiaEntities.Delete {
return { return new VersiaEntities.Delete({
type: "Delete", type: "Delete",
id: crypto.randomUUID(), id: crypto.randomUUID(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
@ -178,9 +177,9 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
: this.data.liker.uri : this.data.liker.uri
? new URL(this.data.liker.uri) ? new URL(this.data.liker.uri)
: null, : null,
).toString(), ),
deleted_type: "pub.versia:likes/Like", deleted_type: "pub.versia:likes/Like",
deleted: this.getUri().toString(), deleted: this.getUri(),
}; });
} }
} }

View file

@ -1,9 +1,13 @@
import { join } from "node:path"; import { join } from "node:path";
import { mimeLookup } from "@/content_types.ts"; import { mimeLookup } from "@/content_types.ts";
import type { Attachment as AttachmentSchema } from "@versia/client/schemas"; import type { Attachment as AttachmentSchema } from "@versia/client/schemas";
import type { ContentFormat } from "@versia/federation/types";
import { db } from "@versia/kit/db"; import { db } from "@versia/kit/db";
import { Medias } from "@versia/kit/tables"; 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 { S3Client, SHA256, randomUUIDv7, write } from "bun";
import { import {
type InferInsertModel, type InferInsertModel,
@ -202,7 +206,9 @@ export class Media extends BaseInterface<typeof Medias> {
const newAttachment = await Media.insert({ const newAttachment = await Media.insert({
id: randomUUIDv7(), id: randomUUIDv7(),
content, content,
thumbnail: thumbnailContent, thumbnail: thumbnailContent as z.infer<
typeof ImageContentFormatSchema
>,
}); });
if (config.media.conversion.convert_images) { if (config.media.conversion.convert_images) {
@ -234,7 +240,7 @@ export class Media extends BaseInterface<typeof Medias> {
): Promise<Media> { ): Promise<Media> {
const mimeType = await mimeLookup(uri); const mimeType = await mimeLookup(uri);
const content: ContentFormat = { const content: z.infer<typeof ContentFormatSchema> = {
[mimeType]: { [mimeType]: {
content: uri.toString(), content: uri.toString(),
remote: true, remote: true,
@ -303,7 +309,7 @@ export class Media extends BaseInterface<typeof Medias> {
public async updateFromUrl(uri: URL): Promise<void> { public async updateFromUrl(uri: URL): Promise<void> {
const mimeType = await mimeLookup(uri); const mimeType = await mimeLookup(uri);
const content: ContentFormat = { const content: z.infer<typeof ContentFormatSchema> = {
[mimeType]: { [mimeType]: {
content: uri.toString(), content: uri.toString(),
remote: true, remote: true,
@ -333,12 +339,19 @@ export class Media extends BaseInterface<typeof Medias> {
const content = await Media.fileToContentFormat(file, url); const content = await Media.fileToContentFormat(file, url);
await this.update({ await this.update({
thumbnail: content, thumbnail: content as z.infer<typeof ImageContentFormatSchema>,
}); });
} }
public async updateMetadata( public async updateMetadata(
metadata: Partial<Omit<ContentFormat[keyof ContentFormat], "content">>, metadata: Partial<
Omit<
z.infer<typeof ContentFormatSchema>[keyof z.infer<
typeof ContentFormatSchema
>],
"content"
>
>,
): Promise<void> { ): Promise<void> {
const content = this.data.content; const content = this.data.content;
@ -447,7 +460,7 @@ export class Media extends BaseInterface<typeof Medias> {
options?: Partial<{ options?: Partial<{
description: string; description: string;
}>, }>,
): Promise<ContentFormat> { ): Promise<z.infer<typeof ContentFormatSchema>> {
const buffer = await file.arrayBuffer(); const buffer = await file.arrayBuffer();
const isImage = file.type.startsWith("image/"); const isImage = file.type.startsWith("image/");
const { width, height } = isImage ? await sharp(buffer).metadata() : {}; const { width, height } = isImage ? await sharp(buffer).metadata() : {};
@ -521,15 +534,17 @@ export class Media extends BaseInterface<typeof Medias> {
}; };
} }
public toVersia(): ContentFormat { public toVersia(): VersiaEntities.ContentFormat {
return this.data.content; return new VersiaEntities.ContentFormat(this.data.content);
} }
public static fromVersia(contentFormat: ContentFormat): Promise<Media> { public static fromVersia(
contentFormat: VersiaEntities.ContentFormat,
): Promise<Media> {
return Media.insert({ return Media.insert({
id: randomUUIDv7(), id: randomUUIDv7(),
content: contentFormat, content: contentFormat.data,
originalContent: contentFormat, originalContent: contentFormat.data,
}); });
} }
} }

View file

@ -4,12 +4,6 @@ import { sanitizedHtmlStrip } from "@/sanitization";
import { sentry } from "@/sentry"; import { sentry } from "@/sentry";
import { getLogger } from "@logtape/logtape"; import { getLogger } from "@logtape/logtape";
import type { Status, Status as StatusSchema } from "@versia/client/schemas"; 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 { Instance, db } from "@versia/kit/db";
import { import {
EmojiToNote, EmojiToNote,
@ -18,6 +12,7 @@ import {
Notes, Notes,
Users, Users,
} from "@versia/kit/tables"; } from "@versia/kit/tables";
import * as VersiaEntities from "@versia/sdk/entities";
import { randomUUIDv7 } from "bun"; import { randomUUIDv7 } from "bun";
import { import {
type InferInsertModel, type InferInsertModel,
@ -39,6 +34,7 @@ import {
parseTextMentions, parseTextMentions,
} from "~/classes/functions/status"; } from "~/classes/functions/status";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
import type { NonTextContentFormatSchema } from "~/packages/federation/schemas/contentformat.ts";
import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts"; import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts";
import { Application } from "./application.ts"; import { Application } from "./application.ts";
import { BaseInterface } from "./base.ts"; import { BaseInterface } from "./base.ts";
@ -222,7 +218,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
await deliveryQueue.addBulk( await deliveryQueue.addBulk(
users.map((user) => ({ users.map((user) => ({
data: { data: {
entity: this.toVersia(), entity: this.toVersia().toJSON(),
recipientId: user.id, recipientId: user.id,
senderId: this.author.id, senderId: this.author.id,
}, },
@ -323,7 +319,12 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
throw new Error("Cannot refetch a local note (it is not remote)"); 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) { if (!updated) {
throw new Error("Note not found after update"); throw new Error("Note not found after update");
@ -341,12 +342,12 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
*/ */
public static async fromData(data: { public static async fromData(data: {
author: User; author: User;
content: ContentFormat; content: VersiaEntities.TextContentFormat;
visibility: z.infer<typeof StatusSchema.shape.visibility>; visibility: z.infer<typeof StatusSchema.shape.visibility>;
isSensitive: boolean; isSensitive: boolean;
spoilerText: string; spoilerText: string;
emojis?: Emoji[]; emojis?: Emoji[];
uri?: string; uri?: URL;
mentions?: User[]; mentions?: User[];
/** List of IDs of database Attachment objects */ /** List of IDs of database Attachment objects */
mediaAttachments?: Media[]; mediaAttachments?: Media[];
@ -355,8 +356,8 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
application?: Application; application?: Application;
}): Promise<Note> { }): Promise<Note> {
const plaintextContent = const plaintextContent =
data.content["text/plain"]?.content ?? data.content.data["text/plain"]?.content ??
Object.entries(data.content)[0][1].content; Object.entries(data.content.data)[0][1].content;
const parsedMentions = mergeAndDeduplicate( const parsedMentions = mergeAndDeduplicate(
data.mentions ?? [], data.mentions ?? [],
@ -374,15 +375,15 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
authorId: data.author.id, authorId: data.author.id,
content: htmlContent, content: htmlContent,
contentSource: contentSource:
data.content["text/plain"]?.content || data.content.data["text/plain"]?.content ||
data.content["text/markdown"]?.content || data.content.data["text/markdown"]?.content ||
Object.entries(data.content)[0][1].content || Object.entries(data.content.data)[0][1].content ||
"", "",
contentType: "text/html", contentType: "text/html",
visibility: data.visibility, visibility: data.visibility,
sensitive: data.isSensitive, sensitive: data.isSensitive,
spoilerText: await sanitizedHtmlStrip(data.spoilerText), spoilerText: await sanitizedHtmlStrip(data.spoilerText),
uri: data.uri || null, uri: data.uri?.href || null,
replyId: data.replyId ?? null, replyId: data.replyId ?? null,
quotingId: data.quoteId ?? null, quotingId: data.quoteId ?? null,
applicationId: data.application?.id ?? null, applicationId: data.application?.id ?? null,
@ -416,12 +417,12 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
*/ */
public async updateFromData(data: { public async updateFromData(data: {
author: User; author: User;
content?: ContentFormat; content?: VersiaEntities.TextContentFormat;
visibility?: z.infer<typeof StatusSchema.shape.visibility>; visibility?: z.infer<typeof StatusSchema.shape.visibility>;
isSensitive?: boolean; isSensitive?: boolean;
spoilerText?: string; spoilerText?: string;
emojis?: Emoji[]; emojis?: Emoji[];
uri?: string; uri?: URL;
mentions?: User[]; mentions?: User[];
mediaAttachments?: Media[]; mediaAttachments?: Media[];
replyId?: string; replyId?: string;
@ -429,8 +430,8 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
application?: Application; application?: Application;
}): Promise<Note> { }): Promise<Note> {
const plaintextContent = data.content const plaintextContent = data.content
? (data.content["text/plain"]?.content ?? ? (data.content.data["text/plain"]?.content ??
Object.entries(data.content)[0][1].content) Object.entries(data.content.data)[0][1].content)
: undefined; : undefined;
const parsedMentions = mergeAndDeduplicate( const parsedMentions = mergeAndDeduplicate(
@ -451,15 +452,16 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
await this.update({ await this.update({
content: htmlContent, content: htmlContent,
contentSource: data.content contentSource: data.content
? data.content["text/plain"]?.content || ? data.content.data["text/plain"]?.content ||
data.content["text/markdown"]?.content || data.content.data["text/markdown"]?.content ||
Object.entries(data.content)[0][1].content || Object.entries(data.content.data)[0][1].content ||
"" ""
: undefined, : undefined,
contentType: "text/html", contentType: "text/html",
visibility: data.visibility, visibility: data.visibility,
sensitive: data.isSensitive, sensitive: data.isSensitive,
spoilerText: data.spoilerText, spoilerText: data.spoilerText,
uri: data.uri?.href,
replyId: data.replyId, replyId: data.replyId,
quotingId: data.quoteId, quotingId: data.quoteId,
applicationId: data.application?.id, applicationId: data.application?.id,
@ -575,37 +577,12 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
return await Note.fromId(uuid[0]); return await Note.fromId(uuid[0]);
} }
return await Note.fetchFromRemote(uri); const note = await User.federationRequester.fetchEntity(
} uri,
VersiaEntities.Note,
);
/** return Note.fromVersia(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<Note | null> {
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);
} }
/** /**
@ -615,15 +592,18 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
* @param instance Instance of the note * @param instance Instance of the note
* @returns The saved note * @returns The saved note
*/ */
public static async fromVersia( public static async fromVersia(note: VersiaEntities.Note): Promise<Note> {
note: VersiaNote,
author: User,
instance: Instance,
): Promise<Note> {
const emojis: Emoji[] = []; const emojis: Emoji[] = [];
const logger = getLogger(["federation", "resolvers"]); 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 ?? []) { ?.emojis ?? []) {
const resolvedEmoji = await Emoji.fetchFromRemote( const resolvedEmoji = await Emoji.fetchFromRemote(
emoji, emoji,
@ -641,7 +621,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
const attachments: Media[] = []; const attachments: Media[] = [];
for (const attachment of note.attachments ?? []) { for (const attachment of note.attachments) {
const resolvedAttachment = await Media.fromVersia(attachment).catch( const resolvedAttachment = await Media.fromVersia(attachment).catch(
(e) => { (e) => {
logger.error`${e}`; logger.error`${e}`;
@ -655,9 +635,9 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
} }
} }
let visibility = note.group let visibility = note.data.group
? ["public", "followers"].includes(note.group) ? ["public", "followers"].includes(note.data.group as string)
? (note.group as "public" | "private") ? (note.data.group as "public" | "private")
: ("url" as const) : ("url" as const)
: ("direct" as const); : ("direct" as const);
@ -668,34 +648,37 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
const newData = { const newData = {
author, author,
content: note.content ?? { content:
note.content ??
new VersiaEntities.TextContentFormat({
"text/plain": { "text/plain": {
content: "", content: "",
remote: false, remote: false,
}, },
}, }),
visibility, visibility,
isSensitive: note.is_sensitive ?? false, isSensitive: note.data.is_sensitive ?? false,
spoilerText: note.subject ?? "", spoilerText: note.data.subject ?? "",
emojis, emojis,
uri: note.uri, uri: note.data.uri,
mentions: await Promise.all( mentions: (
(note.mentions ?? []) await Promise.all(
.map((mention) => User.resolve(new URL(mention))) (note.data.mentions ?? []).map(
.filter((mention) => mention !== null) as Promise<User>[], async (mention) => await User.resolve(mention),
), ),
)
).filter((mention) => mention !== null),
mediaAttachments: attachments, mediaAttachments: attachments,
replyId: note.replies_to replyId: note.data.replies_to
? (await Note.resolve(new URL(note.replies_to)))?.data.id ? (await Note.resolve(note.data.replies_to))?.data.id
: undefined, : undefined,
quoteId: note.quotes quoteId: note.data.quotes
? (await Note.resolve(new URL(note.quotes)))?.data.id ? (await Note.resolve(note.data.quotes))?.data.id
: undefined, : undefined,
}; };
// Check if new note already exists // Check if new note already exists
const foundNote = await Note.fromSql(eq(Notes.uri, note.data.uri.href));
const foundNote = await Note.fromSql(eq(Notes.uri, note.uri));
// If it exists, simply update it // If it exists, simply update it
if (foundNote) { if (foundNote) {
@ -872,31 +855,31 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
); );
} }
public deleteToVersia(): VersiaDelete { public deleteToVersia(): VersiaEntities.Delete {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
return { return new VersiaEntities.Delete({
type: "Delete", type: "Delete",
id, id,
author: this.author.getUri().toString(), author: this.author.getUri(),
deleted_type: "Note", deleted_type: "Note",
deleted: this.getUri().toString(), deleted: this.getUri(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
}; });
} }
/** /**
* Convert a note to the Versia format * Convert a note to the Versia format
* @returns The note in the Versia format * @returns The note in the Versia format
*/ */
public toVersia(): VersiaNote { public toVersia(): VersiaEntities.Note {
const status = this.data; const status = this.data;
return { return new VersiaEntities.Note({
type: "Note", type: "Note",
created_at: new Date(status.createdAt).toISOString(), created_at: new Date(status.createdAt).toISOString(),
id: status.id, id: status.id,
author: this.author.getUri().toString(), author: this.author.getUri(),
uri: this.getUri().toString(), uri: this.getUri(),
content: { content: {
"text/html": { "text/html": {
content: status.content, content: status.content,
@ -908,28 +891,37 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
}, },
}, },
collections: { collections: {
replies: `/notes/${status.id}/replies`, replies: new URL(
quotes: `/notes/${status.id}/quotes`, `/notes/${status.id}/replies`,
config.http.base_url,
),
quotes: new URL(
`/notes/${status.id}/quotes`,
config.http.base_url,
),
}, },
attachments: (status.attachments ?? []).map((attachment) => attachments: status.attachments.map(
new Media(attachment).toVersia(), (attachment) =>
new Media(attachment).toVersia().data as z.infer<
typeof NonTextContentFormatSchema
>,
), ),
is_sensitive: status.sensitive, is_sensitive: status.sensitive,
mentions: status.mentions.map((mention) => mentions: status.mentions.map((mention) =>
User.getUri( User.getUri(
mention.id, mention.id,
mention.uri ? new URL(mention.uri) : null, mention.uri ? new URL(mention.uri) : null,
).toString(), ),
), ),
quotes: status.quote quotes: status.quote
? (status.quote.uri ?? ? status.quote.uri
new URL(`/notes/${status.quote.id}`, config.http.base_url) ? new URL(status.quote.uri)
.href) : new URL(`/notes/${status.quote.id}`, config.http.base_url)
: null, : null,
replies_to: status.reply replies_to: status.reply
? (status.reply.uri ?? ? status.reply.uri
new URL(`/notes/${status.reply.id}`, config.http.base_url) ? new URL(status.reply.uri)
.href) : new URL(`/notes/${status.reply.id}`, config.http.base_url)
: null, : null,
subject: status.spoilerText, subject: status.spoilerText,
// TODO: Refactor as part of groups // TODO: Refactor as part of groups
@ -942,7 +934,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
}, },
// TODO: Add polls and reactions // TODO: Add polls and reactions
}, },
}; });
} }
/** /**

View file

@ -1,6 +1,6 @@
import type { ReactionExtension } from "@versia/federation/types";
import { Emoji, Instance, type Note, User, db } from "@versia/kit/db"; import { Emoji, Instance, type Note, User, db } from "@versia/kit/db";
import { type Notes, Reactions, type Users } from "@versia/kit/tables"; import { type Notes, Reactions, type Users } from "@versia/kit/tables";
import * as VersiaEntities from "@versia/sdk/entities";
import { randomUUIDv7 } from "bun"; import { randomUUIDv7 } from "bun";
import { import {
type InferInsertModel, type InferInsertModel,
@ -173,24 +173,23 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
return !!this.data.emoji || !this.data.emojiText; return !!this.data.emoji || !this.data.emojiText;
} }
public toVersia(): ReactionExtension { public toVersia(): VersiaEntities.Reaction {
if (!this.isLocal()) { if (!this.isLocal()) {
throw new Error("Cannot convert a non-local reaction to Versia"); throw new Error("Cannot convert a non-local reaction to Versia");
} }
return { return new VersiaEntities.Reaction({
uri: this.getUri(config.http.base_url).toString(), uri: this.getUri(config.http.base_url),
type: "pub.versia:reactions/Reaction", type: "pub.versia:reactions/Reaction",
author: User.getUri( author: User.getUri(
this.data.authorId, this.data.authorId,
this.data.author.uri ? new URL(this.data.author.uri) : null, this.data.author.uri ? new URL(this.data.author.uri) : null,
).toString(), ),
created_at: new Date(this.data.createdAt).toISOString(), created_at: new Date(this.data.createdAt).toISOString(),
id: this.id, id: this.id,
object: object: this.data.note.uri
this.data.note.uri ?? ? new URL(this.data.note.uri)
new URL(`/notes/${this.data.noteId}`, config.http.base_url) : new URL(`/notes/${this.data.noteId}`, config.http.base_url),
.href,
content: this.hasCustomEmoji() content: this.hasCustomEmoji()
? `:${this.data.emoji?.shortcode}:` ? `:${this.data.emoji?.shortcode}:`
: this.data.emojiText || "", : this.data.emojiText || "",
@ -205,11 +204,11 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
}, },
} }
: undefined, : undefined,
}; });
} }
public static async fromVersia( public static async fromVersia(
reactionToConvert: ReactionExtension, reactionToConvert: VersiaEntities.Reaction,
author: User, author: User,
note: Note, note: Note,
): Promise<Reaction> { ): Promise<Reaction> {
@ -218,7 +217,7 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
} }
const emojiEntity = const emojiEntity =
reactionToConvert.extensions?.["pub.versia:custom_emojis"] reactionToConvert.data.extensions?.["pub.versia:custom_emojis"]
?.emojis[0]; ?.emojis[0];
const emoji = emojiEntity const emoji = emojiEntity
? await Emoji.fetchFromRemote( ? await Emoji.fetchFromRemote(
@ -233,11 +232,11 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
return Reaction.insert({ return Reaction.insert({
id: randomUUIDv7(), id: randomUUIDv7(),
uri: reactionToConvert.uri, uri: reactionToConvert.data.uri.href,
authorId: author.id, authorId: author.id,
noteId: note.id, noteId: note.id,
emojiId: emoji ? emoji.id : null, emojiId: emoji ? emoji.id : null,
emojiText: emoji ? null : reactionToConvert.content, emojiText: emoji ? null : reactionToConvert.data.content,
}); });
} }
} }

View file

@ -9,19 +9,6 @@ import type {
Source, Source,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import type { RolePermission } 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 { Media, Notification, PushSubscription, db } from "@versia/kit/db";
import { import {
EmojiToUser, EmojiToUser,
@ -32,6 +19,10 @@ import {
UserToPinnedNotes, UserToPinnedNotes,
Users, Users,
} from "@versia/kit/tables"; } 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 { randomUUIDv7 } from "bun";
import { password as bunPassword } from "bun"; import { password as bunPassword } from "bun";
import chalk from "chalk"; import chalk from "chalk";
@ -54,7 +45,7 @@ import type { z } from "zod";
import { findManyUsers } from "~/classes/functions/user"; import { findManyUsers } from "~/classes/functions/user";
import { searchManager } from "~/classes/search/search-manager"; import { searchManager } from "~/classes/search/search-manager";
import { config } from "~/config.ts"; 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 { ProxiableUrl } from "../media/url.ts";
import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts"; import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts";
import { PushJobType, pushQueue } from "../queues/push.ts"; import { PushJobType, pushQueue } from "../queues/push.ts";
@ -240,7 +231,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
): Promise<void> { ): Promise<void> {
if (followee.isRemote()) { if (followee.isRemote()) {
await deliveryQueue.add(DeliveryJobType.FederateEntity, { await deliveryQueue.add(DeliveryJobType.FederateEntity, {
entity: this.unfollowToVersia(followee), entity: this.unfollowToVersia(followee).toJSON(),
recipientId: followee.id, recipientId: followee.id,
senderId: this.id, senderId: this.id,
}); });
@ -251,15 +242,15 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
}); });
} }
private unfollowToVersia(followee: User): Unfollow { private unfollowToVersia(followee: User): VersiaEntities.Unfollow {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
return { return new VersiaEntities.Unfollow({
type: "Unfollow", type: "Unfollow",
id, id,
author: this.getUri().toString(), author: this.getUri(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
followee: followee.getUri().toString(), followee: followee.getUri(),
}; });
} }
public async sendFollowAccept(follower: User): Promise<void> { public async sendFollowAccept(follower: User): Promise<void> {
@ -271,16 +262,16 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
throw new Error("Followee must be a local user"); throw new Error("Followee must be a local user");
} }
const entity: VersiaFollowAccept = { const entity = new VersiaEntities.FollowAccept({
type: "FollowAccept", type: "FollowAccept",
id: crypto.randomUUID(), id: crypto.randomUUID(),
author: this.getUri().toString(), author: this.getUri(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
follower: follower.getUri().toString(), follower: follower.getUri(),
}; });
await deliveryQueue.add(DeliveryJobType.FederateEntity, { await deliveryQueue.add(DeliveryJobType.FederateEntity, {
entity, entity: entity.toJSON(),
recipientId: follower.id, recipientId: follower.id,
senderId: this.id, senderId: this.id,
}); });
@ -295,43 +286,77 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
throw new Error("Followee must be a local user"); throw new Error("Followee must be a local user");
} }
const entity: VersiaFollowReject = { const entity = new VersiaEntities.FollowReject({
type: "FollowReject", type: "FollowReject",
id: crypto.randomUUID(), id: crypto.randomUUID(),
author: this.getUri().toString(), author: this.getUri(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
follower: follower.getUri().toString(), follower: follower.getUri(),
}; });
await deliveryQueue.add(DeliveryJobType.FederateEntity, { await deliveryQueue.add(DeliveryJobType.FederateEntity, {
entity, entity: entity.toJSON(),
recipientId: follower.id, recipientId: follower.id,
senderId: this.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 * Perform a WebFinger lookup to find a user's URI
* @param manager
* @param username * @param username
* @param hostname * @param hostname
* @returns URI, or null if not found * @returns URI, or null if not found
*/ */
public static async webFinger( public static webFinger(
manager: FederationRequester,
username: string, username: string,
hostname: string, hostname: string,
): Promise<URL | null> { ): Promise<URL | null> {
try { try {
return new URL(await manager.webFinger(username, hostname)); return User.federationRequester.resolveWebFinger(
username,
hostname,
);
} catch { } catch {
try { try {
return new URL( return User.federationRequester.resolveWebFinger(
await manager.webFinger(
username, username,
hostname, hostname,
"application/activity+json", "application/activity+json",
),
); );
} catch { } catch {
return Promise.resolve(null); return Promise.resolve(null);
@ -455,7 +480,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
* @param uri The URI of the like, if it is remote * @param uri The URI of the like, if it is remote
* @returns The like object created or the existing like * @returns The like object created or the existing like
*/ */
public async like(note: Note, uri?: string): Promise<Like> { public async like(note: Note, uri?: URL): Promise<Like> {
// Check if the user has already liked the note // Check if the user has already liked the note
const existingLike = await Like.fromSql( const existingLike = await Like.fromSql(
and(eq(Likes.likerId, this.id), eq(Likes.likedId, note.id)), and(eq(Likes.likerId, this.id), eq(Likes.likedId, note.id)),
@ -469,7 +494,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
id: randomUUIDv7(), id: randomUUIDv7(),
likerId: this.id, likerId: this.id,
likedId: note.id, likedId: note.id,
uri, uri: uri?.href,
}); });
if (this.isLocal() && note.author.isLocal()) { if (this.isLocal() && note.author.isLocal()) {
@ -601,7 +626,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
} }
if (instance.data.protocol === "versia") { if (instance.data.protocol === "versia") {
return await User.saveFromVersia(uri, instance); return await User.saveFromVersia(uri);
} }
if (instance.data.protocol === "activitypub") { if (instance.data.protocol === "activitypub") {
@ -616,28 +641,19 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
config.federation.bridge.url, config.federation.bridge.url,
); );
return await User.saveFromVersia(bridgeUri, instance); return await User.saveFromVersia(bridgeUri);
} }
throw new Error(`Unsupported protocol: ${instance.data.protocol}`); throw new Error(`Unsupported protocol: ${instance.data.protocol}`);
} }
private static async saveFromVersia( private static async saveFromVersia(uri: URL): Promise<User> {
uri: URL, const userData = await User.federationRequester.fetchEntity(
instance: Instance, uri,
): Promise<User> { VersiaEntities.User,
const requester = await User.getFederationRequester(); );
const output = await requester.get<Partial<VersiaUser>>(uri, {
// @ts-expect-error Bun extension
proxy: config.http.proxy_address,
});
const { data: json } = output; const user = await User.fromVersia(userData);
const validator = new EntityValidator();
const data = await validator.User(json);
const user = await User.fromVersia(data, instance);
await searchManager.addUser(user); await searchManager.addUser(user);
@ -663,30 +679,32 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
); );
} }
public static async fromVersia( public static async fromVersia(user: VersiaEntities.User): Promise<User> {
user: VersiaUser, const instance = await Instance.resolve(user.data.uri);
instance: Instance,
): Promise<User> {
const data = { const data = {
username: user.username, username: user.data.username,
uri: user.uri, uri: user.data.uri.href,
createdAt: new Date(user.created_at).toISOString(), createdAt: new Date(user.data.created_at).toISOString(),
endpoints: { endpoints: {
dislikes: dislikes:
user.collections["pub.versia:likes/Dislikes"] ?? undefined, user.data.collections["pub.versia:likes/Dislikes"]?.href ??
featured: user.collections.featured, undefined,
likes: user.collections["pub.versia:likes/Likes"] ?? undefined, featured: user.data.collections.featured.href,
followers: user.collections.followers, likes:
following: user.collections.following, user.data.collections["pub.versia:likes/Likes"]?.href ??
inbox: user.inbox, undefined,
outbox: user.collections.outbox, 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 ?? [], fields: user.data.fields ?? [],
updatedAt: new Date(user.created_at).toISOString(), updatedAt: new Date(user.data.created_at).toISOString(),
instanceId: instance.id, instanceId: instance.id,
displayName: user.display_name ?? "", displayName: user.data.display_name ?? "",
note: getBestContentType(user.bio).content, note: getBestContentType(user.data.bio).content,
publicKey: user.public_key.key, publicKey: user.data.public_key.key,
source: { source: {
language: "en", language: "en",
note: "", note: "",
@ -697,46 +715,46 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
}; };
const userEmojis = const userEmojis =
user.extensions?.["pub.versia:custom_emojis"]?.emojis ?? []; user.data.extensions?.["pub.versia:custom_emojis"]?.emojis ?? [];
const emojis = await Promise.all( const emojis = await Promise.all(
userEmojis.map((emoji) => Emoji.fromVersia(emoji, instance)), userEmojis.map((emoji) => Emoji.fromVersia(emoji, instance)),
); );
// Check if new user already exists // 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 it exists, simply update it
if (foundUser) { if (foundUser) {
let avatar: Media | null = null; let avatar: Media | null = null;
let header: Media | null = null; let header: Media | null = null;
if (user.avatar) { if (user.data.avatar) {
if (foundUser.avatar) { if (foundUser.avatar) {
avatar = new Media( avatar = new Media(
await foundUser.avatar.update({ await foundUser.avatar.update({
content: user.avatar, content: user.data.avatar,
}), }),
); );
} else { } else {
avatar = await Media.insert({ avatar = await Media.insert({
id: randomUUIDv7(), id: randomUUIDv7(),
content: user.avatar, content: user.data.avatar,
}); });
} }
} }
if (user.header) { if (user.data.header) {
if (foundUser.header) { if (foundUser.header) {
header = new Media( header = new Media(
await foundUser.header.update({ await foundUser.header.update({
content: user.header, content: user.data.header,
}), }),
); );
} else { } else {
header = await Media.insert({ header = await Media.insert({
id: randomUUIDv7(), id: randomUUIDv7(),
content: user.header, content: user.data.header,
}); });
} }
} }
@ -752,17 +770,17 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
} }
// Else, create a new user // Else, create a new user
const avatar = user.avatar const avatar = user.data.avatar
? await Media.insert({ ? await Media.insert({
id: randomUUIDv7(), id: randomUUIDv7(),
content: user.avatar, content: user.data.avatar,
}) })
: null; : null;
const header = user.header const header = user.data.header
? await Media.insert({ ? await Media.insert({
id: randomUUIDv7(), id: randomUUIDv7(),
content: user.header, content: user.data.header,
}) })
: null; : null;
@ -977,72 +995,25 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return updated.data; return updated.data;
} }
/** public static get federationRequester(): FederationRequester {
* Signs a Versia entity with that user's private key return new FederationRequester(
*
* @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(
config.instance.keys.private, config.instance.keys.private,
config.http.base_url, config.http.base_url,
); );
return new FederationRequester(signatureConstructor);
} }
/** public get federationRequester(): Promise<FederationRequester> {
* Helper to get the appropriate Versia SDK requester with this user's private key return crypto.subtle
* .importKey(
* @returns The requester "pkcs8",
*/ Buffer.from(this.data.privateKey ?? "", "base64"),
public async getFederationRequester(): Promise<FederationRequester> { "Ed25519",
const signatureConstructor = await SignatureConstructor.fromStringKey( false,
this.data.privateKey ?? "", ["sign"],
this.getUri(), )
); .then((k) => {
return new FederationRequester(k, this.getUri());
return new FederationRequester(signatureConstructor); });
} }
/** /**
@ -1071,7 +1042,8 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
followers.map((follower) => ({ followers.map((follower) => ({
name: DeliveryJobType.FederateEntity, name: DeliveryJobType.FederateEntity,
data: { data: {
entity, entity: entity.toJSON(),
type: entity.data.type,
recipientId: follower.id, recipientId: follower.id,
senderId: this.id, senderId: this.id,
}, },
@ -1098,20 +1070,14 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
); );
} }
const { headers } = await this.sign(entity, new URL(inbox));
try { try {
await new FederationRequester().post(inbox, entity, { await (await this.federationRequester).postEntity(
// @ts-expect-error Bun extension new URL(inbox),
proxy: config.http.proxy_address, entity,
headers: { );
...headers.toJSON(),
"Content-Type": "application/json; charset=utf-8",
},
});
} catch (e) { } catch (e) {
getLogger(["federation", "delivery"]) 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}`; getLogger(["federation", "delivery"]).error`${e}`;
sentry?.captureException(e); sentry?.captureException(e);
@ -1176,17 +1142,17 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
}; };
} }
public toVersia(): VersiaUser { public toVersia(): VersiaEntities.User {
if (this.isRemote()) { if (this.isRemote()) {
throw new Error("Cannot convert remote user to Versia format"); throw new Error("Cannot convert remote user to Versia format");
} }
const user = this.data; const user = this.data;
return { return new VersiaEntities.User({
id: user.id, id: user.id,
type: "User", type: "User",
uri: this.getUri().toString(), uri: this.getUri(),
bio: { bio: {
"text/html": { "text/html": {
content: user.note, content: user.note,
@ -1202,44 +1168,42 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
featured: new URL( featured: new URL(
`/users/${user.id}/featured`, `/users/${user.id}/featured`,
config.http.base_url, config.http.base_url,
).toString(), ),
"pub.versia:likes/Likes": new URL( "pub.versia:likes/Likes": new URL(
`/users/${user.id}/likes`, `/users/${user.id}/likes`,
config.http.base_url, config.http.base_url,
).toString(), ),
"pub.versia:likes/Dislikes": new URL( "pub.versia:likes/Dislikes": new URL(
`/users/${user.id}/dislikes`, `/users/${user.id}/dislikes`,
config.http.base_url, config.http.base_url,
).toString(), ),
followers: new URL( followers: new URL(
`/users/${user.id}/followers`, `/users/${user.id}/followers`,
config.http.base_url, config.http.base_url,
).toString(), ),
following: new URL( following: new URL(
`/users/${user.id}/following`, `/users/${user.id}/following`,
config.http.base_url, config.http.base_url,
).toString(), ),
outbox: new URL( outbox: new URL(
`/users/${user.id}/outbox`, `/users/${user.id}/outbox`,
config.http.base_url, config.http.base_url,
).toString(), ),
}, },
inbox: new URL( inbox: new URL(`/users/${user.id}/inbox`, config.http.base_url),
`/users/${user.id}/inbox`,
config.http.base_url,
).toString(),
indexable: this.data.isIndexable, indexable: this.data.isIndexable,
username: user.username, username: user.username,
manually_approves_followers: this.data.isLocked, manually_approves_followers: this.data.isLocked,
avatar: this.avatar?.toVersia(), avatar: this.avatar?.toVersia().data as z.infer<
header: this.header?.toVersia(), typeof ImageContentFormatSchema
>,
header: this.header?.toVersia().data as z.infer<
typeof ImageContentFormatSchema
>,
display_name: user.displayName, display_name: user.displayName,
fields: user.fields, fields: user.fields,
public_key: { public_key: {
actor: new URL( actor: new URL(`/users/${user.id}`, config.http.base_url),
`/users/${user.id}`,
config.http.base_url,
).toString(),
key: user.publicKey, key: user.publicKey,
algorithm: "ed25519", algorithm: "ed25519",
}, },
@ -1250,7 +1214,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
), ),
}, },
}, },
}; });
} }
public toMention(): z.infer<typeof MentionSchema> { public toMention(): z.infer<typeof MentionSchema> {

View file

@ -1,9 +1,9 @@
import { mentionValidator } from "@/api"; import { mentionValidator } from "@/api";
import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization"; import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization";
import markdownItTaskLists from "@hackmd/markdown-it-task-lists"; 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 { type Note, User, db } from "@versia/kit/db";
import { Instances, Users } from "@versia/kit/tables"; 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 { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import linkifyHtml from "linkify-html"; import linkifyHtml from "linkify-html";
import { import {
@ -276,23 +276,19 @@ export const parseTextMentions = async (
// Resolve remote mentions not in database // Resolve remote mentions not in database
for (const person of notFoundRemoteUsers) { for (const person of notFoundRemoteUsers) {
const manager = await author.getFederationRequester(); const url = await (await author.federationRequester).resolveWebFinger(
const uri = await User.webFinger(
manager,
person[1] ?? "", person[1] ?? "",
person[2] ?? "", person[2] ?? "",
); );
if (!uri) { if (url) {
continue; const user = await User.resolve(url);
}
const user = await User.resolve(uri);
if (user) { if (user) {
finalList.push(user); finalList.push(user);
} }
} }
}
return finalList; return finalList;
}; };
@ -327,21 +323,21 @@ export const replaceTextMentions = (text: string, mentions: User[]): string => {
}; };
export const contentToHtml = async ( export const contentToHtml = async (
content: ContentFormat, content: VersiaEntities.TextContentFormat,
mentions: User[] = [], mentions: User[] = [],
inline = false, inline = false,
): Promise<string> => { ): Promise<string> => {
const sanitizer = inline ? sanitizeHtmlInline : sanitizeHtml; const sanitizer = inline ? sanitizeHtmlInline : sanitizeHtml;
let htmlContent = ""; let htmlContent = "";
if (content["text/html"]) { if (content.data["text/html"]) {
htmlContent = await sanitizer(content["text/html"].content); htmlContent = await sanitizer(content.data["text/html"].content);
} else if (content["text/markdown"]) { } else if (content.data["text/markdown"]) {
htmlContent = await sanitizer( htmlContent = await sanitizer(
await markdownParse(content["text/markdown"].content), await markdownParse(content.data["text/markdown"].content),
); );
} else if (content["text/plain"]?.content) { } else if (content.data["text/plain"]?.content) {
htmlContent = (await sanitizer(content["text/plain"].content)) htmlContent = (await sanitizer(content.data["text/plain"].content))
.split("\n") .split("\n")
.map((line) => `<p>${line}</p>`) .map((line) => `<p>${line}</p>`)
.join("\n"); .join("\n");

View file

@ -1,22 +1,10 @@
import { sentry } from "@/sentry"; import { sentry } from "@/sentry";
import { type Logger, getLogger } from "@logtape/logtape"; import { type Logger, getLogger } from "@logtape/logtape";
import { import { type Instance, Like, Note, Relationship, User } from "@versia/kit/db";
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 { Likes, Notes } from "@versia/kit/tables"; 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 type { SocketAddress } from "bun";
import { Glob } from "bun"; import { Glob } from "bun";
import chalk from "chalk"; import chalk from "chalk";
@ -24,6 +12,7 @@ import { eq } from "drizzle-orm";
import { matches } from "ip-matching"; import { matches } from "ip-matching";
import { isValidationError } from "zod-validation-error"; import { isValidationError } from "zod-validation-error";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
import type { JSONObject } from "~/packages/federation/types.ts";
import { ApiError } from "../errors/api-error.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. * @param requestIp Request IP address. Grabs it from the Hono context if not provided.
*/ */
public constructor( public constructor(
private request: { private request: Request,
url: URL; private body: JSONObject,
method: string;
body: string;
},
private body: Entity,
private sender: { private sender: {
instance: Instance; instance: Instance;
key: string; key: CryptoKey;
} | null, } | null,
private headers: { private authorizationHeader?: string,
signature?: string;
signedAt?: Date;
authorization?: string;
},
private logger: Logger = getLogger(["federation", "inbox"]), private logger: Logger = getLogger(["federation", "inbox"]),
private requestIp: SocketAddress | null = null, private requestIp: SocketAddress | null = null,
) {} ) {}
@ -87,40 +68,12 @@ export class InboxProcessor {
* *
* @returns {Promise<boolean>} - Whether the signature is valid. * @returns {Promise<boolean>} - Whether the signature is valid.
*/ */
private async isSignatureValid(): Promise<boolean> { private isSignatureValid(): Promise<boolean> {
if (!this.sender) { if (!this.sender) {
throw new Error("Sender is not defined"); throw new Error("Sender is not defined");
} }
if (config.debug?.federation) { return verify(this.sender.key, this.request);
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;
} }
/** /**
@ -131,7 +84,7 @@ export class InboxProcessor {
*/ */
private shouldCheckSignature(): boolean { private shouldCheckSignature(): boolean {
if (config.federation.bridge) { if (config.federation.bridge) {
const token = this.headers.authorization?.split("Bearer ")[1]; const token = this.authorizationHeader?.split("Bearer ")[1];
if (token) { if (token) {
return this.isRequestFromBridge(token); return this.isRequestFromBridge(token);
@ -226,58 +179,48 @@ export class InboxProcessor {
shouldCheckSignature && this.logger.debug`Signature is valid`; shouldCheckSignature && this.logger.debug`Signature is valid`;
const validator = new EntityValidator();
const handler = new RequestParserHandler(this.body, validator);
try { try {
return await handler.parseBody<void>({ new EntitySorter(this.body)
note: (): Promise<void> => this.processNote(), .on(VersiaEntities.Note, async (n) => {
follow: (): Promise<void> => this.processFollowRequest(), await Note.fromVersia(n);
followAccept: (): Promise<void> => this.processFollowAccept(), })
followReject: (): Promise<void> => this.processFollowReject(), .on(VersiaEntities.Follow, (f) => {
"pub.versia:likes/Like": (): Promise<void> => this.processFollowRequest(f);
this.processLikeRequest(), })
delete: (): Promise<void> => this.processDelete(), .on(VersiaEntities.FollowAccept, (f) => {
user: (): Promise<void> => this.processUserRequest(), this.processFollowAccept(f);
unknown: (): void => { })
.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"); throw new ApiError(400, "Unknown entity type");
},
}); });
} catch (e) { } catch (e) {
return this.handleError(e as Error); return this.handleError(e as Error);
} }
} }
/**
* Handles Note entity processing.
*
* @returns {Promise<void>}
*/
private async processNote(): Promise<void> {
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. * Handles Follow entity processing.
* *
* @param {VersiaFollow} follow - The Follow entity to process.
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
private async processFollowRequest(): Promise<void> { private async processFollowRequest(
const follow = this.body as unknown as VersiaFollow; follow: VersiaEntities.Follow,
const author = await User.resolve(new URL(follow.author)); ): Promise<void> {
const followee = await User.resolve(new URL(follow.followee)); const author = await User.resolve(follow.data.author);
const followee = await User.resolve(follow.data.followee);
if (!author) { if (!author) {
throw new ApiError(404, "Author not found"); throw new ApiError(404, "Author not found");
@ -318,12 +261,14 @@ export class InboxProcessor {
/** /**
* Handles FollowAccept entity processing * Handles FollowAccept entity processing
* *
* @param {VersiaFollowAccept} followAccept - The FollowAccept entity to process.
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
private async processFollowAccept(): Promise<void> { private async processFollowAccept(
const followAccept = this.body as unknown as VersiaFollowAccept; followAccept: VersiaEntities.FollowAccept,
const author = await User.resolve(new URL(followAccept.author)); ): Promise<void> {
const follower = await User.resolve(new URL(followAccept.follower)); const author = await User.resolve(followAccept.data.author);
const follower = await User.resolve(followAccept.data.follower);
if (!author) { if (!author) {
throw new ApiError(404, "Author not found"); throw new ApiError(404, "Author not found");
@ -351,12 +296,14 @@ export class InboxProcessor {
/** /**
* Handles FollowReject entity processing * Handles FollowReject entity processing
* *
* @param {VersiaFollowReject} followReject - The FollowReject entity to process.
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
private async processFollowReject(): Promise<void> { private async processFollowReject(
const followReject = this.body as unknown as VersiaFollowReject; followReject: VersiaEntities.FollowReject,
const author = await User.resolve(new URL(followReject.author)); ): Promise<void> {
const follower = await User.resolve(new URL(followReject.follower)); const author = await User.resolve(followReject.data.author);
const follower = await User.resolve(followReject.data.follower);
if (!author) { if (!author) {
throw new ApiError(404, "Author not found"); throw new ApiError(404, "Author not found");
@ -384,21 +331,20 @@ export class InboxProcessor {
/** /**
* Handles Delete entity processing. * Handles Delete entity processing.
* *
* @param {VersiaDelete} delete_ - The Delete entity to process.
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */ // JS doesn't allow the use of `delete` as a variable name
public async processDelete(): Promise<void> { public async processDelete(delete_: VersiaEntities.Delete): Promise<void> {
// JS doesn't allow the use of `delete` as a variable name const toDelete = delete_.data.deleted;
const delete_ = this.body as unknown as VersiaDelete;
const toDelete = delete_.deleted;
const author = delete_.author const author = delete_.data.author
? await User.resolve(new URL(delete_.author)) ? await User.resolve(delete_.data.author)
: null; : null;
switch (delete_.deleted_type) { switch (delete_.data.deleted_type) {
case "Note": { case "Note": {
const note = await Note.fromSql( const note = await Note.fromSql(
eq(Notes.uri, toDelete), eq(Notes.uri, toDelete.href),
author ? eq(Notes.authorId, author.id) : undefined, author ? eq(Notes.authorId, author.id) : undefined,
); );
@ -413,7 +359,7 @@ export class InboxProcessor {
return; return;
} }
case "User": { case "User": {
const userToDelete = await User.resolve(new URL(toDelete)); const userToDelete = await User.resolve(toDelete);
if (!userToDelete) { if (!userToDelete) {
throw new ApiError(404, "User to delete not found"); throw new ApiError(404, "User to delete not found");
@ -428,7 +374,7 @@ export class InboxProcessor {
} }
case "pub.versia:likes/Like": { case "pub.versia:likes/Like": {
const like = await Like.fromSql( const like = await Like.fromSql(
eq(Likes.uri, toDelete), eq(Likes.uri, toDelete.href),
author ? eq(Likes.likerId, author.id) : undefined, author ? eq(Likes.likerId, author.id) : undefined,
); );
@ -445,7 +391,7 @@ export class InboxProcessor {
default: { default: {
throw new ApiError( throw new ApiError(
400, 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. * Handles Like entity processing.
* *
* @param {VersiaLikeExtension} like - The Like entity to process.
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
private async processLikeRequest(): Promise<void> { private async processLikeRequest(like: VersiaEntities.Like): Promise<void> {
const like = this.body as unknown as VersiaLikeExtension; const author = await User.resolve(like.data.author);
const author = await User.resolve(new URL(like.author)); const likedNote = await Note.resolve(like.data.liked);
const likedNote = await Note.resolve(new URL(like.liked));
if (!author) { if (!author) {
throw new ApiError(404, "Author not found"); throw new ApiError(404, "Author not found");
@ -469,23 +415,7 @@ export class InboxProcessor {
throw new ApiError(404, "Liked Note not found"); throw new ApiError(404, "Liked Note not found");
} }
await author.like(likedNote, like.uri); await author.like(likedNote, like.data.uri);
}
/**
* Handles User entity processing (profile edits).
*
* @returns {Promise<void>}
*/
private async processUserRequest(): Promise<void> {
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);
} }
/** /**

View file

@ -1,9 +1,10 @@
import { User } from "@versia/kit/db"; import { User } from "@versia/kit/db";
import * as VersiaEntities from "@versia/sdk/entities";
import { Queue } from "bullmq"; import { Queue } from "bullmq";
import { Worker } from "bullmq"; import { Worker } from "bullmq";
import chalk from "chalk"; import chalk from "chalk";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
import type { KnownEntity } from "~/types/api"; import type { JSONObject } from "~/packages/federation/types";
import { connection } from "~/utils/redis.ts"; import { connection } from "~/utils/redis.ts";
export enum DeliveryJobType { export enum DeliveryJobType {
@ -11,7 +12,7 @@ export enum DeliveryJobType {
} }
export type DeliveryJobData = { export type DeliveryJobData = {
entity: KnownEntity; entity: JSONObject;
recipientId: string; recipientId: string;
senderId: string; senderId: string;
}; };
@ -55,7 +56,23 @@ export const getDeliveryWorker = (): Worker<
`Federating entity [${entity.id}] from @${sender.getAcct()} to @${recipient.getAcct()}`, `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( await job.log(
`✔ Finished federating entity [${entity.id}]`, `✔ Finished federating entity [${entity.id}]`,

View file

@ -1,10 +1,10 @@
import { getLogger } from "@logtape/logtape"; import { getLogger } from "@logtape/logtape";
import type { Entity } from "@versia/federation/types";
import { Instance, User } from "@versia/kit/db"; import { Instance, User } from "@versia/kit/db";
import { Queue } from "bullmq"; import { Queue } from "bullmq";
import { Worker } from "bullmq"; import { Worker } from "bullmq";
import type { SocketAddress } from "bun"; import type { SocketAddress } from "bun";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
import type { JSONObject } from "~/packages/federation/types.ts";
import { connection } from "~/utils/redis.ts"; import { connection } from "~/utils/redis.ts";
import { ApiError } from "../errors/api-error.ts"; import { ApiError } from "../errors/api-error.ts";
import { InboxProcessor } from "../inbox/processor.ts"; import { InboxProcessor } from "../inbox/processor.ts";
@ -14,7 +14,7 @@ export enum InboxJobType {
} }
export type InboxJobData = { export type InboxJobData = {
data: Entity; data: JSONObject;
headers: { headers: {
"versia-signature"?: string; "versia-signature"?: string;
"versia-signed-at"?: number; "versia-signed-at"?: number;
@ -46,18 +46,25 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
await job.log(`Processing entity [${data.id}]`); 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) { if (headers.authorization) {
try { try {
const processor = new InboxProcessor( const processor = new InboxProcessor(
{ req,
...request,
url: new URL(request.url),
},
data, data,
null, null,
{ headers.authorization,
authorization: headers.authorization,
},
getLogger(["federation", "inbox"]), getLogger(["federation", "inbox"]),
ip, ip,
); );
@ -91,13 +98,7 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
return; return;
} }
const { const { "versia-signed-by": signedBy } = headers as {
"versia-signature": signature,
"versia-signed-at": signedAt,
"versia-signed-by": signedBy,
} = headers as {
"versia-signature": string;
"versia-signed-at": number;
"versia-signed-by": string; "versia-signed-by": string;
}; };
@ -139,24 +140,27 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
); );
} }
const key = await crypto.subtle.importKey(
"spki",
Buffer.from(
sender?.data.publicKey ??
remoteInstance.data.publicKey.key,
"base64",
),
"Ed25519",
false,
["verify"],
);
try { try {
const processor = new InboxProcessor( const processor = new InboxProcessor(
{ req,
...request,
url: new URL(request.url),
},
data, data,
{ {
instance: remoteInstance, instance: remoteInstance,
key: key,
sender?.data.publicKey ??
remoteInstance.data.publicKey.key,
},
{
signature,
signedAt: new Date(signedAt * 1000),
authorization: undefined,
}, },
undefined,
getLogger(["federation", "inbox"]), getLogger(["federation", "inbox"]),
ip, ip,
); );

View file

@ -4,7 +4,13 @@ import type {
Status as StatusSchema, Status as StatusSchema,
} from "@versia/client/schemas"; } from "@versia/client/schemas";
import type { RolePermission } 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 type { Challenge } from "altcha-lib/types";
import { relations, sql } from "drizzle-orm"; import { relations, sql } from "drizzle-orm";
import { import {
@ -361,9 +367,13 @@ export const TokensRelations = relations(Tokens, ({ one }) => ({
export const Medias = pgTable("Medias", { export const Medias = pgTable("Medias", {
id: id(), id: id(),
content: jsonb("content").notNull().$type<ContentFormat>(), content: jsonb("content")
originalContent: jsonb("original_content").$type<ContentFormat>(), .notNull()
thumbnail: jsonb("thumbnail").$type<ContentFormat>(), .$type<z.infer<typeof ContentFormatSchema>>(),
originalContent:
jsonb("original_content").$type<z.infer<typeof ContentFormatSchema>>(),
thumbnail:
jsonb("thumbnail").$type<z.infer<typeof ImageContentFormatSchema>>(),
blurhash: text("blurhash"), blurhash: text("blurhash"),
}); });
@ -506,7 +516,7 @@ export const Instances = pgTable("Instances", {
baseUrl: text("base_url").notNull(), baseUrl: text("base_url").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
version: text("version").notNull(), version: text("version").notNull(),
logo: jsonb("logo").$type<ContentFormat>(), logo: jsonb("logo").$type<typeof NonTextContentFormatSchema._input>(),
disableAutomoderation: boolean("disable_automoderation") disableAutomoderation: boolean("disable_automoderation")
.default(false) .default(false)
.notNull(), .notNull(),
@ -515,8 +525,14 @@ export const Instances = pgTable("Instances", {
.$type<"versia" | "activitypub">() .$type<"versia" | "activitypub">()
.default("versia"), .default("versia"),
inbox: text("inbox"), inbox: text("inbox"),
publicKey: jsonb("public_key").$type<InstanceMetadata["public_key"]>(), publicKey:
extensions: jsonb("extensions").$type<InstanceMetadata["extensions"]>(), jsonb("public_key").$type<
(typeof InstanceMetadataSchema._input)["public_key"]
>(),
extensions:
jsonb("extensions").$type<
(typeof InstanceMetadataSchema._input)["extensions"]
>(),
}); });
export const InstancesRelations = relations(Instances, ({ many }) => ({ export const InstancesRelations = relations(Instances, ({ many }) => ({
@ -549,8 +565,8 @@ export const Users = pgTable(
passwordResetToken: text("password_reset_token"), passwordResetToken: text("password_reset_token"),
fields: jsonb("fields").notNull().default("[]").$type< fields: jsonb("fields").notNull().default("[]").$type<
{ {
key: ContentFormat; key: z.infer<typeof TextContentFormatSchema>;
value: ContentFormat; value: z.infer<typeof TextContentFormatSchema>;
}[] }[]
>(), >(),
endpoints: jsonb("endpoints").$type<Partial<{ endpoints: jsonb("endpoints").$type<Partial<{

View file

@ -92,8 +92,8 @@
"@scalar/hono-api-reference": "^0.8.0", "@scalar/hono-api-reference": "^0.8.0",
"@sentry/bun": "^9.11.0", "@sentry/bun": "^9.11.0",
"@versia/client": "workspace:*", "@versia/client": "workspace:*",
"@versia/federation": "^0.2.1",
"@versia/kit": "workspace:*", "@versia/kit": "workspace:*",
"@versia/sdk": "workspace:*",
"altcha-lib": "^1.2.0", "altcha-lib": "^1.2.0",
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"bullmq": "^5.47.2", "bullmq": "^5.47.2",

View file

@ -0,0 +1,74 @@
const stringToBase64Hash = async (str: string): Promise<string> => {
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<Request> => {
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<boolean> => {
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),
);
};

View file

@ -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<typeof CollectionSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Collection> {
return CollectionSchema.parseAsync(json).then((u) => new Collection(u));
}
}
export class URICollection extends Entity {
public constructor(public data: z.infer<typeof URICollectionSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<URICollection> {
return URICollectionSchema.parseAsync(json).then(
(u) => new URICollection(u),
);
}
}

View file

@ -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<ContentFormat> {
return ContentFormatSchema.parseAsync(data).then(
(d) => new ContentFormat(d),
);
}
public constructor(public data: z.infer<typeof ContentFormatSchema>) {}
}
export class TextContentFormat extends ContentFormat {
public static fromJSON(data: JSONObject): Promise<TextContentFormat> {
return TextContentFormatSchema.parseAsync(data).then(
(d) => new TextContentFormat(d),
);
}
public constructor(public data: z.infer<typeof TextContentFormatSchema>) {
super(data);
}
}
export class NonTextContentFormat extends ContentFormat {
public static fromJSON(data: JSONObject): Promise<NonTextContentFormat> {
return NonTextContentFormatSchema.parseAsync(data).then(
(d) => new NonTextContentFormat(d),
);
}
public constructor(
public data: z.infer<typeof NonTextContentFormatSchema>,
) {
super(data);
}
}
export class ImageContentFormat extends ContentFormat {
public static fromJSON(data: JSONObject): Promise<ImageContentFormat> {
return ImageContentFormatSchema.parseAsync(data).then(
(d) => new ImageContentFormat(d),
);
}
public constructor(public data: z.infer<typeof ImageContentFormatSchema>) {
super(data);
}
}
export class VideoContentFormat extends ContentFormat {
public static fromJSON(data: JSONObject): Promise<VideoContentFormat> {
return VideoContentFormatSchema.parseAsync(data).then(
(d) => new VideoContentFormat(d),
);
}
public constructor(public data: z.infer<typeof VideoContentFormatSchema>) {
super(data);
}
}
export class AudioContentFormat extends ContentFormat {
public static fromJSON(data: JSONObject): Promise<AudioContentFormat> {
return AudioContentFormatSchema.parseAsync(data).then(
(d) => new AudioContentFormat(d),
);
}
public constructor(public data: z.infer<typeof AudioContentFormatSchema>) {
super(data);
}
}

View file

@ -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<typeof DeleteSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Delete> {
return DeleteSchema.parseAsync(json).then((u) => new Delete(u));
}
}

View file

@ -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<Entity> {
return EntitySchema.parseAsync(json).then((u) => new Entity(u));
}
public toJSON(): JSONObject {
return this.data;
}
}

View file

@ -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<typeof LikeSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Like> {
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<typeof DislikeSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Dislike> {
return DislikeSchema.parseAsync(json).then((u) => new Dislike(u));
}
}

View file

@ -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<typeof VoteSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Vote> {
return VoteSchema.parseAsync(json).then((u) => new Vote(u));
}
}

View file

@ -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<typeof ReactionSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Reaction> {
return ReactionSchema.parseAsync(json).then((u) => new Reaction(u));
}
}

View file

@ -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<typeof ReportSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Report> {
return ReportSchema.parseAsync(json).then((u) => new Report(u));
}
}

View file

@ -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<typeof ShareSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Share> {
return ShareSchema.parseAsync(json).then((u) => new Share(u));
}
}

View file

@ -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<typeof FollowSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Follow> {
return FollowSchema.parseAsync(json).then((u) => new Follow(u));
}
}
export class FollowAccept extends Entity {
public static name = "FollowAccept";
public constructor(public data: z.infer<typeof FollowAcceptSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<FollowAccept> {
return FollowAcceptSchema.parseAsync(json).then(
(u) => new FollowAccept(u),
);
}
}
export class FollowReject extends Entity {
public static name = "FollowReject";
public constructor(public data: z.infer<typeof FollowRejectSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<FollowReject> {
return FollowRejectSchema.parseAsync(json).then(
(u) => new FollowReject(u),
);
}
}
export class Unfollow extends Entity {
public static name = "Unfollow";
public constructor(public data: z.infer<typeof UnfollowSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Unfollow> {
return UnfollowSchema.parseAsync(json).then((u) => new Unfollow(u));
}
}

View file

@ -0,0 +1,21 @@
// biome-ignore lint/performance/noBarrelFile: <explanation>
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";

View file

@ -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<typeof InstanceMetadataSchema>) {
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<InstanceMetadata> {
return InstanceMetadataSchema.parseAsync(json).then(
(u) => new InstanceMetadata(u),
);
}
}

View file

@ -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<typeof NoteSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<Note> {
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;
}
}

View file

@ -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<typeof UserSchema>) {
super(data);
}
public static fromJSON(json: JSONObject): Promise<User> {
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;
}
}

203
packages/federation/http.ts Normal file
View file

@ -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<T extends typeof Entity>(
url: URL,
expectedType: T,
): Promise<InstanceType<T>> {
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<T>;
}
public async postEntity(url: URL, entity: Entity): Promise<Response> {
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<T extends typeof Entity>(
url: URL,
expectedType: T,
options?: {
limit?: number;
},
): Promise<InstanceType<T>[]> {
const entities: InstanceType<T>[] = [];
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<T>,
);
}
}
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<URL[]> {
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<User | null>} The resolved User or null if not found
*/
public async resolveWebFinger(
username: string,
hostname: string,
contentType = "application/json",
serverUrl = `https://${hostname}`,
): Promise<URL | null> {
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);
}
}

View file

@ -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<void>
>;
type MaybePromise<T> = T | Promise<T>;
/**
* @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<T extends typeof Entity>(
entity: T,
handler: (entity: InstanceType<T>) => MaybePromise<void>,
): EntitySorter {
this.handlers.set(
entity,
handler as (entity: Entity) => MaybePromise<void>,
);
return this;
}
/**
* Sorts the entity based on the provided JSON data.
* @param {() => MaybePromise<void>} 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<void>,
): Promise<void> {
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?.();
}
}
}

View file

@ -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"
}

View file

@ -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)$/;

View file

@ -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),
});

View file

@ -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));

View file

@ -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,
}),
);

View file

@ -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,
});

View file

@ -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(),
});

View file

@ -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,
}),
),
});

View file

@ -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,
});

View file

@ -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,
});

View file

@ -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(),
});

View file

@ -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(),
});

View file

@ -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),
});

View file

@ -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(),
});

View file

@ -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,
});

View file

@ -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(),
});

View file

@ -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,
});

View file

@ -0,0 +1,27 @@
// biome-ignore lint/performance/noBarrelFile: <explanation>
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";

View file

@ -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(),
});

View file

@ -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(),
});

View file

@ -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(),
});

View file

@ -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(),
});

View file

@ -0,0 +1,11 @@
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue };
export interface JSONObject {
[k: string]: JSONValue;
}

View file

@ -1,14 +1,4 @@
import type { import type * as VersiaEntities from "@versia/sdk/entities";
Delete,
Follow,
FollowAccept,
FollowReject,
InstanceMetadata,
LikeExtension,
Note,
Unfollow,
User,
} from "@versia/federation/types";
import type { SocketAddress } from "bun"; import type { SocketAddress } from "bun";
import type { Hono } from "hono"; import type { Hono } from "hono";
import type { RouterRoute } from "hono/types"; import type { RouterRoute } from "hono/types";
@ -33,12 +23,12 @@ export interface ApiRouteExports {
} }
export type KnownEntity = export type KnownEntity =
| Note | VersiaEntities.Note
| InstanceMetadata | VersiaEntities.InstanceMetadata
| User | VersiaEntities.User
| Follow | VersiaEntities.Follow
| FollowAccept | VersiaEntities.FollowAccept
| FollowReject | VersiaEntities.FollowReject
| Unfollow | VersiaEntities.Unfollow
| Delete | VersiaEntities.Delete
| LikeExtension; | VersiaEntities.Like;

View file

@ -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 { htmlToText as htmlToTextLib } from "html-to-text";
import { lookup } from "mime-types"; import { lookup } from "mime-types";
import type { z } from "zod";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
export const getBestContentType = ( export const getBestContentType = (
content?: ContentFormat | null, content?: z.infer<typeof ContentFormatSchema> | null,
): { ): {
content: string; content: string;
format: string; format: string;
@ -32,7 +33,7 @@ export const getBestContentType = (
export const urlToContentFormat = ( export const urlToContentFormat = (
url: URL, url: URL,
contentType?: string, contentType?: string,
): ContentFormat | null => { ): z.infer<typeof ContentFormatSchema> | null => {
if (url.href.startsWith("https://api.dicebear.com/")) { if (url.href.startsWith("https://api.dicebear.com/")) {
return { return {
"image/svg+xml": { "image/svg+xml": {