feat(federation): Add ActivityPub bridge support with CLI command

This commit is contained in:
Jesse Wierzbinski 2024-07-16 23:29:20 +02:00
parent 153aa061f0
commit ff315af230
No known key found for this signature in database
13 changed files with 2337 additions and 15 deletions

View file

@ -1,8 +1,10 @@
import { parseUserAddress, userAddressValidator } from "@/api";
import { Args, type Command, Flags, type Interfaces } from "@oclif/core"; import { Args, type Command, Flags, type Interfaces } from "@oclif/core";
import chalk from "chalk"; import chalk from "chalk";
import { and, eq, getTableColumns, like } from "drizzle-orm"; import { and, eq, getTableColumns, like } from "drizzle-orm";
import { db } from "~/drizzle/db"; import { db } from "~/drizzle/db";
import { Emojis, Instances, Users } from "~/drizzle/schema"; import { Emojis, Instances, Users } from "~/drizzle/schema";
import { Instance } from "~/packages/database-interface/instance";
import { User } from "~/packages/database-interface/user"; import { User } from "~/packages/database-interface/user";
import { BaseCommand } from "./base"; import { BaseCommand } from "./base";
@ -25,8 +27,15 @@ export abstract class UserFinderCommand<
type: Flags.string({ type: Flags.string({
char: "t", char: "t",
description: "Type of identifier", description: "Type of identifier",
options: ["id", "username", "note", "display-name", "email"], options: [
default: "id", "id",
"username",
"note",
"display-name",
"email",
"address",
],
default: "address",
}), }),
limit: Flags.integer({ limit: Flags.integer({
char: "n", char: "n",
@ -44,7 +53,7 @@ export abstract class UserFinderCommand<
static baseArgs = { static baseArgs = {
identifier: Args.string({ identifier: Args.string({
description: description:
"Identifier of the user (by default this must be an ID)", "Identifier of the user (by default this must be an address, i.e. name@host.com)",
required: true, required: true,
}), }),
}; };
@ -78,10 +87,26 @@ export abstract class UserFinderCommand<
const operator = this.flags.pattern ? like : eq; const operator = this.flags.pattern ? like : eq;
// Replace wildcards with an SQL LIKE pattern // Replace wildcards with an SQL LIKE pattern
const identifier = this.flags.pattern const identifier: string = this.flags.pattern
? this.args.identifier.replace(/\*/g, "%") ? this.args.identifier.replace(/\*/g, "%")
: this.args.identifier; : this.args.identifier;
if (this.flags.type === "address") {
// Check if the address is valid
if (!userAddressValidator.exec(identifier)) {
this.log(
"Invalid address. Please check the address format and try again. For example: name@host.com",
);
this.exit(1);
}
// Check instance exists, if not, create it
await Instance.resolve(
`https://${parseUserAddress(identifier).domain}`,
);
}
return await User.manyFromSql( return await User.manyFromSql(
and( and(
this.flags.type === "id" this.flags.type === "id"
@ -99,6 +124,30 @@ export abstract class UserFinderCommand<
this.flags.type === "email" this.flags.type === "email"
? operator(Users.email, identifier) ? operator(Users.email, identifier)
: undefined, : undefined,
this.flags.type === "address"
? and(
operator(
Users.username,
parseUserAddress(identifier).username,
),
operator(
Users.instanceId,
(
await Instance.fromSql(
eq(
Instances.baseUrl,
new URL(
`https://${
parseUserAddress(identifier)
.domain
}`,
).host,
),
)
)?.id ?? "",
),
)
: undefined,
), ),
undefined, undefined,
this.flags.limit, this.flags.limit,

View file

@ -1,9 +1,11 @@
import { parseUserAddress, userAddressValidator } from "@/api";
import { SignatureConstructor } from "@lysand-org/federation"; import { SignatureConstructor } from "@lysand-org/federation";
import { FederationRequester } from "@lysand-org/federation/requester"; import { FederationRequester } from "@lysand-org/federation/requester";
import { Args } from "@oclif/core"; import { Args } from "@oclif/core";
import chalk from "chalk"; import chalk from "chalk";
import ora from "ora"; import ora from "ora";
import { BaseCommand } from "~/cli/base"; import { BaseCommand } from "~/cli/base";
import { Instance } from "~/packages/database-interface/instance";
import { User } from "~/packages/database-interface/user"; import { User } from "~/packages/database-interface/user";
export default class FederationUserFetch extends BaseCommand< export default class FederationUserFetch extends BaseCommand<
@ -16,7 +18,7 @@ export default class FederationUserFetch extends BaseCommand<
}), }),
}; };
static override description = "Fetch the URL of remote users via WebFinger"; static override description = "Fetch remote users";
static override examples = ["<%= config.bin %> <%= command.id %>"]; static override examples = ["<%= config.bin %> <%= command.id %>"];
@ -25,9 +27,21 @@ export default class FederationUserFetch extends BaseCommand<
public async run(): Promise<void> { public async run(): Promise<void> {
const { args } = await this.parse(FederationUserFetch); const { args } = await this.parse(FederationUserFetch);
const spinner = ora("Fetching user URI").start(); // Check if the address is valid
if (!args.address.match(userAddressValidator)) {
this.log(
"Invalid address. Please check the address format and try again. For example: name@host.com",
);
const [username, host] = args.address.split("@"); this.exit(1);
}
const spinner = ora("Fetching user").start();
const { username, domain: host } = parseUserAddress(args.address);
// Check instance exists, if not, create it
await Instance.resolve(`https://${host}`);
const requester = await User.getServerActor(); const requester = await User.getServerActor();
@ -42,9 +56,15 @@ export default class FederationUserFetch extends BaseCommand<
const uri = await manager.webFinger(username); const uri = await manager.webFinger(username);
spinner.succeed("Fetched user URI"); const newUser = await User.resolve(uri);
this.log(`URI: ${chalk.blueBright(uri)}`); if (newUser) {
spinner.succeed();
this.log(chalk.green(`User found: ${newUser.getUri()}`));
} else {
spinner.fail();
this.log(chalk.red("User not found"));
}
this.exit(0); this.exit(0);
} }

