mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 05:49:16 +01:00
refactor(federation): ♻️ Rewrite federation SDK
This commit is contained in:
parent
ad1dc13a51
commit
d638610361
72 changed files with 2137 additions and 738 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue