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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,8 @@
import { apiRoute, handleZodError } from "@/api";
import {
Collection as CollectionSchema,
Note as NoteSchema,
} from "@versia/federation/schemas";
import { Note, User, db } from "@versia/kit/db";
import { Notes } from "@versia/kit/tables";
import * as VersiaEntities from "@versia/sdk/entities";
import { CollectionSchema, NoteSchema } from "@versia/sdk/schemas";
import { and, eq, inArray } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
@ -96,35 +94,35 @@ export default apiRoute((app) =>
),
);
const json = {
const json = new VersiaEntities.Collection({
first: new URL(
`/users/${uuid}/outbox?page=1`,
config.http.base_url,
).toString(),
),
last: new URL(
`/users/${uuid}/outbox?page=${Math.ceil(
totalNotes / NOTES_PER_PAGE,
)}`,
config.http.base_url,
).toString(),
),
total: totalNotes,
author: author.getUri().toString(),
author: author.getUri(),
next:
notes.length === NOTES_PER_PAGE
? new URL(
`/users/${uuid}/outbox?page=${pageNumber + 1}`,
config.http.base_url,
).toString()
)
: null,
previous:
pageNumber > 1
? new URL(
`/users/${uuid}/outbox?page=${pageNumber - 1}`,
config.http.base_url,
).toString()
)
: null,
items: notes.map((note) => note.toVersia()),
};
});
const { headers } = await author.sign(
json,

View file

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

View file

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

View file

@ -20,8 +20,8 @@
"@scalar/hono-api-reference": "^0.8.0",
"@sentry/bun": "^9.11.0",
"@versia/client": "workspace:*",
"@versia/federation": "^0.2.1",
"@versia/kit": "workspace:*",
"@versia/sdk": "workspace:*",
"altcha-lib": "^1.2.0",
"blurhash": "^2.0.5",
"bullmq": "^5.47.2",
@ -95,6 +95,10 @@
"@badgateway/oauth2-client": "^2.4.2",
},
},
"packages/federation": {
"name": "@versia/sdk",
"version": "0.0.1",
},
"packages/plugin-kit": {
"name": "@versia/kit",
"version": "0.0.0",
@ -568,10 +572,10 @@
"@versia/client": ["@versia/client@workspace:packages/client"],
"@versia/federation": ["@versia/federation@0.2.1", "", { "dependencies": { "magic-regexp": "^0.8.0", "mime-types": "^2.1.35", "zod": "^3.24.1", "zod-validation-error": "^3.4.0" } }, "sha512-FTo3VGNJBGmCi0ZEQMzqFZBbcfbX81kmg0UgY4cKamr1dJWgEf72IAZnEDgrBffFjYtreLGdEjFkkcq3JfS8oQ=="],
"@versia/kit": ["@versia/kit@workspace:packages/plugin-kit"],
"@versia/sdk": ["@versia/sdk@workspace:packages/federation"],
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.3", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-IYSLEQj4LgZZuoVpdSUCw3dIynTWQgPlaRP6iAvMle4My0HdYwr5g5wQAfwOeHQBmYwEkqF70nRpSilr6PoUDg=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.13", "", { "dependencies": { "@babel/parser": "^7.25.3", "@vue/shared": "3.5.13", "entities": "^4.5.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.0" } }, "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q=="],
@ -1412,8 +1416,6 @@
"@ts-morph/common/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"@versia/federation/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
@ -1532,8 +1534,6 @@
"@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"@versia/federation/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"cheerio-select/domutils/dom-serializer": ["dom-serializer@1.4.1", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.0", "entities": "^2.0.0" } }, "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag=="],
"cheerio/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="],

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,10 @@
import { User } from "@versia/kit/db";
import * as VersiaEntities from "@versia/sdk/entities";
import { Queue } from "bullmq";
import { Worker } from "bullmq";
import chalk from "chalk";
import { config } from "~/config.ts";
import type { KnownEntity } from "~/types/api";
import type { JSONObject } from "~/packages/federation/types";
import { connection } from "~/utils/redis.ts";
export enum DeliveryJobType {
@ -11,7 +12,7 @@ export enum DeliveryJobType {
}
export type DeliveryJobData = {
entity: KnownEntity;
entity: JSONObject;
recipientId: string;
senderId: string;
};
@ -55,7 +56,23 @@ export const getDeliveryWorker = (): Worker<
`Federating entity [${entity.id}] from @${sender.getAcct()} to @${recipient.getAcct()}`,
);
await sender.federateToUser(entity, recipient);
const type = entity.type;
const entityCtor = Object.values(VersiaEntities).find(
(ctor) => ctor.name === type,
) as typeof VersiaEntities.Entity | undefined;
if (!entityCtor) {
throw new Error(
`Could not resolve entity type ${chalk.gray(
type,
)} for entity [${entity.id}]`,
);
}
await sender.federateToUser(
await entityCtor.fromJSON(entity),
recipient,
);
await job.log(
`✔ Finished federating entity [${entity.id}]`,

View file

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

View file

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

View file

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

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

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