mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
refactor(federation): ♻️ Replace WebFinger code with @lysand-org/federation logic, add new debug command
This commit is contained in:
parent
38c8ea24a9
commit
cea9452127
|
|
@ -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<User[]> => {
|
||||
export const parseTextMentions = async (
|
||||
text: string,
|
||||
author: User,
|
||||
): Promise<User[]> => {
|
||||
const mentionedPeople = [...text.matchAll(mentionValidator)] ?? [];
|
||||
if (mentionedPeople.length === 0) {
|
||||
return [];
|
||||
|
|
@ -310,10 +314,18 @@ export const parseTextMentions = async (text: string): Promise<User[]> => {
|
|||
|
||||
// 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);
|
||||
|
|
|
|||
|
|
@ -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<User | null> => {
|
||||
// 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.
|
||||
|
|
|
|||
51
cli/commands/federation/user/fetch.ts
Normal file
51
cli/commands/federation/user/fetch.ts
Normal file
|
|
@ -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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -326,7 +326,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
|
|||
|
||||
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<typeof Notes, StatusWithRelations> {
|
|||
* @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<typeof Notes, StatusWithRelations> {
|
|||
const parsedMentions = [
|
||||
...(data.mentions ?? []),
|
||||
...(plaintextContent
|
||||
? await parseTextMentions(plaintextContent)
|
||||
? await parseTextMentions(plaintextContent, data.author)
|
||||
: []),
|
||||
// Deduplicate by .id
|
||||
].filter(
|
||||
|
|
|
|||
|
|
@ -123,6 +123,56 @@ 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: "",
|
||||
});
|
||||
}
|
||||
|
||||
static getUri(id: string, uri: string | null, baseUrl: string) {
|
||||
return uri || new URL(`/users/${id}`, baseUrl).toString();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]: {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue