diff --git a/bun.lockb b/bun.lockb index 89d216c0..798233ed 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/classes/functions/status.ts b/classes/functions/status.ts index 7969532e..6b451b90 100644 --- a/classes/functions/status.ts +++ b/classes/functions/status.ts @@ -2,6 +2,8 @@ import { mentionValidator } from "@/api"; import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization"; import markdownItTaskLists from "@hackmd/markdown-it-task-lists"; import { getLogger } from "@logtape/logtape"; +import { SignatureConstructor } from "@lysand-org/federation"; +import { FederationRequester } from "@lysand-org/federation/requester"; import type { ContentFormat } from "@lysand-org/federation/types"; import { config } from "config-manager"; import { @@ -41,7 +43,6 @@ import { objectToInboxRequest } from "./federation"; import { type UserWithInstance, type UserWithRelations, - resolveWebFinger, transformOutputToUserWithRelations, userExtrasTemplate, userRelations, @@ -255,7 +256,10 @@ export const findManyNotes = async ( * @param text The text to parse mentions from. * @returns An array of users mentioned in the text. */ -export const parseTextMentions = async (text: string): Promise => { +export const parseTextMentions = async ( + text: string, + author: User, +): Promise => { const mentionedPeople = [...text.matchAll(mentionValidator)] ?? []; if (mentionedPeople.length === 0) { return []; @@ -310,10 +314,18 @@ export const parseTextMentions = async (text: string): Promise => { // Attempt to resolve mentions that were not found for (const person of notFoundRemoteUsers) { - const user = await resolveWebFinger( - person?.[1] ?? "", - person?.[2] ?? "", + const signatureConstructor = await SignatureConstructor.fromStringKey( + author.data.privateKey ?? "", + author.getUri(), ); + const manager = new FederationRequester( + new URL(`https://${person?.[2] ?? ""}`), + signatureConstructor, + ); + + const uri = await manager.webFinger(person?.[1] ?? ""); + + const user = await User.resolve(uri); if (user) { finalList.push(user); diff --git a/classes/functions/user.ts b/classes/functions/user.ts index 816662c9..ebe47162 100644 --- a/classes/functions/user.ts +++ b/classes/functions/user.ts @@ -9,12 +9,12 @@ import { type InferSelectModel, and, eq, sql } from "drizzle-orm"; import { db } from "~/drizzle/db"; import { Applications, - Instances, + type Instances, Notifications, Relationships, type Roles, Tokens, - Users, + type Users, } from "~/drizzle/schema"; import { User } from "~/packages/database-interface/user"; import type { Application } from "./application"; @@ -319,74 +319,6 @@ export const findManyUsers = async ( return output.map((user) => transformOutputToUserWithRelations(user)); }; -/** - * Resolves a WebFinger identifier to a user. - * @param identifier Either a UUID or a username - */ -export const resolveWebFinger = async ( - identifier: string, - host: string, -): Promise => { - // Check if user not already in database - const foundUser = await db - .select() - .from(Users) - .innerJoin(Instances, eq(Users.instanceId, Instances.id)) - .where(and(eq(Users.username, identifier), eq(Instances.baseUrl, host))) - .limit(1); - - if (foundUser[0]) { - return await User.fromId(foundUser[0].Users.id); - } - - const hostWithProtocol = host.startsWith("http") ? host : `https://${host}`; - - const response = await fetch( - new URL( - `/.well-known/webfinger?${new URLSearchParams({ - resource: `acct:${identifier}@${host}`, - })}`, - hostWithProtocol, - ), - { - method: "GET", - headers: { - Accept: "application/json", - }, - proxy: config.http.proxy.address, - }, - ); - - if (response.status === 404) { - return null; - } - - const data = (await response.json()) as { - subject: string; - links: { - rel: string; - type: string; - href: string; - }[]; - }; - - if (!(data.subject && data.links)) { - throw new Error( - "Invalid WebFinger data (missing subject or links from response)", - ); - } - - const relevantLink = data.links.find((link) => link.rel === "self"); - - if (!relevantLink) { - throw new Error( - "Invalid WebFinger data (missing link with rel: 'self')", - ); - } - - return User.resolve(relevantLink.href); -}; - /** * Retrieves a user from a token. * @param access_token The access token to retrieve the user from. diff --git a/cli/commands/federation/user/fetch.ts b/cli/commands/federation/user/fetch.ts new file mode 100644 index 00000000..e1cf73f3 --- /dev/null +++ b/cli/commands/federation/user/fetch.ts @@ -0,0 +1,51 @@ +import { SignatureConstructor } from "@lysand-org/federation"; +import { FederationRequester } from "@lysand-org/federation/requester"; +import { Args } from "@oclif/core"; +import chalk from "chalk"; +import ora from "ora"; +import { BaseCommand } from "~/cli/base"; +import { User } from "~/packages/database-interface/user"; + +export default class FederationUserFetch extends BaseCommand< + typeof FederationUserFetch +> { + static override args = { + address: Args.string({ + description: "Address of remote user (name@host.com)", + required: true, + }), + }; + + static override description = "Fetch the URL of remote users via WebFinger"; + + static override examples = ["<%= config.bin %> <%= command.id %>"]; + + static override flags = {}; + + public async run(): Promise { + const { args } = await this.parse(FederationUserFetch); + + const spinner = ora("Fetching user URI").start(); + + const [username, host] = args.address.split("@"); + + const requester = await User.getServerActor(); + + const signatureConstructor = await SignatureConstructor.fromStringKey( + requester.data.privateKey ?? "", + requester.getUri(), + ); + const manager = new FederationRequester( + new URL(`https://${host}`), + signatureConstructor, + ); + + const uri = await manager.webFinger(username); + + spinner.succeed("Fetched user URI"); + + this.log(`URI: ${chalk.blueBright(uri)}`); + + this.exit(0); + } +} diff --git a/cli/index.ts b/cli/index.ts index f99596b7..5e12481d 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -5,6 +5,7 @@ import EmojiDelete from "./commands/emoji/delete"; import EmojiImport from "./commands/emoji/import"; import EmojiList from "./commands/emoji/list"; import FederationInstanceFetch from "./commands/federation/instance/fetch"; +import FederationUserFetch from "./commands/federation/user/fetch"; import IndexRebuild from "./commands/index/rebuild"; import Start from "./commands/start"; import UserCreate from "./commands/user/create"; @@ -28,6 +29,7 @@ export const commands = { "emoji:import": EmojiImport, "index:rebuild": IndexRebuild, "federation:instance:fetch": FederationInstanceFetch, + "federation:user:fetch": FederationUserFetch, start: Start, }; diff --git a/config/config.example.toml b/config/config.example.toml index c80b4d80..62532fbe 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -337,6 +337,11 @@ description = "A Lysand instance" # URL to your instance banner # banner = "" +# Used for federation. If left empty or missing, the server will generate one for you. +[instance.keys] +public = "" +private = "" + [permissions] # Control default permissions for users # Note that an anonymous user having a permission will not allow them diff --git a/package.json b/package.json index 4e7252d8..10296a88 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ "@json2csv/plainjs": "^7.0.6", "@logtape/logtape": "npm:@jsr/logtape__logtape", "@lysand-org/client": "^0.2.3", - "@lysand-org/federation": "^2.0.0", + "@lysand-org/federation": "^2.1.0", "@oclif/core": "^4.0.7", "@tufjs/canonical-json": "^2.0.0", "altcha-lib": "^0.3.0", diff --git a/packages/config-manager/config.type.ts b/packages/config-manager/config.type.ts index af9d202d..85b23c6e 100644 --- a/packages/config-manager/config.type.ts +++ b/packages/config-manager/config.type.ts @@ -509,6 +509,15 @@ export const configValidator = z.object({ privacy_policy_path: z.string().optional(), logo: zUrl.optional(), banner: zUrl.optional(), + keys: z + .object({ + public: z.string().min(3).default("").or(z.literal("")), + private: z.string().min(3).default("").or(z.literal("")), + }) + .default({ + public: "", + private: "", + }), }) .default({ name: "Lysand", @@ -518,6 +527,10 @@ export const configValidator = z.object({ privacy_policy_path: undefined, logo: undefined, banner: undefined, + keys: { + public: "", + private: "", + }, }), permissions: z .object({ diff --git a/packages/database-interface/note.ts b/packages/database-interface/note.ts index 53162de5..2df23cc8 100644 --- a/packages/database-interface/note.ts +++ b/packages/database-interface/note.ts @@ -326,7 +326,7 @@ export class Note extends BaseInterface { const parsedMentions = [ ...(data.mentions ?? []), - ...(await parseTextMentions(plaintextContent)), + ...(await parseTextMentions(plaintextContent, data.author)), // Deduplicate by .id ].filter( (mention, index, self) => @@ -396,7 +396,7 @@ export class Note extends BaseInterface { * @returns The updated note */ async updateFromData(data: { - author?: User; + author: User; content?: ContentFormat; visibility?: ApiStatus["visibility"]; isSensitive?: boolean; @@ -418,7 +418,7 @@ export class Note extends BaseInterface { const parsedMentions = [ ...(data.mentions ?? []), ...(plaintextContent - ? await parseTextMentions(plaintextContent) + ? await parseTextMentions(plaintextContent, data.author) : []), // Deduplicate by .id ].filter( diff --git a/packages/database-interface/user.ts b/packages/database-interface/user.ts index ef8c59b0..d4af2767 100644 --- a/packages/database-interface/user.ts +++ b/packages/database-interface/user.ts @@ -123,6 +123,56 @@ export class User extends BaseInterface { ); } + 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: "", + }); + } + static getUri(id: string, uri: string | null, baseUrl: string) { return uri || new URL(`/users/${id}`, baseUrl).toString(); } diff --git a/server/api/api/v1/accounts/lookup/index.ts b/server/api/api/v1/accounts/lookup/index.ts index 5df45dce..e3ff6ca5 100644 --- a/server/api/api/v1/accounts/lookup/index.ts +++ b/server/api/api/v1/accounts/lookup/index.ts @@ -1,7 +1,8 @@ import { applyConfig, auth, handleZodError } from "@/api"; import { errorResponse, jsonResponse } from "@/response"; import { zValidator } from "@hono/zod-validator"; -import { getLogger } from "@logtape/logtape"; +import { SignatureConstructor } from "@lysand-org/federation"; +import { FederationRequester } from "@lysand-org/federation/requester"; import { eq } from "drizzle-orm"; import type { Hono } from "hono"; import { @@ -16,7 +17,6 @@ import { oneOrMore, } from "magic-regexp"; import { z } from "zod"; -import { resolveWebFinger } from "~/classes/functions/user"; import { RolePermissions, Users } from "~/drizzle/schema"; import { User } from "~/packages/database-interface/user"; @@ -50,6 +50,7 @@ export default (app: Hono) => auth(meta.auth, meta.permissions), async (context) => { const { acct } = context.req.valid("query"); + const { user } = context.req.valid("header"); if (!acct) { return errorResponse("Invalid acct parameter", 400); @@ -78,13 +79,22 @@ export default (app: Hono) => } const [username, domain] = accountMatches[0].split("@"); - const foundAccount = await resolveWebFinger( - username, - domain, - ).catch((e) => { - getLogger("webfinger").error`${e}`; - return null; - }); + + const requester = user ?? User.getServerActor(); + + const signatureConstructor = + await SignatureConstructor.fromStringKey( + requester.data.privateKey ?? "", + requester.getUri(), + ); + const manager = new FederationRequester( + new URL(`https://${domain}`), + signatureConstructor, + ); + + const uri = await manager.webFinger(username); + + const foundAccount = await User.resolve(uri); if (foundAccount) { return jsonResponse(foundAccount.toApi()); diff --git a/server/api/api/v1/accounts/search/index.ts b/server/api/api/v1/accounts/search/index.ts index 5f21d901..99250301 100644 --- a/server/api/api/v1/accounts/search/index.ts +++ b/server/api/api/v1/accounts/search/index.ts @@ -1,6 +1,8 @@ import { applyConfig, auth, handleZodError } from "@/api"; import { errorResponse, jsonResponse } from "@/response"; import { zValidator } from "@hono/zod-validator"; +import { SignatureConstructor } from "@lysand-org/federation"; +import { FederationRequester } from "@lysand-org/federation/requester"; import { eq, like, not, or, sql } from "drizzle-orm"; import type { Hono } from "hono"; import { @@ -16,7 +18,6 @@ import { } from "magic-regexp"; import stringComparison from "string-comparison"; import { z } from "zod"; -import { resolveWebFinger } from "~/classes/functions/user"; import { RolePermissions, Users } from "~/drizzle/schema"; import { User } from "~/packages/database-interface/user"; @@ -90,7 +91,21 @@ export default (app: Hono) => const accounts: User[] = []; if (resolve && username && host) { - const resolvedUser = await resolveWebFinger(username, host); + const requester = self ?? User.getServerActor(); + + const signatureConstructor = + await SignatureConstructor.fromStringKey( + requester.data.privateKey ?? "", + requester.getUri(), + ); + const manager = new FederationRequester( + new URL(`https://${host}`), + signatureConstructor, + ); + + const uri = await manager.webFinger(username); + + const resolvedUser = await User.resolve(uri); if (resolvedUser) { accounts.push(resolvedUser); diff --git a/server/api/api/v1/statuses/:id/index.ts b/server/api/api/v1/statuses/:id/index.ts index c76dcf97..3fa0a331 100644 --- a/server/api/api/v1/statuses/:id/index.ts +++ b/server/api/api/v1/statuses/:id/index.ts @@ -144,6 +144,14 @@ export default (app: Hono) => return jsonResponse(await note.toApi(user), 200); } case "PUT": { + if (!user) { + return errorResponse("Unauthorized", 401); + } + + if (note.author.id !== user.id) { + return errorResponse("Unauthorized", 401); + } + if (media_ids.length > 0) { const foundAttachments = await Attachment.fromIds(media_ids); @@ -154,6 +162,7 @@ export default (app: Hono) => } const newNote = await note.updateFromData({ + author: user, content: statusText ? { [content_type]: { diff --git a/server/api/api/v2/search/index.ts b/server/api/api/v2/search/index.ts index 12444c9f..4e411c00 100644 --- a/server/api/api/v2/search/index.ts +++ b/server/api/api/v2/search/index.ts @@ -1,11 +1,11 @@ import { applyConfig, auth, handleZodError, userAddressValidator } from "@/api"; import { errorResponse, jsonResponse } from "@/response"; import { zValidator } from "@hono/zod-validator"; -import { getLogger } from "@logtape/logtape"; +import { SignatureConstructor } from "@lysand-org/federation"; +import { FederationRequester } from "@lysand-org/federation/requester"; import { and, eq, inArray, sql } from "drizzle-orm"; import type { Hono } from "hono"; import { z } from "zod"; -import { resolveWebFinger } from "~/classes/functions/user"; import { searchManager } from "~/classes/search/search-manager"; import { db } from "~/drizzle/db"; import { Instances, Notes, RolePermissions, Users } from "~/drizzle/schema"; @@ -121,13 +121,21 @@ export default (app: Hono) => } if (resolve) { - const newUser = await resolveWebFinger( - username, - domain, - ).catch((e) => { - getLogger("webfinger").error`${e}`; - return null; - }); + const requester = self ?? User.getServerActor(); + + const signatureConstructor = + await SignatureConstructor.fromStringKey( + requester.data.privateKey ?? "", + requester.getUri(), + ); + const manager = new FederationRequester( + new URL(`https://${domain}`), + signatureConstructor, + ); + + const uri = await manager.webFinger(username); + + const newUser = await User.resolve(uri); if (newUser) { return jsonResponse({ diff --git a/utils/init.ts b/utils/init.ts index b861b5ca..951bade9 100644 --- a/utils/init.ts +++ b/utils/init.ts @@ -1,10 +1,13 @@ import { getLogger } from "@logtape/logtape"; import chalk from "chalk"; import type { Config } from "~/packages/config-manager"; +import { User } from "~/packages/database-interface/user"; export const checkConfig = async (config: Config) => { await checkOidcConfig(config); + await checkFederationConfig(config); + await checkHttpProxyConfig(config); await checkChallengeConfig(config); @@ -127,3 +130,50 @@ const checkOidcConfig = async (config: Config) => { await Bun.sleep(Number.POSITIVE_INFINITY); } }; + +const checkFederationConfig = async (config: Config) => { + const logger = getLogger("server"); + + if (!(config.instance.keys.public && config.instance.keys.private)) { + logger.fatal`The federation keys are not set in the config`; + logger.fatal`Below are generated keys for you to copy in the config at instance.keys.public and instance.keys.private`; + + // Generate a key for them + const { public_key, private_key } = await User.generateKeys(); + + logger.fatal`Generated public key: ${chalk.gray(public_key)}`; + logger.fatal`Generated private key: ${chalk.gray(private_key)}`; + + // Hang until Ctrl+C is pressed + await Bun.sleep(Number.POSITIVE_INFINITY); + } + + // Try and import the key + const privateKey = await crypto.subtle + .importKey( + "pkcs8", + Buffer.from(config.instance.keys.private, "base64"), + "Ed25519", + false, + ["sign"], + ) + .catch((e) => e as Error); + + // Try and import the key + const publicKey = await crypto.subtle + .importKey( + "spki", + Buffer.from(config.instance.keys.public, "base64"), + "Ed25519", + false, + ["verify"], + ) + .catch((e) => e as Error); + + if (privateKey instanceof Error || publicKey instanceof Error) { + logger.fatal`The federation keys could not be imported! You may generate new ones by removing the old ones from the config and restarting the server.`; + + // Hang until Ctrl+C is pressed + await Bun.sleep(Number.POSITIVE_INFINITY); + } +};