refactor(federation): Make references always have domains
Some checks failed
CodeQL Scan / Analyze (push) Failing after 0s
Deploy Docs to GitHub Pages / build (push) Failing after 0s
Deploy Docs to GitHub Pages / Deploy (push) Has been skipped
Nix Build / check (push) Failing after 0s
Test Publish / build (client) (push) Failing after 0s
Test Publish / build (sdk) (push) Failing after 0s
Build Docker Images / lint (push) Has been cancelled
Build Docker Images / check (push) Has been cancelled
Build Docker Images / tests (push) Has been cancelled
Build Docker Images / detect-circular (push) Has been cancelled
Mirror to Codeberg / Mirror (push) Has been cancelled
Build Docker Images / build (server, Dockerfile, ${{ github.repository_owner }}/server) (push) Has been cancelled
Build Docker Images / build (worker, Worker.Dockerfile, ${{ github.repository_owner }}/worker) (push) Has been cancelled

This commit is contained in:
Jesse Wierzbinski 2026-04-03 13:34:19 +02:00
parent df2a5ce260
commit 709e1c6087
No known key found for this signature in database
22 changed files with 688 additions and 477 deletions

View file

@ -1,4 +1,5 @@
import * as VersiaEntities from "@versia/sdk/entities";
import { config } from "@versia-server/config";
import {
and,
desc,
@ -20,7 +21,9 @@ import { BaseInterface } from "./base.ts";
import type { User } from "./user.ts";
type LikeType = InferSelectModel<typeof Likes> & {
liker: InferSelectModel<typeof Users>;
liker: InferSelectModel<typeof Users> & {
instance: InferSelectModel<typeof Instances> | null;
};
liked: InferSelectModel<typeof Notes> & {
author: InferSelectModel<typeof Users> & {
instance: InferSelectModel<typeof Instances> | null;
@ -70,7 +73,11 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
},
},
},
liker: true,
liker: {
with: {
instance: true,
},
},
},
});
@ -102,7 +109,11 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
},
},
},
liker: true,
liker: {
with: {
instance: true,
},
},
},
});
@ -167,22 +178,28 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
likedReference = `${this.data.liked.author.instance.domain}:${this.data.liked.remoteId}`;
}
return new VersiaEntities.Like({
id: this.id,
author: this.data.liker.id,
type: "pub.versia:likes/Like",
created_at: this.data.createdAt.toISOString(),
liked: likedReference,
});
return new VersiaEntities.Like(
{
id: this.id,
author: this.data.liker.id,
type: "pub.versia:likes/Like",
created_at: this.data.createdAt.toISOString(),
liked: likedReference,
},
this.data.liker.instance?.domain ?? config.http.base_url.hostname,
);
}
public unlikeToVersia(unliker?: User): VersiaEntities.Delete {
return new VersiaEntities.Delete({
type: "Delete",
created_at: new Date().toISOString(),
author: unliker ? unliker.id : this.data.liker.id,
deleted_type: "pub.versia:likes/Like",
deleted: this.id,
});
return new VersiaEntities.Delete(
{
type: "Delete",
created_at: new Date().toISOString(),
author: unliker ? unliker.id : this.data.liker.id,
deleted_type: "pub.versia:likes/Like",
deleted: this.id,
},
this.data.liker.instance?.domain ?? config.http.base_url.hostname,
);
}
}

View file

