mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
feat(federation): ✨ Add ActivityPub bridge support with CLI command
This commit is contained in:
parent
153aa061f0
commit
ff315af230
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
65
cli/commands/federation/user/finger.ts
Normal file
65
cli/commands/federation/user/finger.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
1
drizzle/migrations/0029_shiny_korvac.sql
Normal file
1
drizzle/migrations/0029_shiny_korvac.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "Instances" ALTER COLUMN "logo" DROP NOT NULL;
|
||||||
2145
drizzle/migrations/meta/0029_snapshot.json
Normal file
2145
drizzle/migrations/meta/0029_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
11
utils/api.ts
11
utils/api.ts
|
|
@ -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 }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue