mirror of
https://github.com/versia-pub/server.git
synced 2026-04-27 20:59:15 +02:00
feat(federation): ✨ Port to Versia 0.6
This commit is contained in:
parent
de69f27877
commit
fca30b4dad
62 changed files with 1614 additions and 2008 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue