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

@ -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> {