View file

@ -0,0 +1,65 @@
import { parseUserAddress, userAddressValidator } from "@/api";
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 { Instance } from "~/packages/database-interface/instance";
import { User } from "~/packages/database-interface/user";
export default class FederationUserFinger extends BaseCommand<
typeof FederationUserFinger
> {
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(FederationUserFinger);
// Check if the address is valid
if (!args.address.match(userAddressValidator)) {
this.log(
"Invalid address. Please check the address format and try again. For example: name@host.com",
);
this.exit(1);
}
const spinner = ora("Fetching user URI").start();
const { username, domain: host } = parseUserAddress(args.address);
// Check instance exists, if not, create it
await Instance.resolve(`https://${host}`);
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);
}
}

View file

@ -6,6 +6,7 @@ import EmojiImport from "./commands/emoji/import";
import EmojiList from "./commands/emoji/list"; import EmojiList from "./commands/emoji/list";
import FederationInstanceFetch from "./commands/federation/instance/fetch"; import FederationInstanceFetch from "./commands/federation/instance/fetch";
import FederationUserFetch from "./commands/federation/user/fetch"; import FederationUserFetch from "./commands/federation/user/fetch";
import FederationUserFinger from "./commands/federation/user/finger";
import IndexRebuild from "./commands/index/rebuild"; import IndexRebuild from "./commands/index/rebuild";
import Start from "./commands/start"; import Start from "./commands/start";
import UserCreate from "./commands/user/create"; import UserCreate from "./commands/user/create";
@ -29,6 +30,7 @@ export const commands = {
"emoji:import": EmojiImport, "emoji:import": EmojiImport,
"index:rebuild": IndexRebuild, "index:rebuild": IndexRebuild,
"federation:instance:fetch": FederationInstanceFetch, "federation:instance:fetch": FederationInstanceFetch,
"federation:user:finger": FederationUserFinger,
"federation:user:fetch": FederationUserFetch, "federation:user:fetch": FederationUserFetch,
start: Start, start: Start,
}; };

View file

@ -319,6 +319,7 @@ allowed_ips = ["192.168.1.0/24"]
# Token for the bridge software # Token for the bridge software
# Bridge must have the same token! # Bridge must have the same token!
token = "mycooltoken" token = "mycooltoken"
url = "https://ap.lysand.org"
[instance] [instance]
name = "Lysand" name = "Lysand"

View file

@ -0,0 +1 @@
ALTER TABLE "Instances" ALTER COLUMN "logo" DROP NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -204,6 +204,13 @@
"when": 1719726234826, "when": 1719726234826,
"tag": "0028_unique_fat_cobra", "tag": "0028_unique_fat_cobra",
"breakpoints": true "breakpoints": true
},
{
"idx": 29,
"version": "7",
"when": 1721155789219,
"tag": "0029_shiny_korvac",
"breakpoints": true
} }
] ]
} }

View file

@ -344,7 +344,7 @@ export const Instances = pgTable("Instances", {
baseUrl: text("base_url").notNull(), baseUrl: text("base_url").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
version: text("version").notNull(), version: text("version").notNull(),
logo: jsonb("logo").notNull(), logo: jsonb("logo"),
disableAutomoderation: boolean("disable_automoderation") disableAutomoderation: boolean("disable_automoderation")
.default(false) .default(false)
.notNull(), .notNull(),

View file

@ -471,13 +471,19 @@ export const configValidator = z.object({
software: z.enum(["lysand-ap"]).or(z.string()), software: z.enum(["lysand-ap"]).or(z.string()),
allowed_ips: z.array(z.string().trim()).default([]), allowed_ips: z.array(z.string().trim()).default([]),
token: z.string().default(""), token: z.string().default(""),
url: zUrl.optional(),
}) })
.default({ .default({
enabled: false, enabled: false,
software: "lysand-ap", software: "lysand-ap",
allowed_ips: [], allowed_ips: [],
token: "", token: "",
}), url: "",
})
.refine(
(arg) => (arg.enabled ? arg.url : true),
"When bridge is enabled, url must be set",
),
}) })
.default({ .default({
blocked: [], blocked: [],
@ -498,6 +504,7 @@ export const configValidator = z.object({
software: "lysand-ap", software: "lysand-ap",
allowed_ips: [], allowed_ips: [],
token: "", token: "",
url: "",
}, },
}), }),
instance: z instance: z

View file

@ -305,8 +305,14 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
} }
if (instance.data.protocol === "activitypub") { if (instance.data.protocol === "activitypub") {
// Placeholder for ActivityPub user fetching const bridgeUri = new URL(
throw new Error("ActivityPub user fetching not implemented"); `/apbridge/lysand/query?${new URLSearchParams({
user_url: uri,
})}`,
config.federation.bridge.url,
);
return await User.saveFromLysand(bridgeUri.toString(), instance);
} }
throw new Error(`Unsupported protocol: ${instance.data.protocol}`); throw new Error(`Unsupported protocol: ${instance.data.protocol}`);

View file

@ -1,4 +1,10 @@
import { applyConfig, auth, handleZodError, userAddressValidator } from "@/api"; import {
applyConfig,
auth,
handleZodError,
parseUserAddress,
userAddressValidator,
} from "@/api";
import { errorResponse, jsonResponse } from "@/response"; import { errorResponse, jsonResponse } from "@/response";
import type { Hono } from "@hono/hono"; import type { Hono } from "@hono/hono";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
@ -88,7 +94,9 @@ export default (app: Hono) =>
accountMatches[0] = accountMatches[0].slice(1); accountMatches[0] = accountMatches[0].slice(1);
} }
const [username, domain] = accountMatches[0].split("@"); const { username, domain } = parseUserAddress(
accountMatches[0],
);
const accountId = ( const accountId = (
await db await db

View file

@ -106,6 +106,17 @@ export const webfingerMention = createRegExp(
[], [],
); );
export const parseUserAddress = (address: string) => {
let output = address;
// Remove leading @ if it exists
if (output.startsWith("@")) {
output = output.slice(1);
}
const [username, domain] = output.split("@");
return { username, domain };
};
export const handleZodError = ( export const handleZodError = (
result: result:
| { success: true; data?: object } | { success: true; data?: object }