feat(federation): Port to Versia 0.6

This commit is contained in:
Jesse Wierzbinski 2026-02-25 02:34:27 +01:00
parent de69f27877
commit fca30b4dad
No known key found for this signature in database
62 changed files with 1614 additions and 2008 deletions

View file

@ -1,3 +1,4 @@
import { sign } from "@versia/sdk/crypto";
import * as VersiaEntities from "@versia/sdk/entities";
import { FederationRequester } from "@versia/sdk/http";
import { config } from "@versia-server/config";
@ -15,6 +16,7 @@ import {
inArray,
type SQL,
} from "drizzle-orm";
import type { HttpVerb, KnownEntity } from "~/types/api.ts";
import { ApiError } from "../api-error.ts";
import { db } from "../tables/db.ts";
import { Instances } from "../tables/schema.ts";
@ -111,6 +113,13 @@ export class Instance extends BaseInterface<typeof Instances> {
}
}
public static get federationRequester(): FederationRequester {
return new FederationRequester(
config.instance.keys.private,
config.http.base_url,
);
}
public static async fromUser(user: User): Promise<Instance | null> {
if (!user.data.instanceId) {
return null;
@ -139,29 +148,24 @@ export class Instance extends BaseInterface<typeof Instances> {
return this.data.id;
}
public static async fetchMetadata(url: URL): Promise<{
public static async fetchMetadata(domain: string): Promise<{
metadata: VersiaEntities.InstanceMetadata;
protocol: "versia" | "activitypub";
}> {
const origin = new URL(url).origin;
const wellKnownUrl = new URL("/.well-known/versia", origin);
try {
const metadata = await new FederationRequester(
config.instance.keys.private,
config.http.base_url,
).fetchEntity(wellKnownUrl, VersiaEntities.InstanceMetadata);
const metadata =
await Instance.federationRequester.resolveInstance(domain);
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);
const data = await Instance.fetchActivityPubMetadata(domain);
if (!data) {
throw new ApiError(
404,
`Instance at ${origin} is not reachable or does not exist`,
`Instance at ${domain} is not reachable or does not exist`,
);
}
@ -173,9 +177,9 @@ export class Instance extends BaseInterface<typeof Instances> {
}
private static async fetchActivityPubMetadata(
url: URL,
domain: string,
): Promise<VersiaEntities.InstanceMetadata | null> {
const origin = new URL(url).origin;
const origin = new URL(`https://${domain}`);
const wellKnownUrl = new URL("/.well-known/nodeinfo", origin);
// Go to endpoint, then follow the links to the actual metadata
@ -254,7 +258,7 @@ export class Instance extends BaseInterface<typeof Instances> {
key: "",
algorithm: "ed25519",
},
host: new URL(url).host,
domain: origin.hostname,
compatibility: {
extensions: [],
versions: [],
@ -268,50 +272,33 @@ export class Instance extends BaseInterface<typeof Instances> {
}
}
public static resolveFromHost(host: string): Promise<Instance> {
if (host.startsWith("http")) {
const url = new URL(host);
return Instance.resolve(url);
}
const url = new URL(`https://${host}`);
return Instance.resolve(url);
}
public static async resolve(url: URL): Promise<Instance> {
const host = url.host;
public static async resolve(domain: string): Promise<Instance> {
const existingInstance = await Instance.fromSql(
eq(Instances.baseUrl, host),
eq(Instances.baseUrl, domain),
);
if (existingInstance) {
return existingInstance;
}
const output = await Instance.fetchMetadata(url);
const output = await Instance.fetchMetadata(domain);
const { metadata, protocol } = output;
return Instance.insert({
id: randomUUIDv7(),
baseUrl: host,
baseUrl: domain,
name: metadata.data.name,
version: metadata.data.software.version,
logo: metadata.data.logo,
protocol,
publicKey: metadata.data.public_key,
inbox: metadata.data.shared_inbox ?? null,
extensions: metadata.data.extensions ?? null,
});
}
public async updateFromRemote(): Promise<Instance> {
const output = await Instance.fetchMetadata(
new URL(`https://${this.data.baseUrl}`),
);
const output = await Instance.fetchMetadata(this.data.baseUrl);
if (!output) {
federationResolversLogger.error`Failed to update instance ${chalk.bold(
@ -328,13 +315,39 @@ export class Instance extends BaseInterface<typeof Instances> {
logo: metadata.data.logo,
protocol,
publicKey: metadata.data.public_key,
inbox: metadata.data.shared_inbox ?? null,
extensions: metadata.data.extensions ?? null,
});
return this;
}
/**
* Signs a Versia entity with this instance'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 static async sign(
entity: KnownEntity | VersiaEntities.Collection,
signatureUrl: URL,
signatureMethod: HttpVerb = "POST",
): Promise<{
headers: Headers;
}> {
const { headers } = await sign(
config.instance.keys.private,
config.http.base_url,
new Request(signatureUrl, {
method: signatureMethod,
body: JSON.stringify(entity),
}),
);
return { headers };
}
public async sendMessage(content: string): Promise<void> {
if (
!this.data.extensions?.["pub.versia:instance_messaging"]?.endpoint

View file

@ -11,17 +11,22 @@ import {
} from "drizzle-orm";
import { db } from "../tables/db.ts";
import {
type Instances,
Likes,
type Notes,
Notifications,
type Users,
} from "../tables/schema.ts";
import { BaseInterface } from "./base.ts";
import { User } from "./user.ts";
import type { User } from "./user.ts";
type LikeType = InferSelectModel<typeof Likes> & {
liker: InferSelectModel<typeof Users>;
liked: InferSelectModel<typeof Notes>;
liked: InferSelectModel<typeof Notes> & {
author: InferSelectModel<typeof Users> & {
instance: InferSelectModel<typeof Instances> | null;
};
};
};
export class Like extends BaseInterface<typeof Likes, LikeType> {
@ -57,7 +62,15 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
where: sql,
orderBy,
with: {
liked: true,
liked: {
with: {
author: {
with: {
instance: true,
},
},
},
},
liker: true,
},
});
@ -65,6 +78,7 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
if (!found) {
return null;
}
return new Like(found);
}
@ -73,7 +87,6 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
orderBy: SQL<unknown> | undefined = desc(Likes.id),
limit?: number,
offset?: number,
extra?: Parameters<typeof db.query.Likes.findMany>[0],
): Promise<Like[]> {
const found = await db.query.Likes.findMany({
where: sql,
@ -81,9 +94,16 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
limit,
offset,
with: {
liked: true,
liked: {
with: {
author: {
with: {
instance: true,
},
},
},
},
liker: true,
...extra?.with,
},
});
@ -146,37 +166,28 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
}
public toVersia(): VersiaEntities.Like {
let likedReference = this.data.liked.id;
if (this.data.liked.author.instance) {
likedReference = `${this.data.liked.author.instance.baseUrl}:${this.data.liked.remoteId}`;
}
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,
).href,
id: this.id,
author: this.data.liker.id,
type: "pub.versia:likes/Like",
created_at: this.data.createdAt.toISOString(),
liked: this.data.liked.uri
? new URL(this.data.liked.uri).href
: new URL(`/notes/${this.data.liked.id}`, config.http.base_url)
.href,
uri: this.getUri().href,
liked: likedReference,
});
}
public unlikeToVersia(unliker?: User): VersiaEntities.Delete {
return new VersiaEntities.Delete({
type: "Delete",
id: crypto.randomUUID(),
created_at: new Date().toISOString(),
author: User.getUri(
unliker?.id ?? this.data.liker.id,
unliker?.data.uri
? new URL(unliker.data.uri)
: this.data.liker.uri
? new URL(this.data.liker.uri)
: null,
).href,
author: unliker ? unliker.id : this.data.liker.id,
deleted_type: "pub.versia:likes/Like",
deleted: this.getUri().href,
deleted: this.id,
});
}
}

View file

@ -476,9 +476,7 @@ export class Media extends BaseInterface<typeof Medias> {
[file.type]: {
content: uri.toString(),
remote: true,
hash: {
sha256: hash,
},
hash,
width,
height,
description: options?.description,

View file

@ -3,7 +3,6 @@ import type {
Status as StatusSchema,
} from "@versia/client/schemas";
import * as VersiaEntities from "@versia/sdk/entities";
import { FederationRequester } from "@versia/sdk/http";
import type { NonTextContentFormatSchema } from "@versia/sdk/schemas";
import { config } from "@versia-server/config";
import { randomUUIDv7 } from "bun";
@ -25,7 +24,6 @@ import { mergeAndDeduplicate } from "@/lib.ts";
import { sanitizedHtmlStrip } from "@/sanitization";
import { versiaTextToHtml } from "../parsers.ts";
import { DeliveryJobType, deliveryQueue } from "../queues/delivery/queue.ts";
import { uuid } from "../regex.ts";
import { db } from "../tables/db.ts";
import {
EmojiToNote,
@ -166,8 +164,24 @@ const findManyNotes = async (
: sql`false`.as("liked"),
},
},
reply: true,
quote: true,
reply: {
with: {
author: {
with: {
instance: true,
},
},
},
},
quote: {
with: {
author: {
with: {
instance: true,
},
},
},
},
},
extras: {
pinned: userId
@ -197,19 +211,13 @@ const findManyNotes = async (
return output.map((post) => ({
...post,
author: transformOutputToUserWithRelations(post.author),
mentions: post.mentions.map((mention) => ({
...mention.user,
endpoints: mention.user.endpoints,
})),
mentions: post.mentions.map((mention) => mention.user),
attachments: post.attachments.map((attachment) => attachment.media),
emojis: (post.emojis ?? []).map((emoji) => emoji.emoji),
reblog: post.reblog && {
...post.reblog,
author: transformOutputToUserWithRelations(post.reblog.author),
mentions: post.reblog.mentions.map((mention) => ({
...mention.user,
endpoints: mention.user.endpoints,
})),
mentions: post.reblog.mentions.map((mention) => mention.user),
attachments: post.reblog.attachments.map(
(attachment) => attachment.media,
),
@ -236,8 +244,20 @@ type NoteTypeWithRelations = NoteType & {
attachments: (typeof Media.$type)[];
reblog: NoteTypeWithoutRecursiveRelations | null;
emojis: (typeof Emoji.$type)[];
reply: NoteType | null;
quote: NoteType | null;
reply:
| (NoteType & {
author: InferSelectModel<typeof Users> & {
instance: typeof Instance.$type | null;
};
})
| null;
quote:
| (NoteType & {
author: InferSelectModel<typeof Users> & {
instance: typeof Instance.$type | null;
};
})
| null;
client: typeof Client.$type | null;
pinned: boolean;
reblogged: boolean;
@ -404,6 +424,17 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
return this.data.id;
}
public get reference(): VersiaEntities.Reference {
if (this.remote) {
const instanceUrl = new URL(
this.author.data.instance?.baseUrl || "",
);
return new VersiaEntities.Reference(this.id, instanceUrl.hostname);
}
return new VersiaEntities.Reference(this.id);
}
public async federateToUsers(): Promise<void> {
const users = await this.getUsersToFederateTo();
@ -489,13 +520,13 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
* If the note is already reblogged, it will return the existing reblog. Also creates a notification for the author of the note.
* @param reblogger The user reblogging the note
* @param visibility The visibility of the reblog
* @param uri The URI of the reblog, if it is remote
* @param remoteId The remote ID of the reblog, if it is from a remote user
* @returns The reblog object created or the existing reblog
*/
public async reblog(
reblogger: User,
visibility: z.infer<typeof StatusSchema.shape.visibility>,
uri?: URL,
remoteId?: string,
): Promise<Note> {
const existingReblog = await Note.fromSql(
and(eq(Notes.authorId, reblogger.id), eq(Notes.reblogId, this.id)),
@ -515,7 +546,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
sensitive: false,
updatedAt: new Date(),
clientId: null,
uri: uri?.href,
remoteId,
});
await this.recalculateReblogCount();
@ -612,10 +643,10 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
*
* If the note is already liked, it will return the existing like. Also creates a notification for the author of the note.
* @param liker The user liking the note
* @param uri The URI of the like, if it is remote
* @param remoteId The id of the like, if it is remote
* @returns The like object created or the existing like
*/
public async like(liker: User, uri?: URL): Promise<Like> {
public async like(liker: User, remoteId?: string): Promise<Like> {
// Check if the user has already liked the note
const existingLike = await Like.fromSql(
and(eq(Likes.likerId, liker.id), eq(Likes.likedId, this.id)),
@ -629,7 +660,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
id: randomUUIDv7(),
likerId: liker.id,
likedId: this.id,
uri: uri?.href,
remoteId,
});
await this.recalculateLikeCount();
@ -904,73 +935,84 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
}
/**
* Resolve a note from a URI
* @param uri - The URI of the note to resolve
* Resolve a note from a reference
* @param reference - The URI of the note to resolve
* @returns The resolved note
*/
public static async resolve(uri: URL): Promise<Note | null> {
public static async resolve(
reference: VersiaEntities.Reference,
): Promise<Note | null> {
// Check if note not already in database
const foundNote = await Note.fromSql(eq(Notes.uri, uri.href));
if (
!reference.domain ||
reference.domain === config.http.base_url.hostname
) {
return await Note.fromId(reference.id);
}
const instance = await Instance.resolve(reference.domain);
if (!instance) {
return null;
}
const foundNote = await Note.fromSql(
and(
eq(Notes.remoteId, reference.id),
eq(
Notes.authorId,
sql`(
SELECT "Users".id FROM "Users"
WHERE "Users".instanceId = ${instance.id}
LIMIT 1
)`,
),
),
);
if (foundNote) {
return foundNote;
}
// Check if URI is of a local note
if (uri.origin === config.http.base_url.origin) {
const noteUuid = uri.pathname.match(uuid);
if (!noteUuid?.[0]) {
throw new Error(
`URI ${uri} is of a local note, but it could not be parsed`,
);
}
return await Note.fromId(noteUuid[0]);
}
return Note.fromVersia(uri);
return Note.fromVersia(reference);
}
/**
* Takes a Versia Note representation, and serializes it to the database.
*
* If the note already exists, it will update it.
* @param versiaNote - URL or Versia Note representation
* @param versiaNote - Reference or Versia Note representation
*/
public static async fromVersia(
versiaNote: VersiaEntities.Note | URL,
versiaNote: VersiaEntities.Note | VersiaEntities.Reference,
): Promise<Note> {
if (versiaNote instanceof URL) {
if (versiaNote instanceof VersiaEntities.Reference) {
// No bridge support for notes yet
const note = await new FederationRequester(
config.instance.keys.private,
config.http.base_url,
).fetchEntity(versiaNote, VersiaEntities.Note);
const note = await Instance.federationRequester.fetchEntity(
versiaNote,
VersiaEntities.Note,
);
return Note.fromVersia(note);
}
const {
author: authorUrl,
created_at,
uri,
extensions,
group,
is_sensitive,
mentions: noteMentions,
quotes,
replies_to,
subject,
} = versiaNote.data;
const instance = await Instance.resolve(new URL(authorUrl));
const author = await User.resolve(new URL(authorUrl));
const { created_at, extensions, group, id, is_sensitive, subject } =
versiaNote.data;
if (!versiaNote.author.domain) {
throw new Error("Entity author domain is missing");
}
const instance = await Instance.resolve(versiaNote.author.domain);
const author = await User.resolve(versiaNote.author);
if (!author) {
throw new Error("Entity author could not be resolved");
}
const existingNote = await Note.fromSql(eq(Notes.uri, uri));
const existingNote = await Note.fromSql(
and(eq(Notes.remoteId, id), eq(Notes.authorId, author.id)),
);
const note =
existingNote ??
@ -978,7 +1020,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
id: randomUUIDv7(),
authorId: author.id,
visibility: "public",
uri,
remoteId: id,
createdAt: new Date(created_at),
}));
@ -999,9 +1041,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
const mentions = (
await Promise.all(
noteMentions?.map((mention) =>
User.resolve(new URL(mention)),
) ?? [],
versiaNote.mentions.map((m) => User.resolve(m)) ?? [],
)
).filter((m) => m !== null);
@ -1011,10 +1051,12 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
? "direct"
: (group as "public" | "followers" | "unlisted");
const reply = replies_to
? await Note.resolve(new URL(replies_to))
const reply = versiaNote.repliesTo
? await Note.resolve(versiaNote.repliesTo)
: null;
const quote = versiaNote.quotes
? await Note.resolve(versiaNote.quotes)
: null;
const quote = quotes ? await Note.resolve(new URL(quotes)) : null;
const spoiler = subject ? await sanitizedHtmlStrip(subject) : undefined;
await note.update({
@ -1169,9 +1211,11 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
mention.username,
mention.instance?.baseUrl,
),
url: User.getUri(
mention.id,
mention.uri ? new URL(mention.uri) : null,
url: new URL(
`/@${mention.username}${
mention.instance ? `@${mention.instance.baseUrl}` : ""
}`,
config.http.base_url,
).toString(),
username: mention.username,
})),
@ -1191,9 +1235,9 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
sensitive: data.sensitive,
spoiler_text: data.spoilerText,
tags: [],
uri: data.uri || this.getUri().toString(),
uri: this.getUri().toString(),
visibility: data.visibility,
url: data.uri || this.getMastoUri().toString(),
url: this.getMastoUri().toString(),
bookmarked: false,
quote: data.quotingId
? ((await Note.fromId(data.quotingId, userFetching?.id).then(
@ -1207,9 +1251,14 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
}
public getUri(): URL {
return this.data.uri
? new URL(this.data.uri)
: new URL(`/notes/${this.id}`, config.http.base_url);
const domain = this.author.data.instance?.baseUrl
? new URL(`https://${this.author.data.instance.baseUrl}`)
: config.http.base_url;
return new URL(
`/.versia/v0.6/entities/Note/${this.id}`,
`https://${domain}`,
);
}
/**
@ -1224,14 +1273,11 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
}
public deleteToVersia(): VersiaEntities.Delete {
const id = crypto.randomUUID();
return new VersiaEntities.Delete({
type: "Delete",
id,
author: this.author.uri.href,
author: this.author.id,
deleted_type: "Note",
deleted: this.getUri().href,
deleted: this.id,
created_at: new Date().toISOString(),
});
}
@ -1242,12 +1288,24 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
*/
public toVersia(): VersiaEntities.Note {
const status = this.data;
let quoteReference = status.quote?.id ?? null;
if (quoteReference && status.quote?.author.instance) {
quoteReference = `${status.quote.author.instance.baseUrl}:${status.quote.remoteId}`;
}
let replyReference = status.reply?.id ?? null;
if (replyReference && status.reply?.author.instance) {
replyReference = `${status.reply.author.instance.baseUrl}:${status.reply.remoteId}`;
}
return new VersiaEntities.Note({
type: "Note",
created_at: status.createdAt.toISOString(),
id: status.id,
author: this.author.uri.href,
uri: this.getUri().href,
author: this.author.id,
content: {
"text/html": {
content: status.content,
@ -1258,20 +1316,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
remote: false,
},
},
collections: {
replies: new URL(
`/notes/${status.id}/replies`,
config.http.base_url,
).href,
quotes: new URL(
`/notes/${status.id}/quotes`,
config.http.base_url,
).href,
"pub.versia:share/Shares": new URL(
`/notes/${status.id}/shares`,
config.http.base_url,
).href,
},
previews: [],
attachments: status.attachments.map(
(attachment) =>
new Media(attachment).toVersia().data as z.infer<
@ -1279,25 +1324,13 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
>,
),
is_sensitive: status.sensitive,
mentions: status.mentions.map(
(mention) =>
User.getUri(
mention.id,
mention.uri ? new URL(mention.uri) : null,
).href,
mentions: status.mentions.map((mention) =>
mention.instance
? `${mention.instance.baseUrl}:${mention.id}`
: mention.id,
),
quotes: status.quote
? status.quote.uri
? new URL(status.quote.uri).href
: new URL(`/notes/${status.quote.id}`, config.http.base_url)
.href
: null,
replies_to: status.reply
? status.reply.uri
? new URL(status.reply.uri).href
: new URL(`/notes/${status.reply.id}`, config.http.base_url)
.href
: null,
quotes: quoteReference,
replies_to: replyReference,
subject: status.spoilerText,
// TODO: Refactor as part of groups
group: status.visibility === "public" ? "public" : "followers",
@ -1319,26 +1352,22 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
return new VersiaEntities.Share({
type: "pub.versia:share/Share",
id: crypto.randomUUID(),
author: this.author.uri.href,
uri: new URL(`/shares/${this.id}`, config.http.base_url).href,
author: this.author.id,
id: this.id,
created_at: new Date().toISOString(),
shared: new Note(this.data.reblog as NoteTypeWithRelations).getUri()
.href,
shared: this.data.reblog.author.instance
? `${this.data.reblog.author.instance.baseUrl}:${this.data.reblog.id}`
: this.data.reblog.id,
});
}
public toVersiaUnshare(): VersiaEntities.Delete {
return new VersiaEntities.Delete({
type: "Delete",
id: crypto.randomUUID(),
created_at: new Date().toISOString(),
author: User.getUri(
this.data.authorId,
this.data.author.uri ? new URL(this.data.author.uri) : null,
).href,
author: this.author.id,
deleted_type: "pub.versia:share/Share",
deleted: new URL(`/shares/${this.id}`, config.http.base_url).href,
deleted: this.id,
});
}

View file

@ -1,5 +1,4 @@
import * as VersiaEntities from "@versia/sdk/entities";
import { config } from "@versia-server/config";
import { randomUUIDv7 } from "bun";
import {
and,
@ -12,17 +11,26 @@ import {
type SQL,
} from "drizzle-orm";
import { db } from "../tables/db.ts";
import { type Notes, Reactions, type Users } from "../tables/schema.ts";
import {
type Instances,
type Notes,
Reactions,
type Users,
} from "../tables/schema.ts";
import { BaseInterface } from "./base.ts";
import { Emoji } from "./emoji.ts";
import { Instance } from "./instance.ts";
import type { Note } from "./note.ts";
import { User } from "./user.ts";
import type { User } from "./user.ts";
type ReactionType = InferSelectModel<typeof Reactions> & {
emoji: typeof Emoji.$type | null;
author: InferSelectModel<typeof Users>;
note: InferSelectModel<typeof Notes>;
note: InferSelectModel<typeof Notes> & {
author: InferSelectModel<typeof Users> & {
instance: InferSelectModel<typeof Instances> | null;
};
};
};
export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
@ -64,7 +72,15 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
},
},
author: true,
note: true,
note: {
with: {
author: {
with: {
instance: true,
},
},
},
},
},
orderBy,
});
@ -96,7 +112,15 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
},
},
author: true,
note: true,
note: {
with: {
author: {
with: {
instance: true,
},
},
},
},
},
});
@ -210,19 +234,18 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
throw new Error("Cannot convert a non-local reaction to Versia");
}
let noteReference = this.data.note.id;
if (this.data.note.author.instance) {
noteReference = `${this.data.note.author.instance.baseUrl}:${this.data.note.remoteId}`;
}
return new VersiaEntities.Reaction({
uri: this.getUri(config.http.base_url).href,
type: "pub.versia:reactions/Reaction",
author: User.getUri(
this.data.authorId,
this.data.author.uri ? new URL(this.data.author.uri) : null,
).href,
author: this.data.author.id,
created_at: this.data.createdAt.toISOString(),
id: this.id,
object: this.data.note.uri
? new URL(this.data.note.uri).href
: new URL(`/notes/${this.data.noteId}`, config.http.base_url)
.href,
object: noteReference,
content: this.hasCustomEmoji()
? `:${this.data.emoji?.shortcode}:`
: this.data.emojiText || "",
@ -243,14 +266,10 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
public toVersiaUnreact(): VersiaEntities.Delete {
return new VersiaEntities.Delete({
type: "Delete",
id: crypto.randomUUID(),
created_at: new Date().toISOString(),
author: User.getUri(
this.data.authorId,
this.data.author.uri ? new URL(this.data.author.uri) : null,
).href,
author: this.data.authorId,
deleted_type: "pub.versia:reactions/Reaction",
deleted: this.getUri(config.http.base_url).href,
deleted: this.id,
});
}
@ -279,7 +298,7 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
return Reaction.insert({
id: randomUUIDv7(),
uri: reactionToConvert.data.uri,
remoteId: reactionToConvert.data.id,
authorId: author.id,
noteId: note.id,
emojiId: emoji ? emoji.id : null,

View file

@ -4,15 +4,11 @@ import type {
RolePermission,
Source,
} from "@versia/client/schemas";
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 { config, ProxiableUrl } from "@versia-server/config";
import {
federationDeliveryLogger,
federationResolversLogger,
} from "@versia-server/logging";
import { federationDeliveryLogger } from "@versia-server/logging";
import { password as bunPassword, randomUUIDv7 } from "bun";
import chalk from "chalk";
import {
@ -33,10 +29,9 @@ import { htmlToText } from "html-to-text";
import type { z } from "zod";
import { getBestContentType } from "@/content_types";
import { randomString } from "@/math";
import type { HttpVerb, KnownEntity } from "~/types/api.ts";
import type { KnownEntity } from "~/types/api.ts";
import { DeliveryJobType, deliveryQueue } from "../queues/delivery/queue.ts";
import { PushJobType, pushQueue } from "../queues/push/queue.ts";
import { uuid } from "../regex.ts";
import { db } from "../tables/db.ts";
import {
EmojiToUser,
@ -79,7 +74,7 @@ export const userRelations = {
// TODO: Remove this function and use what drizzle outputs directly instead of transforming it
export const transformOutputToUserWithRelations = (
user: Omit<InferSelectModel<typeof Users>, "endpoints"> & {
user: InferSelectModel<typeof Users> & {
followerCount: unknown;
followingCount: unknown;
statusCount: unknown;
@ -96,7 +91,6 @@ export const transformOutputToUserWithRelations = (
roleId: string;
role?: typeof Role.$type;
}[];
endpoints: unknown;
},
): typeof User.$type => {
return {
@ -104,17 +98,6 @@ export const transformOutputToUserWithRelations = (
followerCount: Number(user.followerCount),
followingCount: Number(user.followingCount),
statusCount: Number(user.statusCount),
endpoints:
user.endpoints ??
({} as Partial<{
dislikes: string;
featured: string;
likes: string;
followers: string;
following: string;
inbox: string;
outbox: string;
}>),
emojis: user.emojis.map(
(emoji) =>
(emoji as unknown as Record<string, object>)
@ -239,14 +222,26 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return !this.local;
}
public get uri(): URL {
return this.data.uri
? new URL(this.data.uri)
: new URL(`/users/${this.data.id}`, config.http.base_url);
public get reference(): VersiaEntities.Reference {
if (this.local) {
return new VersiaEntities.Reference(this.id);
}
return new VersiaEntities.Reference(
this.data.remoteId as string,
(this.data.instance as typeof Instance.$type).baseUrl,
);
}
public static getUri(id: string, uri: URL | null): URL {
return uri ? uri : new URL(`/users/${id}`, config.http.base_url);
public get uri(): URL {
const domain = this.data.instance?.baseUrl
? new URL(`https://${this.data.instance.baseUrl}`)
: config.http.base_url;
return new URL(
`/.versia/v0.6/entities/User/${this.id}`,
`https://${domain}`,
);
}
public hasPermission(permission: RolePermission): boolean {
@ -335,13 +330,13 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
}
private unfollowToVersia(followee: User): VersiaEntities.Unfollow {
const id = crypto.randomUUID();
return new VersiaEntities.Unfollow({
type: "Unfollow",
id,
author: this.uri.href,
author: this.id,
created_at: new Date().toISOString(),
followee: followee.uri.href,
followee: followee.data.instance
? `${followee.data.instance.baseUrl}:${followee.id}`
: followee.id,
});
}
@ -359,10 +354,11 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
const entity = new VersiaEntities.FollowAccept({
type: "FollowAccept",
id: crypto.randomUUID(),
author: this.uri.href,
author: this.id,
created_at: new Date().toISOString(),
follower: follower.uri.href,
follower: follower.data.instance
? `${follower.data.instance.baseUrl}:${follower.id}`
: follower.id,
});
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
@ -383,10 +379,11 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
const entity = new VersiaEntities.FollowReject({
type: "FollowReject",
id: crypto.randomUUID(),
author: this.uri.href,
author: this.id,
created_at: new Date().toISOString(),
follower: follower.uri.href,
follower: follower.data.instance
? `${follower.data.instance.baseUrl}:${follower.id}`
: follower.id,
});
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
@ -396,41 +393,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
});
}
/**
* 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.uri,
new Request(signatureUrl, {
method: signatureMethod,
body: JSON.stringify(entity),
}),
);
return { headers };
}
/**
* Perform a WebFinger lookup to find a user's URI
* @param username
@ -708,54 +670,41 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
* Takes a Versia User representation, and serializes it to the database.
*
* If the user already exists, it will update it.
* @param versiaUser URL or Versia User representation
* @param versiaUser Reference or Versia User representation
*/
public static async fromVersia(
versiaUser: VersiaEntities.User | URL,
versiaUser: VersiaEntities.User | VersiaEntities.Reference,
domain: string,
): Promise<User> {
if (versiaUser instanceof URL) {
let uri = versiaUser;
const instance = await Instance.resolve(uri);
if (instance.data.protocol === "activitypub") {
if (!config.federation.bridge) {
throw new Error("ActivityPub bridge is not enabled");
}
uri = new URL(
`/apbridge/versia/query?${new URLSearchParams({
user_url: uri.href,
})}`,
config.federation.bridge.url,
if (versiaUser instanceof VersiaEntities.Reference) {
if (!versiaUser.domain) {
throw new Error(
"Cannot fetch Versia user from reference without domain",
);
}
const user = await new FederationRequester(
config.instance.keys.private,
config.http.base_url,
).fetchEntity(uri, VersiaEntities.User);
const user = await Instance.federationRequester.fetchEntity(
versiaUser,
VersiaEntities.User,
);
return User.fromVersia(user);
return User.fromVersia(user, versiaUser.domain);
}
const {
username,
inbox,
avatar,
header,
display_name,
id,
fields,
collections,
created_at,
manually_approves_followers,
bio,
public_key,
uri,
extensions,
} = versiaUser.data;
const instance = await Instance.resolve(new URL(versiaUser.data.uri));
const instance = await Instance.resolve(domain);
const existingUser = await User.fromSql(
eq(Users.uri, versiaUser.data.uri),
and(eq(Users.instanceId, instance.id), eq(Users.remoteId, id)),
);
const user =
@ -763,60 +712,50 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
(await User.insert({
username,
id: randomUUIDv7(),
publicKey: public_key.key,
uri,
instanceId: instance.id,
remoteId: id,
}));
// Avatars and headers are stored in a separate table, so we need to update them separately
let userAvatar: Media | null = null;
let userHeader: Media | null = null;
if (avatar) {
if (versiaUser.avatar) {
if (user.avatar) {
userAvatar = new Media(
await user.avatar.update({
content: avatar,
content: versiaUser.avatar.data,
}),
);
} else {
userAvatar = await Media.insert({
id: randomUUIDv7(),
content: avatar,
content: versiaUser.avatar.data,
});
}
}
if (header) {
if (versiaUser.header) {
if (user.header) {
userHeader = new Media(
await user.header.update({
content: header,
content: versiaUser.header.data,
}),
);
} else {
userHeader = await Media.insert({
id: randomUUIDv7(),
content: header,
content: versiaUser.header.data,
});
}
}
await user.update({
createdAt: new Date(created_at),
endpoints: {
inbox,
outbox: collections.outbox,
followers: collections.followers,
following: collections.following,
featured: collections.featured,
likes: collections["pub.versia:likes/Likes"] ?? undefined,
dislikes: collections["pub.versia:likes/Dislikes"] ?? undefined,
},
isLocked: manually_approves_followers ?? false,
isLocked: manually_approves_followers,
avatarId: userAvatar?.id,
headerId: userHeader?.id,
fields: fields ?? [],
fields,
displayName: display_name,
note: getBestContentType(bio).content,
});
@ -847,31 +786,39 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return user;
}
public static async resolve(uri: URL): Promise<User | null> {
federationResolversLogger.debug`Resolving user ${chalk.gray(uri)}`;
public static async resolve(
reference: VersiaEntities.Reference,
): Promise<User> {
// Check if user not already in database
const foundUser = await User.fromSql(eq(Users.uri, uri.href));
if (
!reference.domain ||
reference.domain === config.http.base_url.hostname
) {
const user = await User.fromId(reference.id);
if (!user) {
throw new Error(
"Failed to resolve user reference: User not found",
);
}
return user;
}
const instance = await Instance.resolve(reference.domain);
const foundUser = await User.fromSql(
and(
eq(Users.instanceId, instance.id),
eq(Users.remoteId, reference.id),
),
);
if (foundUser) {
return foundUser;
}
// Check if URI is of a local user
if (uri.origin === config.http.base_url.origin) {
const userUuid = uri.href.match(uuid);
if (!userUuid?.[0]) {
throw new Error(
`URI ${uri} is of a local user, but it could not be parsed`,
);
}
return await User.fromId(userUuid[0]);
}
federationResolversLogger.debug`User not found in database, fetching from remote`;
return User.fromVersia(uri);
return User.fromVersia(reference, reference.domain);
}
/**
@ -890,31 +837,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return this.avatar?.getUrl();
}
public static async generateKeys(): Promise<{
private_key: string;
public_key: string;
}> {
const keys = await crypto.subtle.generateKey("Ed25519", true, [
"sign",
"verify",
]);
const privateKey = Buffer.from(
await crypto.subtle.exportKey("pkcs8", keys.privateKey),
).toString("base64");
const publicKey = Buffer.from(
await crypto.subtle.exportKey("spki", keys.publicKey),
).toString("base64");
// Add header, footer and newlines later on
// These keys are base64 encrypted
return {
private_key: privateKey,
public_key: publicKey,
};
}
public static async register(
username: string,
options?: Partial<{
@ -924,8 +846,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
isAdmin: boolean;
}>,
): Promise<User> {
const keys = await User.generateKeys();
const user = await User.insert({
id: randomUUIDv7(),
username,
@ -937,9 +857,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
note: "",
avatarId: options?.avatar?.id,
isAdmin: options?.isAdmin,
publicKey: keys.public_key,
fields: [],
privateKey: keys.private_key,
updatedAt: new Date(),
source: {
language: "en",
@ -999,11 +917,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
newUser.avatar ||
newUser.header ||
newUser.fields ||
newUser.publicKey ||
newUser.isAdmin ||
newUser.isBot ||
newUser.isLocked ||
newUser.endpoints ||
newUser.isDiscoverable ||
newUser.isIndexable)
) {
@ -1013,20 +929,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return updated.data;
}
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.uri);
});
}
/**
* Get all remote followers of the user
* @returns The remote followers
@ -1076,17 +978,13 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
entity: KnownEntity,
user: User,
): Promise<{ ok: boolean }> {
const inbox = user.data.instance?.inbox || user.data.endpoints?.inbox;
if (!inbox) {
throw new Error(
`User ${chalk.gray(user.uri)} does not have an inbox endpoint`,
);
if (!user.data.instance) {
throw new Error("Cannot federate to a local user");
}
try {
await (await this.federationRequester).postEntity(
new URL(inbox),
await Instance.federationRequester.postEntity(
user.data.instance.baseUrl,
entity,
);
} catch (e) {
@ -1110,9 +1008,12 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
display_name: user.displayName || user.username,
note: user.note,
uri: this.uri.href,
url:
user.uri ||
new URL(`/@${user.username}`, config.http.base_url).href,
url: new URL(
`/@${user.username}${
user.instanceId ? `@${user.instance?.baseUrl}` : ""
}`,
config.http.base_url,
).href,
avatar: this.getAvatarUrl().proxied,
header: this.getHeaderUrl()?.proxied ?? "",
locked: user.isLocked,
@ -1166,7 +1067,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return new VersiaEntities.User({
id: user.id,
type: "User",
uri: this.uri.href,
bio: {
"text/html": {
content: user.note,
@ -1178,34 +1078,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
},
},
created_at: user.createdAt.toISOString(),
collections: {
featured: new URL(
`/users/${user.id}/featured`,
config.http.base_url,
).href,
"pub.versia:likes/Likes": new URL(
`/users/${user.id}/likes`,
config.http.base_url,
).href,
"pub.versia:likes/Dislikes": new URL(
`/users/${user.id}/dislikes`,
config.http.base_url,
).href,
followers: new URL(
`/users/${user.id}/followers`,
config.http.base_url,
).href,
following: new URL(
`/users/${user.id}/following`,
config.http.base_url,
).href,
outbox: new URL(
`/users/${user.id}/outbox`,
config.http.base_url,
).href,
},
inbox: new URL(`/users/${user.id}/inbox`, config.http.base_url)
.href,
indexable: this.data.isIndexable,
username: user.username,
manually_approves_followers: this.data.isLocked,
@ -1217,11 +1089,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
>,
display_name: user.displayName,
fields: user.fields,
public_key: {
actor: new URL(`/users/${user.id}`, config.http.base_url).href,
key: user.publicKey,
algorithm: "ed25519",
},
extensions: {
"pub.versia:custom_emojis": {
emojis: user.emojis.map((emoji) =>

View file

@ -199,7 +199,12 @@ export class InboxProcessor {
.on(VersiaEntities.Delete, (d) =>
InboxProcessor.processDelete(d),
)
.on(VersiaEntities.User, (u) => InboxProcessor.processUser(u))
.on(VersiaEntities.User, (u) =>
InboxProcessor.processUser(
u,
this.sender?.instance.data.baseUrl ?? "",
),
)
.on(VersiaEntities.Share, (s) => InboxProcessor.processShare(s))
.on(VersiaEntities.Reaction, (r) =>
InboxProcessor.processReaction(r),
@ -221,8 +226,8 @@ export class InboxProcessor {
private static async processReaction(
reaction: VersiaEntities.Reaction,
): Promise<void> {
const author = await User.resolve(new URL(reaction.data.author));
const note = await Note.resolve(new URL(reaction.data.object));
const author = await User.resolve(reaction.author);
const note = await Note.resolve(reaction.object);
if (!author) {
throw new ApiError(404, "Author not found");
@ -264,9 +269,13 @@ export class InboxProcessor {
* Handles User entity processing.
*
* @param {VersiaUser} user - The User entity to process.
* @param {string} domain - The domain of the user.
* @returns {Promise<void>}
*/
private static async processUser(user: VersiaEntities.User): Promise<void> {
private static async processUser(
user: VersiaEntities.User,
domain: string,
): Promise<void> {
if (
config.validation.filters.username.some((filter) =>
filter.test(user.data.username),
@ -294,7 +303,7 @@ export class InboxProcessor {
return;
}
await User.fromVersia(user);
await User.fromVersia(user, domain);
}
/**
@ -306,8 +315,8 @@ export class InboxProcessor {
private static async processFollowRequest(
follow: VersiaEntities.Follow,
): Promise<void> {
const author = await User.resolve(new URL(follow.data.author));
const followee = await User.resolve(new URL(follow.data.followee));
const author = await User.resolve(follow.author);
const followee = await User.resolve(follow.followee);
if (!author) {
throw new ApiError(404, "Author not found");
@ -354,10 +363,8 @@ export class InboxProcessor {
private static async processFollowAccept(
followAccept: VersiaEntities.FollowAccept,
): Promise<void> {
const author = await User.resolve(new URL(followAccept.data.author));
const follower = await User.resolve(
new URL(followAccept.data.follower),
);
const author = await User.resolve(followAccept.author);
const follower = await User.resolve(followAccept.follower);
if (!author) {
throw new ApiError(404, "Author not found");
@ -391,10 +398,8 @@ export class InboxProcessor {
private static async processFollowReject(
followReject: VersiaEntities.FollowReject,
): Promise<void> {
const author = await User.resolve(new URL(followReject.data.author));
const follower = await User.resolve(
new URL(followReject.data.follower),
);
const author = await User.resolve(followReject.author);
const follower = await User.resolve(followReject.follower);
if (!author) {
throw new ApiError(404, "Author not found");
@ -428,8 +433,8 @@ export class InboxProcessor {
private static async processShare(
share: VersiaEntities.Share,
): Promise<void> {
const author = await User.resolve(new URL(share.data.author));
const sharedNote = await Note.resolve(new URL(share.data.shared));
const author = await User.resolve(share.author);
const sharedNote = await Note.resolve(share.shared);
if (!author) {
throw new ApiError(404, "Author not found");
@ -439,7 +444,7 @@ export class InboxProcessor {
throw new ApiError(404, "Shared Note not found");
}
await sharedNote.reblog(author, "public", new URL(share.data.uri));
await sharedNote.reblog(author, "public", share.data.id);
}
/**
@ -451,17 +456,15 @@ export class InboxProcessor {
public static async processDelete(
delete_: VersiaEntities.Delete,
): Promise<void> {
const toDelete = delete_.data.deleted;
const toDelete = delete_.deleted;
const author = delete_.data.author
? await User.resolve(new URL(delete_.data.author))
: null;
const author = await User.resolve(delete_.author);
switch (delete_.data.deleted_type) {
case "Note": {
const note = await Note.fromSql(
eq(Notes.uri, toDelete),
author ? eq(Notes.authorId, author.id) : undefined,
eq(Notes.remoteId, toDelete.id),
eq(Notes.authorId, author.id),
);
if (!note) {
@ -475,7 +478,7 @@ export class InboxProcessor {
return;
}
case "User": {
const userToDelete = await User.resolve(new URL(toDelete));
const userToDelete = await User.resolve(toDelete);
if (!userToDelete) {
throw new ApiError(404, "User to delete not found");
@ -490,8 +493,8 @@ export class InboxProcessor {
}
case "pub.versia:likes/Like": {
const like = await Like.fromSql(
eq(Likes.uri, toDelete),
author ? eq(Likes.likerId, author.id) : undefined,
eq(Likes.remoteId, toDelete.id),
eq(Likes.likerId, author.id),
);
if (!like) {
@ -525,7 +528,10 @@ export class InboxProcessor {
}
const reblog = await Note.fromSql(
and(eq(Notes.uri, toDelete), eq(Notes.authorId, author.id)),
and(
eq(Notes.remoteId, toDelete.id),
eq(Notes.authorId, author.id),
),
);
if (!reblog) {
@ -568,8 +574,8 @@ export class InboxProcessor {
private static async processLikeRequest(
like: VersiaEntities.Like,
): Promise<void> {
const author = await User.resolve(new URL(like.data.author));
const likedNote = await Note.resolve(new URL(like.data.liked));
const author = await User.resolve(like.author);
const likedNote = await Note.resolve(like.liked);
if (!author) {
throw new ApiError(404, "Author not found");
@ -579,7 +585,7 @@ export class InboxProcessor {
throw new ApiError(404, "Liked Note not found");
}
await likedNote.like(author, new URL(like.data.uri));
await likedNote.like(author, like.data.id);
}
/**

View file

@ -1,4 +1,4 @@
import type * as VersiaEntities from "@versia/sdk/entities";
import * as VersiaEntities from "@versia/sdk/entities";
import { FederationRequester } from "@versia/sdk/http";
import { config } from "@versia-server/config";
import { and, eq, inArray, isNull, or } from "drizzle-orm";
@ -13,6 +13,7 @@ import {
letter,
} from "magic-regexp";
import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization";
import { Instance } from "./db/instance.ts";
import { User } from "./db/user.ts";
import { markdownToHtml } from "./markdown.ts";
import { mention } from "./regex.ts";
@ -81,7 +82,12 @@ export const parseMentionsFromText = async (text: string): Promise<User[]> => {
);
if (url) {
const user = await User.resolve(url);
const userEntity = await Instance.federationRequester.fetchSigned(
url,
VersiaEntities.User,
);
const user = await User.fromVersia(userEntity, url.hostname);
if (user) {
finalList.push(user);

View file

@ -17,7 +17,7 @@ export const getFetchWorker = (): Worker<FetchJobData, void, FetchJobType> =>
await job.log(`Fetching instance metadata from [${uri}]`);
// Check if exists
const host = new URL(uri).host;
const host = new URL(uri).hostname;
const existingInstance = await Instance.fromSql(
eq(Instances.baseUrl, host),
@ -37,7 +37,7 @@ export const getFetchWorker = (): Worker<FetchJobData, void, FetchJobType> =>
return;
}
await Instance.resolve(new URL(uri));
await Instance.resolve(host);
await job.log(
`✔ Finished fetching instance metadata from [${uri}]`,

View file

@ -2,7 +2,6 @@ import { config } from "@versia-server/config";
import { Worker } from "bullmq";
import { ApiError } from "../../api-error.ts";
import { Instance } from "../../db/instance.ts";
import { User } from "../../db/user.ts";
import { InboxProcessor } from "../../inbox-processor.ts";
import { connection } from "../../redis.ts";
import { type InboxJobData, InboxJobType, inboxQueue } from "./queue.ts";
@ -72,51 +71,29 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
"versia-signed-by": string;
};
const sender = await User.resolve(new URL(signedBy));
const sender = await Instance.resolve(signedBy);
if (!(sender || signedBy.startsWith("instance "))) {
if (!sender) {
await job.log(
`Could not resolve sender URI [${signedBy}]`,
`Could not resolve sender domain [${signedBy}]`,
);
return;
}
if (sender?.local) {
throw new Error(
"Cannot process federation requests from local users",
);
}
const remoteInstance = sender
? await Instance.fromUser(sender)
: await Instance.resolveFromHost(
signedBy.split(" ")[1],
);
if (!remoteInstance) {
await job.log("Could not resolve the remote instance.");
return;
}
await job.log(
`Entity [${data.id}] is from remote instance [${remoteInstance.data.baseUrl}]`,
`Entity [${data.id}] is from remote instance [${sender.data.baseUrl}]`,
);
if (!remoteInstance.data.publicKey?.key) {
if (!sender.data.publicKey?.key) {
throw new Error(
`Instance ${remoteInstance.data.baseUrl} has no public key stored in database`,
`Instance ${sender.data.baseUrl} has no public key stored in database`,
);
}
const key = await crypto.subtle.importKey(
"spki",
Buffer.from(
sender?.data.publicKey ??
remoteInstance.data.publicKey.key,
"base64",
),
Buffer.from(sender.data.publicKey.key, "base64"),
"Ed25519",
false,
["verify"],
@ -127,7 +104,7 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
req,
data,
{
instance: remoteInstance,
instance: sender,
key,
},
undefined,
@ -147,10 +124,10 @@ export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
);
await job.log(
`Sending error message to instance [${remoteInstance.data.baseUrl}]`,
`Sending error message to instance [${sender.data.baseUrl}]`,
);
await remoteInstance.sendMessage(
await sender.sendMessage(
`Failed processing entity [${
data.uri
}] delivered to inbox. Returned error:\n\n${JSON.stringify(

View file

@ -137,6 +137,7 @@ export const PushSubscriptionsRelations = relations(
export const Reactions = pgTable("Reaction", {
id: id(),
uri: uri(),
remoteId: text("remote_id"),
// Emoji ID is nullable, in which case it is a text emoji, and the emojiText field is used
emojiId: uuid("emojiId").references(() => Emojis.id, {
onDelete: "cascade",
@ -244,7 +245,7 @@ export const Markers = pgTable("Markers", {
export const Likes = pgTable("Likes", {
id: id(),
uri: uri(),
remoteId: text("remote_id"),
likerId: uuid("likerId")
.notNull()
.references(() => Users.id, {
@ -472,7 +473,7 @@ export const NotificationsRelations = relations(Notifications, ({ one }) => ({
export const Notes = pgTable("Notes", {
id: id(),
uri: uri(),
remoteId: text("remote_id"),
authorId: uuid("authorId")
.notNull()
.references(() => Users.id, {
@ -600,7 +601,7 @@ export const Users = pgTable(
"Users",
{
id: id(),
uri: uri(),
remoteId: text("remote_id"),
username: text("username").notNull(),
displayName: text("display_name"),
password: text("password"),
@ -615,15 +616,6 @@ export const Users = pgTable(
value: z.infer<typeof TextContentFormatSchema>;
}[]
>(),
endpoints: jsonb("endpoints").$type<Partial<{
dislikes?: string;
featured: string;
likes?: string;
followers: string;
following: string;
inbox: string;
outbox: string;
}> | null>(),
source: jsonb("source").$type<z.infer<typeof Source>>(),
avatarId: uuid("avatarId").references(() => Medias.id, {
onDelete: "set null",
@ -646,8 +638,6 @@ export const Users = pgTable(
.notNull(),
isIndexable: boolean("is_indexable").default(true).notNull(),
sanctions: text("sanctions").array(),
publicKey: text("public_key").notNull(),
privateKey: text("private_key"),
instanceId: uuid("instanceId").references(() => Instances.id, {
onDelete: "cascade",
onUpdate: "cascade",
@ -656,11 +646,7 @@ export const Users = pgTable(
.default(false)
.notNull(),
},
(table) => [
uniqueIndex().on(table.uri),
index().on(table.username),
uniqueIndex().on(table.email),
],
(table) => [index().on(table.username), uniqueIndex().on(table.email)],
);
export const UsersRelations = relations(Users, ({ many, one }) => ({