chore(federation): 👽 Finish initial Versia Working Draft 4 update

This commit is contained in:
Jesse Wierzbinski 2024-08-26 19:27:40 +02:00
parent c3fa867e74
commit 42e198ca0e
No known key found for this signature in database
14 changed files with 90 additions and 115 deletions

View file

@ -41,9 +41,7 @@ export default class FederationUserFetch extends BaseCommand<
// Check instance exists, if not, create it // Check instance exists, if not, create it
await Instance.resolve(`https://${host}`); await Instance.resolve(`https://${host}`);
const requester = await User.getServerActor(); const manager = await User.getFederationRequester();
const manager = await requester.getFederationRequester();
const uri = await User.webFinger(manager, username, host); const uri = await User.webFinger(manager, username, host);

View file

@ -41,9 +41,7 @@ export default class FederationUserFinger extends BaseCommand<
// Check instance exists, if not, create it // Check instance exists, if not, create it
await Instance.resolve(`https://${host}`); await Instance.resolve(`https://${host}`);
const requester = await User.getServerActor(); const manager = await User.getFederationRequester();
const manager = await requester.getFederationRequester();
const uri = await User.webFinger(manager, username, host); const uri = await User.webFinger(manager, username, host);

View file

@ -1,4 +1,4 @@
import { emojiValidatorWithColons } from "@/api"; import { emojiValidatorWithColons, emojiValidatorWithIdentifiers } from "@/api";
import { proxyUrl } from "@/response"; import { proxyUrl } from "@/response";
import type { Emoji as ApiEmoji } from "@lysand-org/client/types"; import type { Emoji as ApiEmoji } from "@lysand-org/client/types";
import type { CustomEmojiExtension } from "@versia/federation/types"; import type { CustomEmojiExtension } from "@versia/federation/types";
@ -191,7 +191,7 @@ export class Emoji extends BaseInterface<typeof Emojis, EmojiWithInstance> {
public toVersia(): CustomEmojiExtension["emojis"][0] { public toVersia(): CustomEmojiExtension["emojis"][0] {
return { return {
name: this.data.shortcode, name: `:${this.data.shortcode}:`,
url: { url: {
[this.data.contentType]: { [this.data.contentType]: {
content: this.data.url, content: this.data.url,
@ -206,8 +206,17 @@ export class Emoji extends BaseInterface<typeof Emojis, EmojiWithInstance> {
emoji: CustomEmojiExtension["emojis"][0], emoji: CustomEmojiExtension["emojis"][0],
instanceId: string | null, instanceId: string | null,
): Promise<Emoji> { ): Promise<Emoji> {
// Extracts the shortcode from the emoji name (e.g. :shortcode: -> shortcode)
const shortcode = [
...emoji.name.matchAll(emojiValidatorWithIdentifiers),
][0].groups.shortcode;
if (!shortcode) {
throw new Error("Could not extract shortcode from emoji name");
}
return Emoji.insert({ return Emoji.insert({
shortcode: emoji.name, shortcode,
url: Object.entries(emoji.url)[0][1].content, url: Object.entries(emoji.url)[0][1].content,
alt: Object.entries(emoji.url)[0][1].description || undefined, alt: Object.entries(emoji.url)[0][1].description || undefined,
contentType: Object.keys(emoji.url)[0], contentType: Object.keys(emoji.url)[0],

View file

@ -139,7 +139,7 @@ export class Instance extends BaseInterface<typeof Instances> {
const wellKnownUrl = new URL("/.well-known/versia", origin); const wellKnownUrl = new URL("/.well-known/versia", origin);
const logger = getLogger("federation"); const logger = getLogger("federation");
const requester = await User.getServerActor().getFederationRequester(); const requester = await User.getFederationRequester();
try { try {
const { ok, raw, data } = await requester const { ok, raw, data } = await requester
@ -195,7 +195,7 @@ export class Instance extends BaseInterface<typeof Instances> {
// Go to endpoint, then follow the links to the actual metadata // Go to endpoint, then follow the links to the actual metadata
const logger = getLogger("federation"); const logger = getLogger("federation");
const requester = await User.getServerActor().getFederationRequester(); const requester = await User.getFederationRequester();
try { try {
const { const {

View file

@ -602,8 +602,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
throw new Error(`Invalid URI to parse ${uri}`); throw new Error(`Invalid URI to parse ${uri}`);
} }
const requester = const requester = await User.getFederationRequester();
await User.getServerActor().getFederationRequester();
const { data } = await requester.get(uri, { const { data } = await requester.get(uri, {
// @ts-expect-error Bun extension // @ts-expect-error Bun extension
@ -636,7 +635,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
const emojis: Emoji[] = []; const emojis: Emoji[] = [];
const logger = getLogger("federation"); const logger = getLogger("federation");
for (const emoji of note.extensions?.["org.lysand:custom_emojis"] for (const emoji of note.extensions?.["pub.versia:custom_emojis"]
?.emojis ?? []) { ?.emojis ?? []) {
const resolvedEmoji = await Emoji.fetchFromRemote(emoji).catch( const resolvedEmoji = await Emoji.fetchFromRemote(emoji).catch(
(e) => { (e) => {
@ -948,7 +947,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
// TODO: Refactor as part of groups // TODO: Refactor as part of groups
group: status.visibility === "public" ? "public" : "followers", group: status.visibility === "public" ? "public" : "followers",
extensions: { extensions: {
"org.lysand:custom_emojis": { "pub.versia:custom_emojis": {
emojis: status.emojis.map((emoji) => emojis: status.emojis.map((emoji) =>
new Emoji(emoji).toVersia(), new Emoji(emoji).toVersia(),
), ),

View file

@ -135,56 +135,6 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
); );
} }
static getServerActor(): User {
return new User({
id: "00000000-0000-0000-0000-000000000000",
username: "actor",
avatar: "",
createdAt: "2024-01-01T00:00:00.000Z",
displayName: "Server Actor",
note: "This is a system actor used for server-to-server communication. It is not a real user.",
updatedAt: "2024-01-01T00:00:00.000Z",
instanceId: null,
publicKey: config.instance.keys.public,
source: {
fields: [],
language: null,
note: "",
privacy: "public",
sensitive: false,
},
fields: [],
isAdmin: false,
isBot: false,
isLocked: false,
isDiscoverable: false,
endpoints: {
dislikes: "",
featured: "",
likes: "",
followers: "",
following: "",
inbox: "",
outbox: "",
},
disableAutomoderation: false,
email: "",
emailVerificationToken: "",
emojis: [],
followerCount: 0,
followingCount: 0,
header: "",
instance: null,
password: "",
passwordResetToken: "",
privateKey: config.instance.keys.private,
roles: [],
sanctions: [],
statusCount: 0,
uri: "/users/actor",
});
}
static getUri(id: string, uri: string | null, baseUrl: string) { static getUri(id: string, uri: string | null, baseUrl: string) {
return uri || new URL(`/users/${id}`, baseUrl).toString(); return uri || new URL(`/users/${id}`, baseUrl).toString();
} }
@ -433,7 +383,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
uri: string, uri: string,
instance: Instance, instance: Instance,
): Promise<User> { ): Promise<User> {
const requester = await User.getServerActor().getFederationRequester(); const requester = await User.getFederationRequester();
const { data: json } = await requester.get<Partial<VersiaUser>>(uri, { const { data: json } = await requester.get<Partial<VersiaUser>>(uri, {
// @ts-expect-error Bun extension // @ts-expect-error Bun extension
proxy: config.http.proxy.address, proxy: config.http.proxy.address,
@ -749,6 +699,20 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return output; return output;
} }
/**
* Helper to get the appropriate Versia SDK requester with the instance's private key
*
* @returns The requester
*/
static async getFederationRequester(): Promise<FederationRequester> {
const signatureConstructor = await SignatureConstructor.fromStringKey(
config.instance.keys.private,
config.http.base_url,
);
return new FederationRequester(signatureConstructor);
}
/** /**
* Helper to get the appropriate Versia SDK requester with this user's private key * Helper to get the appropriate Versia SDK requester with this user's private key
* *
@ -947,6 +911,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
).toString(), ).toString(),
indexable: false, indexable: false,
username: user.username, username: user.username,
manually_approves_followers: this.data.isLocked,
avatar: urlToContentFormat(this.getAvatarUrl(config)) ?? undefined, avatar: urlToContentFormat(this.getAvatarUrl(config)) ?? undefined,
header: urlToContentFormat(this.getHeaderUrl(config)) ?? undefined, header: urlToContentFormat(this.getHeaderUrl(config)) ?? undefined,
display_name: user.displayName, display_name: user.displayName,
@ -960,7 +925,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
algorithm: "ed25519", algorithm: "ed25519",
}, },
extensions: { extensions: {
"org.lysand:custom_emojis": { "pub.versia:custom_emojis": {
emojis: user.emojis.map((emoji) => emojis: user.emojis.map((emoji) =>
new Emoji(emoji).toVersia(), new Emoji(emoji).toVersia(),
), ),

View file

@ -76,9 +76,7 @@ export default apiRoute((app) =>
const [username, domain] = accountMatches[0].split("@"); const [username, domain] = accountMatches[0].split("@");
const requester = user ?? User.getServerActor(); const manager = await (user ?? User).getFederationRequester();
const manager = await requester.getFederationRequester();
const uri = await User.webFinger(manager, username, domain); const uri = await User.webFinger(manager, username, domain);

View file

@ -87,9 +87,7 @@ export default apiRoute((app) =>
const accounts: User[] = []; const accounts: User[] = [];
if (resolve && username && host) { if (resolve && username && host) {
const requester = self ?? User.getServerActor(); const manager = await (self ?? User).getFederationRequester();
const manager = await requester.getFederationRequester();
const uri = await User.webFinger(manager, username, host); const uri = await User.webFinger(manager, username, host);

View file

@ -128,10 +128,9 @@ export default apiRoute((app) =>
} }
if (resolve) { if (resolve) {
const requester = self ?? User.getServerActor(); const manager = await (
self ?? User
const manager = ).getFederationRequester();
await requester.getFederationRequester();
const uri = await User.webFinger( const uri = await User.webFinger(
manager, manager,

View file

@ -73,6 +73,10 @@ export default apiRoute((app) =>
return context.json({ error: "Object not found" }, 404); return context.json({ error: "Object not found" }, 404);
} }
if (!foundAuthor) {
return context.json({ error: "Author not found" }, 404);
}
if (foundAuthor?.isRemote()) { if (foundAuthor?.isRemote()) {
return context.json( return context.json(
{ error: "Cannot view objects from remote instances" }, { error: "Cannot view objects from remote instances" },
@ -92,9 +96,11 @@ export default apiRoute((app) =>
reqUrl.protocol = "https:"; reqUrl.protocol = "https:";
} }
const author = foundAuthor ?? User.getServerActor(); const { headers } = await foundAuthor.sign(
apiObject,
const { headers } = await author.sign(apiObject, reqUrl, "GET"); reqUrl,
"GET",
);
return response(objectString, 200, { return response(objectString, 200, {
"Content-Type": "application/json", "Content-Type": "application/json",

View file

@ -1,8 +1,7 @@
import { apiRoute, applyConfig, handleZodError } from "@/api"; import { apiRoute, applyConfig, handleZodError } from "@/api";
import { redirect, response } from "@/response"; import { redirect } from "@/response";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { z } from "zod"; import { z } from "zod";
import { config } from "~/packages/config-manager";
import { User } from "~/packages/database-interface/user"; import { User } from "~/packages/database-interface/user";
export const meta = applyConfig({ export const meta = applyConfig({
@ -19,7 +18,7 @@ export const meta = applyConfig({
export const schemas = { export const schemas = {
param: z.object({ param: z.object({
uuid: z.string().uuid().or(z.literal("actor")), uuid: z.string().uuid(),
}), }),
}; };
@ -31,10 +30,7 @@ export default apiRoute((app) =>
async (context) => { async (context) => {
const { uuid } = context.req.valid("param"); const { uuid } = context.req.valid("param");
const user = const user = await User.fromId(uuid);
uuid === "actor"
? User.getServerActor()
: await User.fromId(uuid);
if (!user) { if (!user) {
return context.json({ error: "User not found" }, 404); return context.json({ error: "User not found" }, 404);
@ -55,24 +51,15 @@ export default apiRoute((app) =>
return redirect(user.toApi().url); return redirect(user.toApi().url);
} }
const userString = JSON.stringify(user.toVersia()); const userJson = user.toVersia();
// If base_url uses https and request uses http, rewrite request to use https const { headers } = await user.sign(
// This fixes reverse proxy errors userJson,
const reqUrl = new URL(context.req.url); context.req.url,
if ( "GET",
new URL(config.http.base_url).protocol === "https:" && );
reqUrl.protocol === "http:"
) {
reqUrl.protocol = "https:";
}
const { headers } = await user.sign(user.toVersia(), reqUrl, "GET"); return context.json(userJson, 200, headers.toJSON());
return response(userString, 200, {
"Content-Type": "application/json",
...headers.toJSON(),
});
}, },
), ),
); );

View file

@ -1,5 +1,6 @@
import { apiRoute, applyConfig, handleZodError } from "@/api"; import { apiRoute, applyConfig, handleZodError } from "@/api";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import type { Entity } from "@versia/federation/types";
import { and, count, eq, inArray } from "drizzle-orm"; import { and, count, eq, inArray } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { db } from "~/drizzle/db"; import { db } from "~/drizzle/db";
@ -79,7 +80,7 @@ export default apiRoute((app) =>
) )
)[0].count; )[0].count;
return context.json({ const json = {
first: new URL( first: new URL(
`/users/${uuid}/outbox?page=1`, `/users/${uuid}/outbox?page=1`,
config.http.base_url, config.http.base_url,
@ -90,8 +91,7 @@ export default apiRoute((app) =>
)}`, )}`,
config.http.base_url, config.http.base_url,
).toString(), ).toString(),
total_items: totalNotes, total: totalNotes,
// Server actor
author: author.getUri(), author: author.getUri(),
next: next:
notes.length === NOTES_PER_PAGE notes.length === NOTES_PER_PAGE
@ -99,16 +99,25 @@ export default apiRoute((app) =>
`/users/${uuid}/outbox?page=${pageNumber + 1}`, `/users/${uuid}/outbox?page=${pageNumber + 1}`,
config.http.base_url, config.http.base_url,
).toString() ).toString()
: undefined, : null,
prev: previous:
pageNumber > 1 pageNumber > 1
? new URL( ? new URL(
`/users/${uuid}/outbox?page=${pageNumber - 1}`, `/users/${uuid}/outbox?page=${pageNumber - 1}`,
config.http.base_url, config.http.base_url,
).toString() ).toString()
: undefined, : null,
items: notes.map((note) => note.toVersia()), items: notes.map((note) => note.toVersia()),
}); };
const { headers } = await author.sign(
// @ts-expect-error To fix when I add collections to versia-api
json as Entity,
context.req.url,
"GET",
);
return context.json(json, 200, headers.toJSON());
}, },
), ),
); );

View file

@ -79,9 +79,7 @@ export default apiRoute((app) =>
let activityPubUrl = ""; let activityPubUrl = "";
if (config.federation.bridge.enabled) { if (config.federation.bridge.enabled) {
const requester = await User.getServerActor(); const manager = await User.getFederationRequester();
const manager = await requester.getFederationRequester();
try { try {
activityPubUrl = await manager.webFinger( activityPubUrl = await manager.webFinger(

View file

@ -9,12 +9,14 @@ import {
anyOf, anyOf,
caseInsensitive, caseInsensitive,
charIn, charIn,
charNotIn,
createRegExp, createRegExp,
digit, digit,
exactly, exactly,
global, global,
letter, letter,
maybe, maybe,
not,
oneOrMore, oneOrMore,
} from "magic-regexp"; } from "magic-regexp";
import { parse } from "qs"; import { parse } from "qs";
@ -67,17 +69,26 @@ export const idValidator = createRegExp(
export const emojiValidator = createRegExp( export const emojiValidator = createRegExp(
// A-Z a-z 0-9 _ - // A-Z a-z 0-9 _ -
oneOrMore(letter.or(digit).or(exactly("_")).or(exactly("-"))), oneOrMore(letter.or(digit).or(charIn("_-"))),
[caseInsensitive, global], [caseInsensitive, global],
); );
export const emojiValidatorWithColons = createRegExp( export const emojiValidatorWithColons = createRegExp(
exactly(":"), exactly(":"),
oneOrMore(letter.or(digit).or(exactly("_")).or(exactly("-"))), oneOrMore(letter.or(digit).or(charIn("_-"))),
exactly(":"), exactly(":"),
[caseInsensitive, global], [caseInsensitive, global],
); );
export const emojiValidatorWithIdentifiers = createRegExp(
exactly(
exactly(not.letter.or(not.digit).or(charNotIn("_-"))).times(1),
oneOrMore(letter.or(digit).or(charIn("_-"))).groupedAs("shortcode"),
exactly(not.letter.or(not.digit).or(charNotIn("_-"))).times(1),
),
[caseInsensitive, global],
);
export const mentionValidator = createRegExp( export const mentionValidator = createRegExp(
exactly("@"), exactly("@"),
oneOrMore(anyOf(letter.lowercase, digit, charIn("-"))).groupedAs( oneOrMore(anyOf(letter.lowercase, digit, charIn("-"))).groupedAs(