@ -426,13 +426,13 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
public get reference(): VersiaEntities.Reference {
if (this.remote) {
const instanceUrl = new URL(
return new VersiaEntities.Reference(
this.id,
this.author.data.instance?.domain || "",
);
return new VersiaEntities.Reference(this.id, instanceUrl.hostname);
}
return new VersiaEntities.Reference(this.id);
return new VersiaEntities.Reference(this.id, config.http.base_url.host);
}
public async federateToUsers(): Promise<void> {
@ -941,19 +941,13 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
*/
public static async resolve(
reference: VersiaEntities.Reference,
defaultInstance?: Instance,
): Promise<Note | null> {
// Check if note not already in database
if (
!(reference.domain || defaultInstance) ||
reference.domain === config.http.base_url.hostname
) {
if (reference.domain === config.http.base_url.hostname) {
return await Note.fromId(reference.id);
}
const instance = reference.domain
? await Instance.resolve(reference.domain)
: (defaultInstance as Instance);
const instance = await Instance.resolve(reference.domain);
const foundNote = await Note.fromSql(
and(
@ -973,14 +967,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
return foundNote;
}
return Note.fromVersia(
reference.domain
? reference
: new VersiaEntities.Reference(
reference.id,
instance.data.domain,
),
);
return Note.fromVersia(reference);
}
/**
@ -1003,12 +990,6 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
instance?: Instance,
): Promise<Note> {
if (versiaNote instanceof VersiaEntities.Reference) {
if (!versiaNote.domain) {
throw new Error(
"Cannot fetch Versia note from reference without domain",
);
}
// No bridge support for notes yet
const note = await Instance.federationRequester.fetchEntity(
versiaNote,
@ -1027,7 +1008,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
const { created_at, extensions, group, id, is_sensitive, subject } =
versiaNote.data;
const author = await User.resolve(versiaNote.author, instance);
const author = await User.resolve(versiaNote.author);
if (!author) {
throw new Error("Entity author could not be resolved");
@ -1064,7 +1045,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
const mentions = (
await Promise.all(
versiaNote.mentions.map((m) => User.resolve(m, instance)) ?? [],
versiaNote.mentions.map((m) => User.resolve(m)) ?? [],
)
).filter((m) => m !== null);
@ -1075,10 +1056,10 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
: (group as "public" | "followers" | "unlisted");
const reply = versiaNote.repliesTo
? await Note.resolve(versiaNote.repliesTo, instance)
? await Note.resolve(versiaNote.repliesTo)
: null;
const quote = versiaNote.quotes
? await Note.resolve(versiaNote.quotes, instance)
? await Note.resolve(versiaNote.quotes)
: null;
const spoiler = subject ? await sanitizedHtmlStrip(subject) : undefined;
@ -1296,13 +1277,16 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
}
public deleteToVersia(): VersiaEntities.Delete {
return new VersiaEntities.Delete({
type: "Delete",
author: this.author.id,
deleted_type: "Note",
deleted: this.id,
created_at: new Date().toISOString(),
});
return new VersiaEntities.Delete(
{
type: "Delete",
author: this.author.id,
deleted_type: "Note",
deleted: this.id,
created_at: new Date().toISOString(),
},
this.data.author.instance?.domain ?? config.http.base_url.hostname,
);
}
/**
@ -1324,48 +1308,51 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
replyReference = `${status.reply.author.instance.domain}:${status.reply.remoteId}`;
}
return new VersiaEntities.Note({
type: "Note",
created_at: status.createdAt.toISOString(),
id: status.id,
author: this.author.id,
content: {
"text/html": {
content: status.content,
remote: false,
return new VersiaEntities.Note(
{
type: "Note",
created_at: status.createdAt.toISOString(),
id: status.id,
author: this.author.id,
content: {
"text/html": {
content: status.content,
remote: false,
},
"text/plain": {
content: htmlToText(status.content),
remote: false,
},
},
"text/plain": {
content: htmlToText(status.content),
remote: false,
previews: [],
attachments: status.attachments.map(
(attachment) =>
new Media(attachment).toVersia().data as z.infer<
typeof NonTextContentFormatSchema
>,
),
is_sensitive: status.sensitive,
mentions: status.mentions.map((mention) =>
mention.instance
? `${mention.instance.domain}:${mention.id}`
: mention.id,
),
quotes: quoteReference,
replies_to: replyReference,
subject: status.spoilerText,
// TODO: Refactor as part of groups
group: status.visibility === "public" ? "public" : "followers",
extensions: {
"pub.versia:custom_emojis": {
emojis: status.emojis.map((emoji) =>
new Emoji(emoji).toVersia(),
),
},
// TODO: Add polls and reactions
},
},
previews: [],
attachments: status.attachments.map(
(attachment) =>
new Media(attachment).toVersia().data as z.infer<
typeof NonTextContentFormatSchema
>,
),
is_sensitive: status.sensitive,
mentions: status.mentions.map((mention) =>
mention.instance
? `${mention.instance.domain}:${mention.id}`
: mention.id,
),
quotes: quoteReference,
replies_to: replyReference,
subject: status.spoilerText,
// TODO: Refactor as part of groups
group: status.visibility === "public" ? "public" : "followers",
extensions: {
"pub.versia:custom_emojis": {
emojis: status.emojis.map((emoji) =>
new Emoji(emoji).toVersia(),
),
},
// TODO: Add polls and reactions
},
});
status.author.instance?.domain ?? config.http.base_url.hostname,
);
}
public toVersiaShare(): VersiaEntities.Share {
@ -1373,25 +1360,31 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
throw new Error("Cannot share a non-reblogged note");
}
return new VersiaEntities.Share({
type: "pub.versia:share/Share",
author: this.author.id,
id: this.id,
created_at: new Date().toISOString(),
shared: this.data.reblog.author.instance
? `${this.data.reblog.author.instance.domain}:${this.data.reblog.id}`
: this.data.reblog.id,
});
return new VersiaEntities.Share(
{
type: "pub.versia:share/Share",
author: this.author.id,
id: this.id,
created_at: new Date().toISOString(),
shared: this.data.reblog.author.instance
? `${this.data.reblog.author.instance.domain}:${this.data.reblog.id}`
: this.data.reblog.id,
},
this.data.author.instance?.domain ?? config.http.base_url.hostname,
);
}
public toVersiaUnshare(): VersiaEntities.Delete {
return new VersiaEntities.Delete({
type: "Delete",
created_at: new Date().toISOString(),
author: this.author.id,
deleted_type: "pub.versia:share/Share",
deleted: this.id,
});
return new VersiaEntities.Delete(
{
type: "Delete",
created_at: new Date().toISOString(),
author: this.author.id,
deleted_type: "pub.versia:share/Share",
deleted: this.id,
},
this.data.author.instance?.domain ?? config.http.base_url.hostname,
);
}
/**

View file

@ -26,7 +26,9 @@ import type { User } from "./user.ts";
type ReactionType = InferSelectModel<typeof Reactions> & {
emoji: typeof Emoji.$type | null;
author: InferSelectModel<typeof Users>;
author: InferSelectModel<typeof Users> & {
instance: InferSelectModel<typeof Instances> | null;
};
note: InferSelectModel<typeof Notes> & {
author: InferSelectModel<typeof Users> & {
instance: InferSelectModel<typeof Instances> | null;
@ -72,7 +74,11 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
media: true,
},
},
author: true,
author: {
with: {
instance: true,
},
},
note: {
with: {
author: {
@ -112,7 +118,11 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
media: true,
},
},
author: true,
author: {
with: {
instance: true,
},
},
note: {
with: {
author: {
@ -245,37 +255,43 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
noteReference = `${this.data.note.author.instance.domain}:${this.data.note.remoteId}`;
}
return new VersiaEntities.Reaction({
type: "pub.versia:reactions/Reaction",
author: this.data.author.id,
created_at: this.data.createdAt.toISOString(),
id: this.id,
object: noteReference,
content: this.hasCustomEmoji()
? `:${this.data.emoji?.shortcode}:`
: this.data.emojiText || "",
extensions: this.hasCustomEmoji()
? {
"pub.versia:custom_emojis": {
emojis: [
new Emoji(
this.data.emoji as typeof Emoji.$type,
).toVersia(),
],
},
}
: undefined,
});
return new VersiaEntities.Reaction(
{
type: "pub.versia:reactions/Reaction",
author: this.data.author.id,
created_at: this.data.createdAt.toISOString(),
id: this.id,
object: noteReference,
content: this.hasCustomEmoji()
? `:${this.data.emoji?.shortcode}:`
: this.data.emojiText || "",
extensions: this.hasCustomEmoji()
? {
"pub.versia:custom_emojis": {
emojis: [
new Emoji(
this.data.emoji as typeof Emoji.$type,
).toVersia(),
],
},
}
: undefined,
},
this.data.author.instance?.domain ?? config.http.base_url.hostname,
);
}
public toVersiaUnreact(): VersiaEntities.Delete {
return new VersiaEntities.Delete({
type: "Delete",
created_at: new Date().toISOString(),
author: this.data.authorId,
deleted_type: "pub.versia:reactions/Reaction",
deleted: this.id,
});
return new VersiaEntities.Delete(
{
type: "Delete",
created_at: new Date().toISOString(),
author: this.data.authorId,
deleted_type: "pub.versia:reactions/Reaction",
deleted: this.id,
},
this.data.author.instance?.domain ?? config.http.base_url.hostname,
);
}
public static async fromVersia(

View file

@ -224,7 +224,10 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
public get reference(): VersiaEntities.Reference {
if (this.local) {
return new VersiaEntities.Reference(this.id);
return new VersiaEntities.Reference(
this.id,
config.http.base_url.hostname,
);
}
return new VersiaEntities.Reference(
@ -330,14 +333,17 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
}
private unfollowToVersia(followee: User): VersiaEntities.Unfollow {
return new VersiaEntities.Unfollow({
type: "Unfollow",
author: this.id,
created_at: new Date().toISOString(),
followee: followee.data.instance
? `${followee.data.instance.domain}:${followee.id}`
: followee.id,
});
return new VersiaEntities.Unfollow(
{
type: "Unfollow",
author: this.id,
created_at: new Date().toISOString(),
followee: followee.data.instance
? `${followee.data.instance.domain}:${followee.id}`
: followee.id,
},
this.data.instance?.domain ?? config.http.base_url.hostname,
);
}
public async acceptFollowRequest(follower: User): Promise<void> {
@ -352,14 +358,17 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
await follower.recalculateFollowerCount();
await this.recalculateFollowingCount();
const entity = new VersiaEntities.FollowAccept({
type: "FollowAccept",
author: this.id,
created_at: new Date().toISOString(),
follower: follower.data.instance
? `${follower.data.instance.domain}:${follower.id}`
: follower.id,
});
const entity = new VersiaEntities.FollowAccept(
{
type: "FollowAccept",
author: this.id,
created_at: new Date().toISOString(),
follower: follower.data.instance
? `${follower.data.instance.domain}:${follower.id}`
: follower.id,
},
this.data.instance?.domain ?? config.http.base_url.hostname,
);
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
entity: entity.toJSON(),
@ -377,14 +386,17 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
throw new Error("Followee must be a local user");
}
const entity = new VersiaEntities.FollowReject({
type: "FollowReject",
author: this.id,
created_at: new Date().toISOString(),
follower: follower.data.instance
? `${follower.data.instance.domain}:${follower.id}`
: follower.id,
});
const entity = new VersiaEntities.FollowReject(
{
type: "FollowReject",
author: this.id,
created_at: new Date().toISOString(),
follower: follower.data.instance
? `${follower.data.instance.domain}:${follower.id}`
: follower.id,
},
this.data.instance?.domain ?? config.http.base_url.hostname,
);
await deliveryQueue.add(DeliveryJobType.FederateEntity, {
entity: entity.toJSON(),
@ -686,12 +698,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
instance?: Instance,
): Promise<User> {
if (versiaUser instanceof VersiaEntities.Reference) {
if (!versiaUser.domain) {
throw new Error(
"Cannot fetch Versia user from reference without domain",
);
}
const user = await Instance.federationRequester.fetchEntity(
versiaUser,
VersiaEntities.User,
@ -802,13 +808,9 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
public static async resolve(
reference: VersiaEntities.Reference,
defaultInstance?: Instance,
): Promise<User> {
// Check if user not already in database
if (
!(reference.domain || defaultInstance) ||
reference.domain === config.http.base_url.hostname
) {
if (reference.domain === config.http.base_url.hostname) {
const user = await User.fromId(reference.id);
if (!user) {
@ -820,9 +822,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return user;
}
const instance = reference.domain
? await Instance.resolve(reference.domain)
: (defaultInstance as Instance);
const instance = await Instance.resolve(reference.domain);
const foundUser = await User.fromSql(
and(
@ -836,12 +836,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
}
return User.fromVersia(
reference.domain
? reference
: new VersiaEntities.Reference(
reference.id,
instance.data.domain,
),
new VersiaEntities.Reference(reference.id, instance.data.domain),
);
}
@ -1088,39 +1083,42 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
const user = this.data;
return new VersiaEntities.User({
id: user.id,
type: "User",
bio: {
"text/html": {
content: user.note,
remote: false,
return new VersiaEntities.User(
{
id: user.id,
type: "User",
bio: {
"text/html": {
content: user.note,
remote: false,
},
"text/plain": {
content: htmlToText(user.note),
remote: false,
},
},
"text/plain": {
content: htmlToText(user.note),
remote: false,
created_at: user.createdAt.toISOString(),
indexable: this.data.isIndexable,
username: user.username,
manually_approves_followers: this.data.isLocked,
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,
extensions: {
"pub.versia:custom_emojis": {
emojis: user.emojis.map((emoji) =>
new Emoji(emoji).toVersia(),
),
},
},
},
created_at: user.createdAt.toISOString(),
indexable: this.data.isIndexable,
username: user.username,
manually_approves_followers: this.data.isLocked,
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,
extensions: {
"pub.versia:custom_emojis": {
emojis: user.emojis.map((emoji) =>
new Emoji(emoji).toVersia(),
),
},
},
});
this.data.instance?.domain ?? config.http.base_url.hostname,
);
}
public toMention(): z.infer<typeof MentionSchema> {