mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
commit
457a4054b7
8
build.ts
8
build.ts
|
|
@ -25,7 +25,7 @@ await Bun.build({
|
|||
target: "bun",
|
||||
splitting: true,
|
||||
minify: false,
|
||||
external: ["unzipit", "acorn", "@bull-board/ui"],
|
||||
external: ["acorn", "@bull-board/ui"],
|
||||
});
|
||||
|
||||
buildSpinner.text = "Transforming";
|
||||
|
|
@ -47,10 +47,6 @@ await $`mkdir -p dist/node_modules/@img`;
|
|||
await $`cp -r node_modules/@img/sharp-libvips-linuxmusl-* dist/node_modules/@img`;
|
||||
await $`cp -r node_modules/@img/sharp-linuxmusl-* dist/node_modules/@img`;
|
||||
|
||||
// Copy unzipit and uzip-module to dist
|
||||
await $`cp -r node_modules/unzipit dist/node_modules/unzipit`;
|
||||
await $`cp -r node_modules/uzip-module dist/node_modules/uzip-module`;
|
||||
|
||||
// Copy acorn to dist
|
||||
await $`cp -r node_modules/acorn dist/node_modules/acorn`;
|
||||
|
||||
|
|
@ -63,7 +59,5 @@ await $`cp beemovie.txt dist/beemovie.txt`;
|
|||
|
||||
// Copy package.json
|
||||
await $`cp package.json dist/package.json`;
|
||||
// Copy cli/theme.json
|
||||
await $`cp cli/theme.json dist/cli/theme.json`;
|
||||
|
||||
buildSpinner.stop();
|
||||
|
|
|
|||
12
cli/base.ts
12
cli/base.ts
|
|
@ -1,12 +0,0 @@
|
|||
import { Command } from "@oclif/core";
|
||||
import { searchManager } from "~/classes/search/search-manager";
|
||||
import { setupDatabase } from "~/drizzle/db";
|
||||
|
||||
export abstract class BaseCommand<_T extends typeof Command> extends Command {
|
||||
protected async init(): Promise<void> {
|
||||
await super.init();
|
||||
|
||||
await setupDatabase(false);
|
||||
await searchManager.connect(true);
|
||||
}
|
||||
}
|
||||
246
cli/classes.ts
246
cli/classes.ts
|
|
@ -1,246 +0,0 @@
|
|||
import { parseUserAddress, userAddressValidator } from "@/api";
|
||||
import { Args, type Command, Flags, type Interfaces } from "@oclif/core";
|
||||
import { Emoji, Instance, User } from "@versia/kit/db";
|
||||
import { Emojis, Instances, Users } from "@versia/kit/tables";
|
||||
import chalk from "chalk";
|
||||
import { and, eq, inArray, like } from "drizzle-orm";
|
||||
import { BaseCommand } from "./base.ts";
|
||||
|
||||
export type FlagsType<T extends typeof Command> = Interfaces.InferredFlags<
|
||||
(typeof BaseCommand)["baseFlags"] & T["flags"]
|
||||
>;
|
||||
export type ArgsType<T extends typeof Command> = Interfaces.InferredArgs<
|
||||
T["args"]
|
||||
>;
|
||||
|
||||
export abstract class UserFinderCommand<
|
||||
T extends typeof BaseCommand,
|
||||
> extends BaseCommand<typeof UserFinderCommand> {
|
||||
public static baseFlags = {
|
||||
pattern: Flags.boolean({
|
||||
char: "p",
|
||||
description:
|
||||
"Process as a wildcard pattern (don't forget to escape)",
|
||||
}),
|
||||
type: Flags.string({
|
||||
char: "t",
|
||||
description: "Type of identifier",
|
||||
options: [
|
||||
"id",
|
||||
"username",
|
||||
"note",
|
||||
"display-name",
|
||||
"email",
|
||||
"address",
|
||||
],
|
||||
default: "address",
|
||||
}),
|
||||
limit: Flags.integer({
|
||||
char: "n",
|
||||
description: "Limit the number of users",
|
||||
default: 100,
|
||||
}),
|
||||
print: Flags.boolean({
|
||||
allowNo: true,
|
||||
default: true,
|
||||
char: "P",
|
||||
description: "Print user(s) found before processing",
|
||||
}),
|
||||
};
|
||||
|
||||
public static baseArgs = {
|
||||
identifier: Args.string({
|
||||
description:
|
||||
"Identifier of the user (by default this must be an address, i.e. name@host.com)",
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
protected flags!: FlagsType<T>;
|
||||
protected args!: ArgsType<T>;
|
||||
|
||||
public async init(): Promise<void> {
|
||||
await super.init();
|
||||
const { args, flags } = await this.parse({
|
||||
flags: this.ctor.flags,
|
||||
baseFlags: (super.ctor as typeof BaseCommand).baseFlags,
|
||||
args: this.ctor.args,
|
||||
strict: this.ctor.strict,
|
||||
});
|
||||
this.flags = flags as FlagsType<T>;
|
||||
this.args = args as ArgsType<T>;
|
||||
}
|
||||
|
||||
public async findUsers(): Promise<User[]> {
|
||||
// Check if there are asterisks in the identifier but no pattern flag, warn the user if so
|
||||
if (this.args.identifier.includes("*") && !this.flags.pattern) {
|
||||
this.log(
|
||||
chalk.bold(
|
||||
`${chalk.yellow(
|
||||
"⚠",
|
||||
)} Your identifier has asterisks but the --pattern flag is not set. This will match a literal string. If you want to use wildcards, set the --pattern flag.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const operator = this.flags.pattern ? like : eq;
|
||||
// Replace wildcards with an SQL LIKE pattern
|
||||
const identifier: string = this.flags.pattern
|
||||
? this.args.identifier.replace(/\*/g, "%")
|
||||
: 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(
|
||||
new URL(`https://${parseUserAddress(identifier).domain}`),
|
||||
);
|
||||
}
|
||||
|
||||
return await User.manyFromSql(
|
||||
and(
|
||||
this.flags.type === "id"
|
||||
? operator(Users.id, identifier)
|
||||
: undefined,
|
||||
this.flags.type === "username"
|
||||
? operator(Users.username, identifier)
|
||||
: undefined,
|
||||
this.flags.type === "note"
|
||||
? operator(Users.note, identifier)
|
||||
: undefined,
|
||||
this.flags.type === "display-name"
|
||||
? operator(Users.displayName, identifier)
|
||||
: undefined,
|
||||
this.flags.type === "email"
|
||||
? operator(Users.email, identifier)
|
||||
: 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,
|
||||
this.flags.limit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class EmojiFinderCommand<
|
||||
T extends typeof BaseCommand,
|
||||
> extends BaseCommand<typeof EmojiFinderCommand> {
|
||||
public static baseFlags = {
|
||||
pattern: Flags.boolean({
|
||||
char: "p",
|
||||
description:
|
||||
"Process as a wildcard pattern (don't forget to escape)",
|
||||
}),
|
||||
type: Flags.string({
|
||||
char: "t",
|
||||
description: "Type of identifier",
|
||||
options: ["shortcode", "instance"],
|
||||
default: "shortcode",
|
||||
}),
|
||||
limit: Flags.integer({
|
||||
char: "n",
|
||||
description: "Limit the number of emojis",
|
||||
default: 100,
|
||||
}),
|
||||
print: Flags.boolean({
|
||||
allowNo: true,
|
||||
default: true,
|
||||
char: "P",
|
||||
description: "Print emoji(s) found before processing",
|
||||
}),
|
||||
};
|
||||
|
||||
public static baseArgs = {
|
||||
identifier: Args.string({
|
||||
description: "Identifier of the emoji (defaults to shortcode)",
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
protected flags!: FlagsType<T>;
|
||||
protected args!: ArgsType<T>;
|
||||
|
||||
public async init(): Promise<void> {
|
||||
await super.init();
|
||||
const { args, flags } = await this.parse({
|
||||
flags: this.ctor.flags,
|
||||
baseFlags: (super.ctor as typeof BaseCommand).baseFlags,
|
||||
args: this.ctor.args,
|
||||
strict: this.ctor.strict,
|
||||
});
|
||||
this.flags = flags as FlagsType<T>;
|
||||
this.args = args as ArgsType<T>;
|
||||
}
|
||||
|
||||
public async findEmojis(): Promise<Emoji[]> {
|
||||
// Check if there are asterisks in the identifier but no pattern flag, warn the user if so
|
||||
if (this.args.identifier.includes("*") && !this.flags.pattern) {
|
||||
this.log(
|
||||
chalk.bold(
|
||||
`${chalk.yellow(
|
||||
"⚠",
|
||||
)} Your identifier has asterisks but the --pattern flag is not set. This will match a literal string. If you want to use wildcards, set the --pattern flag.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const operator = this.flags.pattern ? like : eq;
|
||||
// Replace wildcards with an SQL LIKE pattern
|
||||
const identifier = this.flags.pattern
|
||||
? this.args.identifier.replace(/\*/g, "%")
|
||||
: this.args.identifier;
|
||||
|
||||
const instanceIds =
|
||||
this.flags.type === "instance"
|
||||
? (
|
||||
await Instance.manyFromSql(
|
||||
operator(Instances.baseUrl, identifier),
|
||||
)
|
||||
).map((instance) => instance.id)
|
||||
: undefined;
|
||||
|
||||
return await Emoji.manyFromSql(
|
||||
and(
|
||||
this.flags.type === "shortcode"
|
||||
? operator(Emojis.shortcode, identifier)
|
||||
: undefined,
|
||||
instanceIds && instanceIds.length > 0
|
||||
? inArray(Emojis.instanceId, instanceIds)
|
||||
: undefined,
|
||||
),
|
||||
undefined,
|
||||
this.flags.limit,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
import { Args } from "@oclif/core";
|
||||
import { Emoji, Media } from "@versia/kit/db";
|
||||
import { Emojis } from "@versia/kit/tables";
|
||||
import chalk from "chalk";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import ora from "ora";
|
||||
import { BaseCommand } from "~/cli/base";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
export default class EmojiAdd extends BaseCommand<typeof EmojiAdd> {
|
||||
public static override args = {
|
||||
shortcode: Args.string({
|
||||
description: "Shortcode of the emoji",
|
||||
required: true,
|
||||
}),
|
||||
file: Args.string({
|
||||
description: "Path to the image file (can be an URL)",
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
public static override description = "Adds a new emoji";
|
||||
|
||||
public static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> baba_yassie ./emojis/baba_yassie.png",
|
||||
"<%= config.bin %> <%= command.id %> baba_yassie https://example.com/emojis/baba_yassie.png",
|
||||
];
|
||||
|
||||
public static override flags = {};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { args } = await this.parse(EmojiAdd);
|
||||
|
||||
// Check if emoji already exists
|
||||
const existingEmoji = await Emoji.fromSql(
|
||||
and(
|
||||
eq(Emojis.shortcode, args.shortcode),
|
||||
isNull(Emojis.instanceId),
|
||||
),
|
||||
);
|
||||
|
||||
if (existingEmoji) {
|
||||
this.log(
|
||||
`${chalk.red("✗")} Emoji with shortcode ${chalk.red(
|
||||
args.shortcode,
|
||||
)} already exists`,
|
||||
);
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
let file: File | null = null;
|
||||
|
||||
if (URL.canParse(args.file)) {
|
||||
const spinner = ora(
|
||||
`Downloading emoji from ${chalk.blue(
|
||||
chalk.underline(args.file),
|
||||
)}`,
|
||||
).start();
|
||||
|
||||
const response = await fetch(args.file, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
},
|
||||
// @ts-expect-error Proxy is a Bun-specific feature
|
||||
proxy: config.http.proxy_address,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
spinner.fail();
|
||||
this.log(
|
||||
`${chalk.red("✗")} Request returned status code ${chalk.red(
|
||||
response.status,
|
||||
)}`,
|
||||
);
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
const filename =
|
||||
new URL(args.file).pathname.split("/").pop() ?? "emoji";
|
||||
|
||||
file = new File([await response.blob()], filename, {
|
||||
type:
|
||||
response.headers.get("Content-Type") ??
|
||||
"application/octet-stream",
|
||||
});
|
||||
|
||||
spinner.succeed();
|
||||
} else {
|
||||
const bunFile = Bun.file(args.file);
|
||||
file = new File(
|
||||
[await bunFile.arrayBuffer()],
|
||||
args.file.split("/").pop() ?? "emoji",
|
||||
{
|
||||
type: bunFile.type,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const spinner = ora("Uploading emoji").start();
|
||||
|
||||
const media = await Media.fromFile(file);
|
||||
|
||||
spinner.succeed();
|
||||
|
||||
await Emoji.insert({
|
||||
shortcode: args.shortcode,
|
||||
mediaId: media.id,
|
||||
visibleInPicker: true,
|
||||
});
|
||||
|
||||
this.log(
|
||||
`${chalk.green("✓")} Created emoji ${chalk.green(
|
||||
args.shortcode,
|
||||
)} with url ${chalk.blue(chalk.underline(media.getUrl()))}`,
|
||||
);
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
import confirm from "@inquirer/confirm";
|
||||
import { Flags } from "@oclif/core";
|
||||
import chalk from "chalk";
|
||||
import ora from "ora";
|
||||
import { EmojiFinderCommand } from "~/cli/classes";
|
||||
import { formatArray } from "~/cli/utils/format";
|
||||
|
||||
export default class EmojiDelete extends EmojiFinderCommand<
|
||||
typeof EmojiDelete
|
||||
> {
|
||||
public static override args = {
|
||||
identifier: EmojiFinderCommand.baseArgs.identifier,
|
||||
};
|
||||
|
||||
public static override description = "Deletes an emoji";
|
||||
|
||||
public static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> baba_yassie",
|
||||
'<%= config.bin %> <%= command.id %> "baba\\*" --pattern',
|
||||
];
|
||||
|
||||
public static override flags = {
|
||||
confirm: Flags.boolean({
|
||||
description:
|
||||
"Ask for confirmation before deleting the emoji (default yes)",
|
||||
allowNo: true,
|
||||
default: true,
|
||||
}),
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags } = await this.parse(EmojiDelete);
|
||||
|
||||
const emojis = await this.findEmojis();
|
||||
|
||||
if (!emojis || emojis.length === 0) {
|
||||
this.log(chalk.bold(`${chalk.red("✗")} No emojis found`));
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
// Display user
|
||||
flags.print &&
|
||||
this.log(
|
||||
chalk.bold(
|
||||
`${chalk.green("✓")} Found ${chalk.green(
|
||||
emojis.length,
|
||||
)} emoji(s)`,
|
||||
),
|
||||
);
|
||||
|
||||
flags.print &&
|
||||
this.log(
|
||||
formatArray(
|
||||
emojis.map((e) => e.data),
|
||||
["id", "shortcode", "alt", "contentType", "instanceUrl"],
|
||||
),
|
||||
);
|
||||
|
||||
if (flags.confirm) {
|
||||
const choice = await confirm({
|
||||
message: `Are you sure you want to delete these emojis? ${chalk.red(
|
||||
"This is irreversible.",
|
||||
)}`,
|
||||
});
|
||||
|
||||
if (!choice) {
|
||||
this.log(chalk.bold(`${chalk.red("✗")} Aborted operation`));
|
||||
return this.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = ora("Deleting emoji(s)").start();
|
||||
|
||||
for (const emoji of emojis) {
|
||||
spinner.text = `Deleting emoji ${chalk.gray(emoji.data.shortcode)} (${
|
||||
emojis.findIndex((e) => e.id === emoji.id) + 1
|
||||
}/${emojis.length})`;
|
||||
|
||||
await emoji.delete();
|
||||
}
|
||||
|
||||
spinner.succeed("Emoji(s) deleted");
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
import { Args, Flags } from "@oclif/core";
|
||||
import { Emoji, Media } from "@versia/kit/db";
|
||||
import { Emojis } from "@versia/kit/tables";
|
||||
import chalk from "chalk";
|
||||
import { and, inArray, isNull } from "drizzle-orm";
|
||||
import { lookup } from "mime-types";
|
||||
import ora from "ora";
|
||||
import { unzip } from "unzipit";
|
||||
import { BaseCommand } from "~/cli/base";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
type MetaType = {
|
||||
emojis: {
|
||||
fileName: string;
|
||||
emoji: {
|
||||
name: string;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
export default class EmojiImport extends BaseCommand<typeof EmojiImport> {
|
||||
public static override args = {
|
||||
path: Args.string({
|
||||
description: "Path to the emoji archive (can be an URL)",
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
public static override description =
|
||||
"Imports emojis from a zip file (which can be fetched from a zip URL, e.g. for Pleroma emoji packs)";
|
||||
|
||||
public static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> https://volpeon.ink/emojis/neocat/neocat.zip",
|
||||
"<%= config.bin %> <%= command.id %> export.zip",
|
||||
];
|
||||
|
||||
public static override flags = {
|
||||
confirm: Flags.boolean({
|
||||
description:
|
||||
"Ask for confirmation before deleting the emoji (default yes)",
|
||||
allowNo: true,
|
||||
default: true,
|
||||
}),
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { args } = await this.parse(EmojiImport);
|
||||
|
||||
// Check if path ends in .zip, warn the user if it doesn't
|
||||
if (!args.path.endsWith(".zip")) {
|
||||
this.log(
|
||||
`${chalk.yellow(
|
||||
"⚠",
|
||||
)} The path you provided does not end in .zip, this may not be a zip file. Proceeding anyway.`,
|
||||
);
|
||||
}
|
||||
|
||||
let file: File | null = null;
|
||||
|
||||
if (URL.canParse(args.path)) {
|
||||
const spinner = ora(
|
||||
`Downloading pack from ${chalk.blue(
|
||||
chalk.underline(args.path),
|
||||
)}`,
|
||||
).start();
|
||||
|
||||
const response = await fetch(args.path, {
|
||||
headers: {
|
||||
"Accept-Encoding": "identity",
|
||||
},
|
||||
// @ts-expect-error Proxy is a Bun-specific feature
|
||||
proxy: config.http.proxy_address,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
spinner.fail();
|
||||
this.log(
|
||||
`${chalk.red("✗")} Request returned status code ${chalk.red(
|
||||
response.status,
|
||||
)}`,
|
||||
);
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
const filename =
|
||||
new URL(args.path).pathname.split("/").pop() ?? "archive";
|
||||
|
||||
file = new File([await response.blob()], filename, {
|
||||
type:
|
||||
response.headers.get("Content-Type") ??
|
||||
"application/octet-stream",
|
||||
});
|
||||
|
||||
spinner.succeed();
|
||||
} else {
|
||||
const bunFile = Bun.file(args.path);
|
||||
file = new File(
|
||||
[await bunFile.arrayBuffer()],
|
||||
args.path.split("/").pop() ?? "archive",
|
||||
{
|
||||
type: bunFile.type,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const unzipSpinner = ora("Unzipping pack").start();
|
||||
|
||||
const { entries: unzipped } = await unzip(file);
|
||||
|
||||
unzipSpinner.succeed();
|
||||
|
||||
const entries = Object.entries(unzipped);
|
||||
|
||||
// Check if a meta.json file exists
|
||||
const metaExists = entries.find(([name]) => name === "meta.json");
|
||||
|
||||
if (metaExists) {
|
||||
this.log(`${chalk.green("✓")} Detected Pleroma meta.json, parsing`);
|
||||
}
|
||||
|
||||
const meta = metaExists
|
||||
? ((await metaExists[1].json()) as MetaType)
|
||||
: ({
|
||||
emojis: entries.map(([name]) => ({
|
||||
fileName: name,
|
||||
emoji: {
|
||||
name: name.split(".")[0],
|
||||
},
|
||||
})),
|
||||
} as MetaType);
|
||||
|
||||
// Get all emojis that already exist
|
||||
const existingEmojis = await Emoji.manyFromSql(
|
||||
and(
|
||||
isNull(Emojis.instanceId),
|
||||
inArray(
|
||||
Emojis.shortcode,
|
||||
meta.emojis.map((e) => e.emoji.name),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Filter out existing emojis
|
||||
const newEmojis = meta.emojis.filter(
|
||||
(e) =>
|
||||
!existingEmojis.find(
|
||||
(ee) => ee.data.shortcode === e.emoji.name,
|
||||
),
|
||||
);
|
||||
|
||||
existingEmojis.length > 0 &&
|
||||
this.log(
|
||||
`${chalk.yellow("⚠")} Emojis with shortcode ${chalk.yellow(
|
||||
existingEmojis.map((e) => e.data.shortcode).join(", "),
|
||||
)} already exist in the database and will not be imported`,
|
||||
);
|
||||
|
||||
if (newEmojis.length === 0) {
|
||||
this.log(`${chalk.red("✗")} No new emojis to import`);
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
this.log(
|
||||
`${chalk.green("✓")} Found ${chalk.green(
|
||||
newEmojis.length,
|
||||
)} new emoji(s)`,
|
||||
);
|
||||
|
||||
const importSpinner = ora("Importing emojis").start();
|
||||
|
||||
const successfullyImported: MetaType["emojis"] = [];
|
||||
|
||||
for (const emoji of newEmojis) {
|
||||
importSpinner.text = `Uploading ${chalk.gray(emoji.emoji.name)} (${
|
||||
newEmojis.indexOf(emoji) + 1
|
||||
}/${newEmojis.length})`;
|
||||
const zipEntry = unzipped[emoji.fileName];
|
||||
|
||||
if (!zipEntry) {
|
||||
this.log(
|
||||
`${chalk.red(
|
||||
"✗",
|
||||
)} Could not find file for emoji ${chalk.red(
|
||||
emoji.emoji.name,
|
||||
)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileName = emoji.fileName.split("/").pop() ?? "emoji";
|
||||
const contentType = lookup(fileName) || "application/octet-stream";
|
||||
|
||||
const newFile = new File([await zipEntry.arrayBuffer()], fileName, {
|
||||
type: contentType,
|
||||
});
|
||||
|
||||
const media = await Media.fromFile(newFile);
|
||||
|
||||
await Emoji.insert({
|
||||
shortcode: emoji.emoji.name,
|
||||
mediaId: media.id,
|
||||
visibleInPicker: true,
|
||||
});
|
||||
|
||||
successfullyImported.push(emoji);
|
||||
}
|
||||
|
||||
importSpinner.succeed("Imported emojis");
|
||||
|
||||
successfullyImported.length > 0 &&
|
||||
this.log(
|
||||
`${chalk.green("✓")} Successfully imported ${chalk.green(
|
||||
successfullyImported.length,
|
||||
)} emoji(s)`,
|
||||
);
|
||||
|
||||
newEmojis.length - successfullyImported.length > 0 &&
|
||||
this.log(
|
||||
`${chalk.yellow("⚠")} Failed to import ${chalk.yellow(
|
||||
newEmojis.length - successfullyImported.length,
|
||||
)} emoji(s): ${chalk.yellow(
|
||||
newEmojis
|
||||
.filter((e) => !successfullyImported.includes(e))
|
||||
.map((e) => e.emoji.name)
|
||||
.join(", "),
|
||||
)}`,
|
||||
);
|
||||
|
||||
if (successfullyImported.length === 0) {
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
import { Flags } from "@oclif/core";
|
||||
import { db } from "@versia/kit/db";
|
||||
import { Emojis, Instances, Users } from "@versia/kit/tables";
|
||||
import { and, eq, getTableColumns, isNotNull, isNull } from "drizzle-orm";
|
||||
import { BaseCommand } from "~/cli/base";
|
||||
import { formatArray } from "~/cli/utils/format";
|
||||
|
||||
export default class EmojiList extends BaseCommand<typeof EmojiList> {
|
||||
public static override args = {};
|
||||
|
||||
public static override description = "List all emojis";
|
||||
|
||||
public static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> --format json --local",
|
||||
"<%= config.bin %> <%= command.id %>",
|
||||
];
|
||||
|
||||
public static override flags = {
|
||||
format: Flags.string({
|
||||
char: "f",
|
||||
description: "Output format",
|
||||
options: ["json", "csv"],
|
||||
}),
|
||||
local: Flags.boolean({
|
||||
char: "l",
|
||||
description: "Local emojis only",
|
||||
exclusive: ["remote"],
|
||||
}),
|
||||
remote: Flags.boolean({
|
||||
char: "r",
|
||||
description: "Remote emojis only",
|
||||
exclusive: ["local"],
|
||||
}),
|
||||
limit: Flags.integer({
|
||||
char: "n",
|
||||
description: "Limit the number of emojis",
|
||||
default: 200,
|
||||
}),
|
||||
username: Flags.string({
|
||||
char: "u",
|
||||
description: "Filter by username",
|
||||
}),
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags } = await this.parse(EmojiList);
|
||||
|
||||
const emojis = await db
|
||||
.select({
|
||||
...getTableColumns(Emojis),
|
||||
instanceUrl: Instances.baseUrl,
|
||||
owner: Users.username,
|
||||
})
|
||||
.from(Emojis)
|
||||
.leftJoin(Instances, eq(Emojis.instanceId, Instances.id))
|
||||
.leftJoin(Users, eq(Emojis.ownerId, Users.id))
|
||||
.where(
|
||||
and(
|
||||
flags.local ? isNull(Emojis.instanceId) : undefined,
|
||||
flags.remote ? isNotNull(Emojis.instanceId) : undefined,
|
||||
flags.username
|
||||
? eq(Users.username, flags.username)
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
|
||||
const keys = [
|
||||
"id",
|
||||
"shortcode",
|
||||
"alt",
|
||||
"contentType",
|
||||
"instanceUrl",
|
||||
"owner",
|
||||
];
|
||||
|
||||
this.log(
|
||||
formatArray(
|
||||
emojis,
|
||||
keys,
|
||||
flags.format as "json" | "csv" | undefined,
|
||||
),
|
||||
);
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import { Args } from "@oclif/core";
|
||||
import { Instance } from "@versia/kit/db";
|
||||
import { Instances } from "@versia/kit/tables";
|
||||
import { eq } from "drizzle-orm";
|
||||
import ora from "ora";
|
||||
import { FetchJobType, fetchQueue } from "~/classes/queues/fetch";
|
||||
import { BaseCommand } from "~/cli/base";
|
||||
|
||||
export default class FederationInstanceRefetch extends BaseCommand<
|
||||
typeof FederationInstanceRefetch
|
||||
> {
|
||||
public static override args = {
|
||||
url: Args.string({
|
||||
description: "URL of the remote instance",
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
public static override description =
|
||||
"Refetches metadata from remote instances";
|
||||
|
||||
public static override examples = ["<%= config.bin %> <%= command.id %>"];
|
||||
|
||||
public static override flags = {};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { args } = await this.parse(FederationInstanceRefetch);
|
||||
|
||||
const spinner = ora("Refetching instance metadata").start();
|
||||
|
||||
const host = new URL(args.url).host;
|
||||
|
||||
const instance = await Instance.fromSql(eq(Instances.baseUrl, host));
|
||||
|
||||
if (!instance) {
|
||||
throw new Error("Instance not found");
|
||||
}
|
||||
|
||||
await fetchQueue.add(FetchJobType.Instance, {
|
||||
uri: args.url,
|
||||
});
|
||||
|
||||
spinner.succeed("Task added to queue");
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { parseUserAddress, userAddressValidator } from "@/api";
|
||||
import { Args } from "@oclif/core";
|
||||
import { Instance, User } from "@versia/kit/db";
|
||||
import chalk from "chalk";
|
||||
import ora from "ora";
|
||||
import { BaseCommand } from "~/cli/base";
|
||||
|
||||
export default class FederationUserFetch extends BaseCommand<
|
||||
typeof FederationUserFetch
|
||||
> {
|
||||
public static override args = {
|
||||
address: Args.string({
|
||||
description: "Address of remote user (name@host.com)",
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
public static override description = "Fetch remote users";
|
||||
|
||||
public static override examples = ["<%= config.bin %> <%= command.id %>"];
|
||||
|
||||
public static override flags = {};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { args } = await this.parse(FederationUserFetch);
|
||||
|
||||
// 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").start();
|
||||
|
||||
const { username, domain: host } = parseUserAddress(args.address);
|
||||
|
||||
if (!host) {
|
||||
this.log("Address must contain a domain.");
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
// Check instance exists, if not, create it
|
||||
await Instance.resolve(new URL(`https://${host}`));
|
||||
|
||||
const manager = await User.getFederationRequester();
|
||||
|
||||
const uri = await User.webFinger(manager, username, host);
|
||||
|
||||
if (!uri) {
|
||||
spinner.fail();
|
||||
this.log(chalk.red("User not found"));
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
const newUser = await User.resolve(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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import { parseUserAddress, userAddressValidator } from "@/api";
|
||||
import { Args } from "@oclif/core";
|
||||
import { Instance, User } from "@versia/kit/db";
|
||||
import chalk from "chalk";
|
||||
import ora from "ora";
|
||||
import { BaseCommand } from "~/cli/base";
|
||||
|
||||
export default class FederationUserFinger extends BaseCommand<
|
||||
typeof FederationUserFinger
|
||||
> {
|
||||
public static override args = {
|
||||
address: Args.string({
|
||||
description: "Address of remote user (name@host.com)",
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
public static override description =
|
||||
"Fetch the URL of remote users via WebFinger";
|
||||
|
||||
public static override examples = ["<%= config.bin %> <%= command.id %>"];
|
||||
|
||||
public 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);
|
||||
|
||||
if (!host) {
|
||||
this.log("Address must contain a domain.");
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
// Check instance exists, if not, create it
|
||||
await Instance.resolve(new URL(`https://${host}`));
|
||||
|
||||
const manager = await User.getFederationRequester();
|
||||
|
||||
const uri = await User.webFinger(manager, username, host);
|
||||
|
||||
spinner.succeed("Fetched user URI");
|
||||
|
||||
this.log(`URI: ${chalk.blueBright(uri)}`);
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { User } from "@versia/kit/db";
|
||||
import chalk from "chalk";
|
||||
import { BaseCommand } from "~/cli/base";
|
||||
|
||||
export default class GenerateKeys extends BaseCommand<typeof GenerateKeys> {
|
||||
public static override args = {};
|
||||
|
||||
public static override description =
|
||||
"Generates keys to use in Versia Server";
|
||||
|
||||
public static override flags = {};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { public_key, private_key } = await User.generateKeys();
|
||||
|
||||
this.log(`Generated public key: ${chalk.gray(public_key)}`);
|
||||
this.log(`Generated private key: ${chalk.gray(private_key)}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
import { Args, Flags } from "@oclif/core";
|
||||
import ora from "ora";
|
||||
import { SonicIndexType, searchManager } from "~/classes/search/search-manager";
|
||||
import { BaseCommand } from "~/cli/base";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
export default class IndexRebuild extends BaseCommand<typeof IndexRebuild> {
|
||||
public static override args = {
|
||||
type: Args.string({
|
||||
description: "Index category to rebuild",
|
||||
options: ["accounts", "statuses"],
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
public static override description = "Rebuild search indexes";
|
||||
|
||||
public static override examples = ["<%= config.bin %> <%= command.id %>"];
|
||||
|
||||
public static override flags = {
|
||||
"batch-size": Flags.integer({
|
||||
char: "b",
|
||||
description: "Number of items to process in each batch",
|
||||
default: 100,
|
||||
}),
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags, args } = await this.parse(IndexRebuild);
|
||||
|
||||
if (!config.search.enabled) {
|
||||
this.error("Search is disabled");
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
const spinner = ora("Rebuilding search indexes").start();
|
||||
|
||||
switch (args.type) {
|
||||
case "accounts":
|
||||
await searchManager.rebuildSearchIndexes(
|
||||
[SonicIndexType.Accounts],
|
||||
flags["batch-size"],
|
||||
(progress) => {
|
||||
spinner.text = `Rebuilding search indexes (${(progress * 100).toFixed(2)}%)`;
|
||||
},
|
||||
);
|
||||
break;
|
||||
case "statuses":
|
||||
await searchManager.rebuildSearchIndexes(
|
||||
[SonicIndexType.Statuses],
|
||||
flags["batch-size"],
|
||||
(progress) => {
|
||||
spinner.text = `Rebuilding search indexes (${(progress * 100).toFixed(2)}%)`;
|
||||
},
|
||||
);
|
||||
break;
|
||||
default: {
|
||||
this.error("Invalid index type");
|
||||
}
|
||||
}
|
||||
|
||||
spinner.succeed("Search indexes rebuilt");
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
import confirm from "@inquirer/confirm";
|
||||
import { Flags } from "@oclif/core";
|
||||
import chalk from "chalk";
|
||||
import { sql } from "drizzle-orm";
|
||||
import ora from "ora";
|
||||
import { Note } from "~/classes/database/note";
|
||||
import { BaseCommand } from "~/cli/base";
|
||||
import { Notes } from "~/drizzle/schema";
|
||||
|
||||
export default class NoteRecalculate extends BaseCommand<
|
||||
typeof NoteRecalculate
|
||||
> {
|
||||
public static override description = "Recalculate all notes";
|
||||
|
||||
public static override examples = ["<%= config.bin %> <%= command.id %>"];
|
||||
|
||||
public static override flags = {
|
||||
confirm: Flags.boolean({
|
||||
description:
|
||||
"Ask for confirmation before the recalculation (default yes)",
|
||||
allowNo: true,
|
||||
default: true,
|
||||
}),
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags } = await this.parse(NoteRecalculate);
|
||||
|
||||
const noteCount = await Note.getCount();
|
||||
|
||||
if (flags.confirm) {
|
||||
const choice = await confirm({
|
||||
message: `Recalculate ${chalk.gray(noteCount)} notes? ${chalk.red(
|
||||
"This might take a while.",
|
||||
)}`,
|
||||
});
|
||||
|
||||
if (!choice) {
|
||||
this.log(chalk.bold(`${chalk.red("✗")} Aborted operation`));
|
||||
return this.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = ora("Recalculating notes").start();
|
||||
let done = false;
|
||||
let count = 0;
|
||||
const pageSize = 100;
|
||||
|
||||
while (done === false) {
|
||||
spinner.text = `Fetching next ${chalk.gray(pageSize)} notes`;
|
||||
|
||||
const notes = await Note.manyFromSql(
|
||||
sql`EXISTS (SELECT 1 FROM "Users" WHERE "Users"."id" = ${Notes.authorId} AND "Users"."instanceId" IS NULL)`,
|
||||
undefined,
|
||||
pageSize,
|
||||
count,
|
||||
);
|
||||
|
||||
for (const note of notes) {
|
||||
spinner.text = `Recalculating note ${chalk.gray(
|
||||
count,
|
||||
)}/${chalk.gray(noteCount)}`;
|
||||
|
||||
await note.updateFromData({
|
||||
author: note.author,
|
||||
content: {
|
||||
[note.data.contentType]: {
|
||||
content: note.data.content,
|
||||
remote: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
if (notes.length < pageSize) {
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
|
||||
spinner.succeed("Recalculated all notes");
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import os from "node:os";
|
||||
import { Flags } from "@oclif/core";
|
||||
import { BaseCommand } from "~/cli/base";
|
||||
|
||||
export default class Start extends BaseCommand<typeof Start> {
|
||||
public static override args = {};
|
||||
|
||||
public static override description = "Starts Versia Server";
|
||||
|
||||
public static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> --threads 4",
|
||||
"<%= config.bin %> <%= command.id %> --all-threads",
|
||||
];
|
||||
|
||||
public static override flags = {
|
||||
threads: Flags.integer({
|
||||
char: "t",
|
||||
description: "Number of threads to use",
|
||||
default: 1,
|
||||
exclusive: ["all-threads"],
|
||||
}),
|
||||
"all-threads": Flags.boolean({
|
||||
description: "Use all available threads",
|
||||
default: false,
|
||||
exclusive: ["threads"],
|
||||
}),
|
||||
silent: Flags.boolean({
|
||||
description: "Don't show logs in console",
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags } = await this.parse(Start);
|
||||
|
||||
const numCpus = flags["all-threads"] ? os.cpus().length : flags.threads;
|
||||
|
||||
process.env.NUM_CPUS = String(numCpus);
|
||||
process.env.SILENT = flags.silent ? "true" : "false";
|
||||
|
||||
await import("~/index");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
import input from "@inquirer/input";
|
||||
import { Args, Flags } from "@oclif/core";
|
||||
import { User } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import chalk from "chalk";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { renderUnicodeCompact } from "uqr";
|
||||
import { BaseCommand } from "~/cli/base";
|
||||
import { formatArray } from "~/cli/utils/format";
|
||||
|
||||
export default class UserCreate extends BaseCommand<typeof UserCreate> {
|
||||
public static override args = {
|
||||
username: Args.string({
|
||||
description: "Username",
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
public static override description = "Creates a new user";
|
||||
|
||||
public static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> johngastron --email joe@gamer.com",
|
||||
"<%= config.bin %> <%= command.id %> bimbobaggins",
|
||||
];
|
||||
|
||||
public static override flags = {
|
||||
format: Flags.string({
|
||||
char: "f",
|
||||
description:
|
||||
"Output format (when set, no password reset link is generated)",
|
||||
options: ["json", "csv"],
|
||||
}),
|
||||
admin: Flags.boolean({
|
||||
char: "a",
|
||||
description: "Admin user",
|
||||
allowNo: true,
|
||||
default: false,
|
||||
}),
|
||||
email: Flags.string({
|
||||
char: "e",
|
||||
description: "Email",
|
||||
}),
|
||||
"verify-email": Flags.boolean({
|
||||
description: "Send email verification",
|
||||
default: true,
|
||||
allowNo: true,
|
||||
}),
|
||||
"set-password": Flags.boolean({
|
||||
description: "Type password instead of getting a reset link",
|
||||
default: false,
|
||||
exclusive: ["format"],
|
||||
}),
|
||||
password: Flags.string({
|
||||
description:
|
||||
"Password. Make sure this isn't saved in the shell history",
|
||||
exclusive: ["set-password"],
|
||||
}),
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags, args } = await this.parse(UserCreate);
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await User.fromSql(
|
||||
eq(Users.username, args.username),
|
||||
);
|
||||
|
||||
if (existingUser) {
|
||||
this.log(
|
||||
`${chalk.red("✗")} User ${chalk.red(
|
||||
args.username,
|
||||
)} already exists`,
|
||||
);
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
let password: string | null = null;
|
||||
|
||||
if (flags["set-password"]) {
|
||||
const password1 = await input({
|
||||
message: "Please enter the user's password:",
|
||||
// Set whatever the user types to stars
|
||||
transformer: (value): string => "*".repeat(value.length),
|
||||
});
|
||||
|
||||
const password2 = await input({
|
||||
message: "Please confirm the user's password:",
|
||||
// Set whatever the user types to stars
|
||||
transformer: (value): string => "*".repeat(value.length),
|
||||
});
|
||||
|
||||
if (password1 !== password2) {
|
||||
this.log(
|
||||
`${chalk.red(
|
||||
"✗",
|
||||
)} Passwords do not match. Please try again.`,
|
||||
);
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
password = password1;
|
||||
}
|
||||
|
||||
if (flags.password) {
|
||||
password = flags.password;
|
||||
}
|
||||
|
||||
// TODO: Add password resets
|
||||
|
||||
const user = await User.fromDataLocal({
|
||||
email: flags.email ?? undefined,
|
||||
password: password ?? undefined,
|
||||
username: args.username,
|
||||
admin: flags.admin,
|
||||
skipPasswordHash: !password,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
this.log(
|
||||
`${chalk.red("✗")} Failed to create user ${chalk.red(
|
||||
args.username,
|
||||
)}`,
|
||||
);
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
!flags.format &&
|
||||
this.log(
|
||||
`${chalk.green("✓")} Created user ${chalk.green(
|
||||
user.data.username,
|
||||
)} with id ${chalk.green(user.id)}`,
|
||||
);
|
||||
|
||||
this.log(
|
||||
formatArray(
|
||||
[user.data],
|
||||
[
|
||||
"id",
|
||||
"username",
|
||||
"displayName",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"isAdmin",
|
||||
],
|
||||
flags.format as "json" | "csv" | undefined,
|
||||
),
|
||||
);
|
||||
|
||||
if (!(flags.format || flags["set-password"] || flags.password)) {
|
||||
const link = "";
|
||||
|
||||
this.log(
|
||||
flags.format
|
||||
? link
|
||||
: `\nPassword reset link for ${chalk.bold(
|
||||
`@${user.data.username}`,
|
||||
)}: ${chalk.underline(chalk.blue(link))}\n`,
|
||||
);
|
||||
|
||||
const qrcode = renderUnicodeCompact(link, {
|
||||
border: 2,
|
||||
});
|
||||
|
||||
// Pad all lines of QR code with spaces
|
||||
|
||||
this.log(` ${qrcode.replaceAll("\n", "\n ")}`);
|
||||
}
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import confirm from "@inquirer/confirm";
|
||||
import { Flags } from "@oclif/core";
|
||||
import chalk from "chalk";
|
||||
import ora from "ora";
|
||||
import { UserFinderCommand } from "~/cli/classes";
|
||||
import { formatArray } from "~/cli/utils/format";
|
||||
|
||||
export default class UserDelete extends UserFinderCommand<typeof UserDelete> {
|
||||
public static override description = "Deletes users";
|
||||
|
||||
public static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> johngastron --type username",
|
||||
"<%= config.bin %> <%= command.id %> 018ec11c-c6cb-7a67-bd20-a4c81bf42912",
|
||||
'<%= config.bin %> <%= command.id %> "*badword*" --pattern --type username',
|
||||
];
|
||||
|
||||
public static override flags = {
|
||||
confirm: Flags.boolean({
|
||||
description:
|
||||
"Ask for confirmation before deleting the user (default yes)",
|
||||
allowNo: true,
|
||||
default: true,
|
||||
}),
|
||||
};
|
||||
|
||||
public static override args = {
|
||||
identifier: UserFinderCommand.baseArgs.identifier,
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags } = await this.parse(UserDelete);
|
||||
|
||||
const users = await this.findUsers();
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
this.log(chalk.bold(`${chalk.red("✗")} No users found`));
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
// Display user
|
||||
flags.print &&
|
||||
this.log(
|
||||
chalk.bold(
|
||||
`${chalk.green("✓")} Found ${chalk.green(
|
||||
users.length,
|
||||
)} user(s)`,
|
||||
),
|
||||
);
|
||||
|
||||
flags.print &&
|
||||
this.log(
|
||||
formatArray(
|
||||
users.map((u) => u.data),
|
||||
[
|
||||
"id",
|
||||
"username",
|
||||
"displayName",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"isAdmin",
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (flags.confirm) {
|
||||
const choice = await confirm({
|
||||
message: `Are you sure you want to delete these users? ${chalk.red(
|
||||
"This is irreversible.",
|
||||
)}`,
|
||||
});
|
||||
|
||||
if (!choice) {
|
||||
this.log(chalk.bold(`${chalk.red("✗")} Aborted operation`));
|
||||
return this.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = ora("Deleting user(s)").start();
|
||||
|
||||
for (const user of users) {
|
||||
await user.delete();
|
||||
}
|
||||
|
||||
spinner.succeed();
|
||||
|
||||
this.log(chalk.bold(`${chalk.green("✓")} User(s) deleted`));
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
import { Flags } from "@oclif/core";
|
||||
import { User } from "@versia/kit/db";
|
||||
import { Users } from "@versia/kit/tables";
|
||||
import { and, eq, isNotNull, isNull } from "drizzle-orm";
|
||||
import { BaseCommand } from "~/cli/base";
|
||||
import { formatArray } from "~/cli/utils/format";
|
||||
|
||||
export default class UserList extends BaseCommand<typeof UserList> {
|
||||
public static override args = {};
|
||||
|
||||
public static override description = "List all users";
|
||||
|
||||
public static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> --format json --local",
|
||||
"<%= config.bin %> <%= command.id %>",
|
||||
];
|
||||
|
||||
public static override flags = {
|
||||
format: Flags.string({
|
||||
char: "f",
|
||||
description: "Output format",
|
||||
options: ["json", "csv"],
|
||||
exclusive: ["pretty-dates"],
|
||||
}),
|
||||
local: Flags.boolean({
|
||||
char: "l",
|
||||
description: "Local users only",
|
||||
exclusive: ["remote"],
|
||||
}),
|
||||
remote: Flags.boolean({
|
||||
char: "r",
|
||||
description: "Remote users only",
|
||||
exclusive: ["local"],
|
||||
}),
|
||||
limit: Flags.integer({
|
||||
char: "n",
|
||||
description: "Limit the number of users",
|
||||
default: 200,
|
||||
}),
|
||||
admin: Flags.boolean({
|
||||
char: "a",
|
||||
description: "Admin users only",
|
||||
allowNo: true,
|
||||
}),
|
||||
"pretty-dates": Flags.boolean({
|
||||
char: "p",
|
||||
description: "Pretty print dates",
|
||||
}),
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags } = await this.parse(UserList);
|
||||
|
||||
const users = await User.manyFromSql(
|
||||
and(
|
||||
flags.local ? isNull(Users.instanceId) : undefined,
|
||||
flags.remote ? isNotNull(Users.instanceId) : undefined,
|
||||
flags.admin ? eq(Users.isAdmin, flags.admin) : undefined,
|
||||
),
|
||||
undefined,
|
||||
flags.limit,
|
||||
);
|
||||
|
||||
const keys = [
|
||||
"id",
|
||||
"username",
|
||||
"displayName",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"isAdmin",
|
||||
];
|
||||
|
||||
this.log(
|
||||
formatArray(
|
||||
users.map((u) => u.data),
|
||||
keys,
|
||||
flags.format as "json" | "csv" | undefined,
|
||||
flags["pretty-dates"],
|
||||
),
|
||||
);
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import confirm from "@inquirer/confirm";
|
||||
import { Flags } from "@oclif/core";
|
||||
import chalk from "chalk";
|
||||
import ora from "ora";
|
||||
import { UserFinderCommand } from "~/cli/classes";
|
||||
import { formatArray } from "~/cli/utils/format";
|
||||
|
||||
export default class UserRefetch extends UserFinderCommand<typeof UserRefetch> {
|
||||
public static override description = "Refetch remote users";
|
||||
|
||||
public static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> johngastron --type username",
|
||||
"<%= config.bin %> <%= command.id %> 018ec11c-c6cb-7a67-bd20-a4c81bf42912",
|
||||
];
|
||||
|
||||
public static override flags = {
|
||||
confirm: Flags.boolean({
|
||||
description:
|
||||
"Ask for confirmation before refetching the user (default yes)",
|
||||
allowNo: true,
|
||||
default: true,
|
||||
}),
|
||||
limit: Flags.integer({
|
||||
char: "n",
|
||||
description: "Limit the number of users",
|
||||
default: 1,
|
||||
}),
|
||||
};
|
||||
|
||||
public static override args = {
|
||||
identifier: UserFinderCommand.baseArgs.identifier,
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags } = await this.parse(UserRefetch);
|
||||
|
||||
const users = await this.findUsers();
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
this.log(chalk.bold(`${chalk.red("✗")} No users found`));
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
// Display user
|
||||
flags.print &&
|
||||
this.log(
|
||||
chalk.bold(
|
||||
`${chalk.green("✓")} Found ${chalk.green(
|
||||
users.length,
|
||||
)} user(s)`,
|
||||
),
|
||||
);
|
||||
|
||||
flags.print &&
|
||||
this.log(
|
||||
formatArray(
|
||||
users.map((u) => u.data),
|
||||
[
|
||||
"id",
|
||||
"username",
|
||||
"displayName",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"isAdmin",
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (flags.confirm && !flags.print) {
|
||||
const choice = await confirm({
|
||||
message: `Refetch these users? ${chalk.red(
|
||||
"This is irreversible.",
|
||||
)}`,
|
||||
});
|
||||
|
||||
if (!choice) {
|
||||
this.log(chalk.bold(`${chalk.red("✗")} Aborted operation`));
|
||||
return this.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const spinner = ora("Refetching users").start();
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
spinner.text = `Refetching user ${user.data.username}`;
|
||||
await user.updateFromRemote();
|
||||
} catch (error) {
|
||||
this.log(
|
||||
chalk.bold(
|
||||
`${chalk.red("✗")} Failed to refetch user ${
|
||||
user.data.username
|
||||
}`,
|
||||
),
|
||||
);
|
||||
this.log(chalk.red((error as Error).message));
|
||||
}
|
||||
}
|
||||
|
||||
spinner.succeed("Refetched users");
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
import confirm from "@inquirer/confirm";
|
||||
import { Flags } from "@oclif/core";
|
||||
import chalk from "chalk";
|
||||
import { renderUnicodeCompact } from "uqr";
|
||||
import { UserFinderCommand } from "~/cli/classes";
|
||||
import { formatArray } from "~/cli/utils/format";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
export default class UserReset extends UserFinderCommand<typeof UserReset> {
|
||||
public static override description = "Resets users' passwords";
|
||||
|
||||
public static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> johngastron --type username",
|
||||
"<%= config.bin %> <%= command.id %> 018ec11c-c6cb-7a67-bd20-a4c81bf42912",
|
||||
];
|
||||
|
||||
public static override flags = {
|
||||
confirm: Flags.boolean({
|
||||
description:
|
||||
"Ask for confirmation before deleting the user (default yes)",
|
||||
allowNo: true,
|
||||
default: true,
|
||||
}),
|
||||
limit: Flags.integer({
|
||||
char: "n",
|
||||
description: "Limit the number of users",
|
||||
default: 1,
|
||||
}),
|
||||
raw: Flags.boolean({
|
||||
description:
|
||||
"Only output the password reset link (implies --no-print and --no-confirm)",
|
||||
}),
|
||||
};
|
||||
|
||||
public static override args = {
|
||||
identifier: UserFinderCommand.baseArgs.identifier,
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags } = await this.parse(UserReset);
|
||||
|
||||
const users = await this.findUsers();
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
this.log(chalk.bold(`${chalk.red("✗")} No users found`));
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
// Display user
|
||||
!flags.raw &&
|
||||
this.log(
|
||||
chalk.bold(
|
||||
`${chalk.green("✓")} Found ${chalk.green(
|
||||
users.length,
|
||||
)} user(s)`,
|
||||
),
|
||||
);
|
||||
|
||||
!flags.raw &&
|
||||
flags.print &&
|
||||
this.log(
|
||||
formatArray(
|
||||
users.map((u) => u.data),
|
||||
[
|
||||
"id",
|
||||
"username",
|
||||
"displayName",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"isAdmin",
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (flags.confirm && !flags.raw) {
|
||||
const choice = await confirm({
|
||||
message: `Reset these users's passwords? ${chalk.red(
|
||||
"This is irreversible.",
|
||||
)}`,
|
||||
});
|
||||
|
||||
if (!choice) {
|
||||
this.log(chalk.bold(`${chalk.red("✗")} Aborted operation`));
|
||||
return this.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const user of users) {
|
||||
const token = await user.resetPassword();
|
||||
|
||||
const link = new URL(
|
||||
`${config.frontend.routes.password_reset}?${new URLSearchParams(
|
||||
{
|
||||
token,
|
||||
},
|
||||
).toString()}`,
|
||||
config.http.base_url,
|
||||
).toString();
|
||||
|
||||
!flags.raw &&
|
||||
this.log(
|
||||
`${chalk.green("✓")} Password reset for ${
|
||||
users.length
|
||||
} user(s)`,
|
||||
);
|
||||
|
||||
this.log(
|
||||
flags.raw
|
||||
? link
|
||||
: `\nPassword reset link for ${chalk.bold(
|
||||
`@${user.data.username}`,
|
||||
)}: ${chalk.underline(chalk.blue(link))}\n`,
|
||||
);
|
||||
|
||||
const qrcode = renderUnicodeCompact(link, {
|
||||
border: 2,
|
||||
});
|
||||
|
||||
// Pad all lines of QR code with spaces
|
||||
|
||||
!flags.raw && this.log(` ${qrcode.replaceAll("\n", "\n ")}`);
|
||||
}
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
import { randomString } from "@/math";
|
||||
import { Flags } from "@oclif/core";
|
||||
import chalk from "chalk";
|
||||
import ora from "ora";
|
||||
import { Token } from "~/classes/database/token";
|
||||
import { UserFinderCommand } from "~/cli/classes";
|
||||
import { formatArray } from "~/cli/utils/format";
|
||||
|
||||
export default class UserToken extends UserFinderCommand<typeof UserToken> {
|
||||
public static override description = "Generates access tokens for users";
|
||||
|
||||
public static override examples = [
|
||||
"<%= config.bin %> <%= command.id %> johngastron --type username",
|
||||
"<%= config.bin %> <%= command.id %> 018ec11c-c6cb-7a67-bd20-a4c81bf42912",
|
||||
'<%= config.bin %> <%= command.id %> "*badword*" --pattern --type username',
|
||||
];
|
||||
|
||||
public static override flags = {
|
||||
format: Flags.string({
|
||||
char: "f",
|
||||
description: "Output format",
|
||||
options: ["json", "csv"],
|
||||
exclusive: ["pretty-dates"],
|
||||
}),
|
||||
limit: Flags.integer({
|
||||
char: "n",
|
||||
description: "Limit the number of users",
|
||||
default: 200,
|
||||
}),
|
||||
};
|
||||
|
||||
public static override args = {
|
||||
identifier: UserFinderCommand.baseArgs.identifier,
|
||||
};
|
||||
|
||||
public async run(): Promise<void> {
|
||||
const { flags } = await this.parse(UserToken);
|
||||
|
||||
const foundUsers = await this.findUsers();
|
||||
const users = foundUsers.filter((u) => u.isLocal());
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
this.log(chalk.bold(`${chalk.red("✗")} No users found`));
|
||||
|
||||
if (foundUsers.length > 0) {
|
||||
this.log(
|
||||
chalk.bold(
|
||||
`${chalk.yellow("✗")} Found ${chalk.yellow(
|
||||
foundUsers.length,
|
||||
)} user(s) but none are local`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
this.exit(1);
|
||||
}
|
||||
|
||||
// Display user
|
||||
flags.print &&
|
||||
this.log(
|
||||
chalk.bold(
|
||||
`${chalk.green("✓")} Found ${chalk.green(
|
||||
users.length,
|
||||
)} user(s)`,
|
||||
),
|
||||
);
|
||||
|
||||
flags.print &&
|
||||
this.log(
|
||||
formatArray(
|
||||
users.map((u) => u.data),
|
||||
[
|
||||
"id",
|
||||
"username",
|
||||
"displayName",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"isAdmin",
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
const spinner = ora("Generating access tokens").start();
|
||||
const tokens = await Promise.all(
|
||||
users.map(
|
||||
async (u) =>
|
||||
await Token.insert({
|
||||
accessToken: randomString(64, "base64url"),
|
||||
code: null,
|
||||
scope: "read write follow",
|
||||
tokenType: "Bearer",
|
||||
userId: u.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
spinner.succeed();
|
||||
|
||||
this.log(chalk.bold(`${chalk.green("✓")} Tokens generated`));
|
||||
|
||||
this.log(
|
||||
formatArray(
|
||||
tokens.map((t) => t.data),
|
||||
["accessToken", "userId"],
|
||||
flags.format as "json" | "csv" | undefined,
|
||||
),
|
||||
);
|
||||
|
||||
this.exit(0);
|
||||
}
|
||||
}
|
||||
69
cli/index.ts
69
cli/index.ts
|
|
@ -1,37 +1,36 @@
|
|||
import { configureLoggers } from "@/loggers";
|
||||
import { execute } from "@oclif/core";
|
||||
import Start from "./commands/start.ts";
|
||||
import { completionsPlugin } from "@clerc/plugin-completions";
|
||||
import { friendlyErrorPlugin } from "@clerc/plugin-friendly-error";
|
||||
import { helpPlugin } from "@clerc/plugin-help";
|
||||
import { notFoundPlugin } from "@clerc/plugin-not-found";
|
||||
import { versionPlugin } from "@clerc/plugin-version";
|
||||
import { Clerc } from "clerc";
|
||||
import { searchManager } from "~/classes/search/search-manager.ts";
|
||||
import { setupDatabase } from "~/drizzle/db.ts";
|
||||
import pkg from "~/package.json";
|
||||
import { rebuildIndexCommand } from "./index/rebuild.ts";
|
||||
import { refetchInstanceCommand } from "./instance/refetch.ts";
|
||||
import { createUserCommand } from "./user/create.ts";
|
||||
import { deleteUserCommand } from "./user/delete.ts";
|
||||
import { refetchUserCommand } from "./user/refetch.ts";
|
||||
import { generateTokenCommand } from "./user/token.ts";
|
||||
|
||||
await configureLoggers();
|
||||
await setupDatabase(false);
|
||||
await searchManager.connect(true);
|
||||
|
||||
// Use "explicit" oclif strategy to avoid issues with oclif's module resolver and bundling
|
||||
export const commands = {
|
||||
"user:list": (await import("./commands/user/list.ts")).default,
|
||||
"user:delete": (await import("./commands/user/delete.ts")).default,
|
||||
"user:create": (await import("./commands/user/create.ts")).default,
|
||||
"user:reset": (await import("./commands/user/reset.ts")).default,
|
||||
"user:refetch": (await import("./commands/user/refetch.ts")).default,
|
||||
"user:token": (await import("./commands/user/token.ts")).default,
|
||||
"emoji:add": (await import("./commands/emoji/add.ts")).default,
|
||||
"emoji:delete": (await import("./commands/emoji/delete.ts")).default,
|
||||
"emoji:list": (await import("./commands/emoji/list.ts")).default,
|
||||
"emoji:import": (await import("./commands/emoji/import.ts")).default,
|
||||
"index:rebuild": (await import("./commands/index/rebuild.ts")).default,
|
||||
"federation:instance:refetch": (
|
||||
await import("./commands/federation/instance/refetch.ts")
|
||||
).default,
|
||||
"federation:user:finger": (
|
||||
await import("./commands/federation/user/finger.ts")
|
||||
).default,
|
||||
"federation:user:fetch": (
|
||||
await import("./commands/federation/user/fetch.ts")
|
||||
).default,
|
||||
"generate-keys": (await import("./commands/generate-keys.ts")).default,
|
||||
start: Start,
|
||||
"notes:recalculate": (await import("./commands/notes/recalculate.ts"))
|
||||
.default,
|
||||
};
|
||||
|
||||
if (import.meta.path === Bun.main) {
|
||||
await execute({ dir: import.meta.url });
|
||||
}
|
||||
Clerc.create()
|
||||
.scriptName("cli")
|
||||
.name("Versia Server CLI")
|
||||
.description("CLI interface for Versia Server")
|
||||
.version(pkg.version)
|
||||
.use(helpPlugin())
|
||||
.use(versionPlugin())
|
||||
.use(completionsPlugin())
|
||||
.use(notFoundPlugin())
|
||||
.use(friendlyErrorPlugin())
|
||||
.command(createUserCommand)
|
||||
.command(deleteUserCommand)
|
||||
.command(generateTokenCommand)
|
||||
.command(refetchUserCommand)
|
||||
.command(rebuildIndexCommand)
|
||||
.command(refetchInstanceCommand)
|
||||
.parse();
|
||||
|
|
|
|||
65
cli/index/rebuild.ts
Normal file
65
cli/index/rebuild.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { type Root, defineCommand } from "clerc";
|
||||
import ora from "ora";
|
||||
import {
|
||||
SonicIndexType,
|
||||
searchManager,
|
||||
} from "~/classes/search/search-manager.ts";
|
||||
import { config } from "~/config.ts";
|
||||
|
||||
export const rebuildIndexCommand = defineCommand(
|
||||
{
|
||||
name: "index rebuild",
|
||||
description: "Rebuild the search index.",
|
||||
parameters: ["<type>"],
|
||||
flags: {
|
||||
"batch-size": {
|
||||
description: "Number of records to process at once",
|
||||
type: Number,
|
||||
alias: "b",
|
||||
default: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (context) => {
|
||||
const { "batch-size": batchSize } = context.flags;
|
||||
const { type } = context.parameters;
|
||||
|
||||
if (!config.search.enabled) {
|
||||
throw new Error(
|
||||
"Search is not enabled in the instance configuration.",
|
||||
);
|
||||
}
|
||||
|
||||
const spinner = ora("Rebuilding search indexes").start();
|
||||
|
||||
switch (type) {
|
||||
case "accounts":
|
||||
await searchManager.rebuildSearchIndexes(
|
||||
[SonicIndexType.Accounts],
|
||||
batchSize,
|
||||
(progress) => {
|
||||
spinner.text = `Rebuilding search indexes (${(progress * 100).toFixed(2)}%)`;
|
||||
},
|
||||
);
|
||||
break;
|
||||
case "statuses":
|
||||
await searchManager.rebuildSearchIndexes(
|
||||
[SonicIndexType.Statuses],
|
||||
batchSize,
|
||||
(progress) => {
|
||||
spinner.text = `Rebuilding search indexes (${(progress * 100).toFixed(2)}%)`;
|
||||
},
|
||||
);
|
||||
break;
|
||||
default: {
|
||||
throw new Error(
|
||||
"Invalid index type. Can be 'accounts' or 'statuses'.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
spinner.succeed("Search indexes rebuilt");
|
||||
},
|
||||
);
|
||||
37
cli/instance/refetch.ts
Normal file
37
cli/instance/refetch.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import chalk from "chalk";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { type Root, defineCommand } from "clerc";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Instance } from "~/classes/database/instance.ts";
|
||||
import { FetchJobType, fetchQueue } from "~/classes/queues/fetch.ts";
|
||||
import { Instances } from "~/drizzle/schema.ts";
|
||||
|
||||
export const refetchInstanceCommand = defineCommand(
|
||||
{
|
||||
name: "instance refetch",
|
||||
description: "Refetches metadata from remote instances.",
|
||||
parameters: ["<url_or_host>"],
|
||||
},
|
||||
async (context) => {
|
||||
const { urlOrHost } = context.parameters;
|
||||
|
||||
const host = URL.canParse(urlOrHost)
|
||||
? new URL(urlOrHost).host
|
||||
: urlOrHost;
|
||||
|
||||
const instance = await Instance.fromSql(eq(Instances.baseUrl, host));
|
||||
|
||||
if (!instance) {
|
||||
throw new Error(`Instance ${chalk.gray(host)} not found.`);
|
||||
}
|
||||
|
||||
await fetchQueue.add(FetchJobType.Instance, {
|
||||
uri: new URL(`https://${instance.data.baseUrl}`).origin,
|
||||
});
|
||||
|
||||
console.info(
|
||||
`Refresh job enqueued for ${chalk.gray(instance.data.baseUrl)}.`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"bin": "white",
|
||||
"command": "cyan",
|
||||
"commandSummary": "white",
|
||||
"dollarSign": "white",
|
||||
"flag": "white",
|
||||
"flagDefaultValue": "blue",
|
||||
"flagOptions": "green",
|
||||
"flagRequired": "red",
|
||||
"flagSeparator": "white",
|
||||
"sectionDescription": "white",
|
||||
"sectionHeader": "underline",
|
||||
"topic": "white",
|
||||
"version": "green"
|
||||
}
|
||||
87
cli/user/create.ts
Normal file
87
cli/user/create.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import chalk from "chalk";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { type Root, defineCommand } from "clerc";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { renderUnicodeCompact } from "uqr";
|
||||
import { User } from "~/classes/database/user";
|
||||
import { config } from "~/config";
|
||||
import { Users } from "~/drizzle/schema";
|
||||
|
||||
export const createUserCommand = defineCommand(
|
||||
{
|
||||
name: "user create",
|
||||
description: "Create a new user.",
|
||||
parameters: ["<username>"],
|
||||
flags: {
|
||||
password: {
|
||||
description: "Password for the new user",
|
||||
type: String,
|
||||
alias: "p",
|
||||
},
|
||||
email: {
|
||||
description: "Email for the new user",
|
||||
type: String,
|
||||
alias: "e",
|
||||
},
|
||||
admin: {
|
||||
description: "Make the new user an admin",
|
||||
type: Boolean,
|
||||
alias: "a",
|
||||
},
|
||||
},
|
||||
},
|
||||
async (context) => {
|
||||
const { admin, email, password } = context.flags;
|
||||
const { username } = context.parameters;
|
||||
|
||||
if (!/^[a-z0-9_-]+$/.test(username)) {
|
||||
throw new Error("Username must be alphanumeric and lowercase.");
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await User.fromSql(
|
||||
and(eq(Users.username, username), isNull(Users.instanceId)),
|
||||
);
|
||||
|
||||
if (existingUser) {
|
||||
throw new Error(`User ${chalk.gray(username)} is taken.`);
|
||||
}
|
||||
|
||||
const user = await User.fromDataLocal({
|
||||
email,
|
||||
password,
|
||||
username,
|
||||
admin,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Failed to create user.");
|
||||
}
|
||||
|
||||
console.info(`User ${chalk.gray(username)} created.`);
|
||||
|
||||
if (!password) {
|
||||
const token = await user.resetPassword();
|
||||
|
||||
const link = new URL(
|
||||
`${config.frontend.routes.password_reset}?${new URLSearchParams(
|
||||
{
|
||||
token,
|
||||
},
|
||||
)}`,
|
||||
config.http.base_url,
|
||||
);
|
||||
|
||||
console.info(`Password reset link for ${chalk.gray(username)}:`);
|
||||
console.info(chalk.blue(link.href));
|
||||
|
||||
const qrcode = renderUnicodeCompact(link.href, {
|
||||
border: 2,
|
||||
});
|
||||
|
||||
// Pad all lines of QR code with spaces
|
||||
console.info(`\n ${qrcode.replaceAll("\n", "\n ")}`);
|
||||
}
|
||||
},
|
||||
);
|
||||
60
cli/user/delete.ts
Normal file
60
cli/user/delete.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import confirm from "@inquirer/confirm";
|
||||
import chalk from "chalk";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { type Root, defineCommand } from "clerc";
|
||||
import { retrieveUser } from "../utils.ts";
|
||||
|
||||
export const deleteUserCommand = defineCommand(
|
||||
{
|
||||
name: "user delete",
|
||||
alias: "user rm",
|
||||
description:
|
||||
"Delete a user from the database. Can use username or handle.",
|
||||
parameters: ["<username_or_handle>"],
|
||||
flags: {
|
||||
confirm: {
|
||||
description: "Ask for confirmation before deleting the user",
|
||||
type: Boolean,
|
||||
alias: "c",
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (context) => {
|
||||
const { confirm: confirmFlag } = context.flags;
|
||||
const { usernameOrHandle } = context.parameters;
|
||||
|
||||
const user = await retrieveUser(usernameOrHandle);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`User ${chalk.gray(usernameOrHandle)} not found.`);
|
||||
}
|
||||
|
||||
console.info(`About to delete user ${chalk.gray(user.data.username)}!`);
|
||||
console.info(`Username: ${chalk.blue(user.data.username)}`);
|
||||
console.info(`Display Name: ${chalk.blue(user.data.displayName)}`);
|
||||
console.info(`Created At: ${chalk.blue(user.data.createdAt)}`);
|
||||
console.info(
|
||||
`Instance: ${chalk.blue(user.data.instance?.baseUrl || "Local")}`,
|
||||
);
|
||||
|
||||
if (confirmFlag) {
|
||||
const choice = await confirm({
|
||||
message: `Are you sure you want to delete this user? ${chalk.red(
|
||||
"This is irreversible.",
|
||||
)}`,
|
||||
});
|
||||
|
||||
if (!choice) {
|
||||
throw new Error("Operation aborted.");
|
||||
}
|
||||
}
|
||||
|
||||
await user.delete();
|
||||
|
||||
console.info(
|
||||
`User ${chalk.gray(user.data.username)} has been deleted.`,
|
||||
);
|
||||
},
|
||||
);
|
||||
42
cli/user/refetch.ts
Normal file
42
cli/user/refetch.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import chalk from "chalk";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { type Root, defineCommand } from "clerc";
|
||||
import ora from "ora";
|
||||
import { retrieveUser } from "../utils.ts";
|
||||
|
||||
export const refetchUserCommand = defineCommand(
|
||||
{
|
||||
name: "user refetch",
|
||||
description: "Refetches user data from their remote instance.",
|
||||
parameters: ["<handle>"],
|
||||
},
|
||||
async (context) => {
|
||||
const { handle } = context.parameters;
|
||||
|
||||
const user = await retrieveUser(handle);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`User ${chalk.gray(handle)} not found.`);
|
||||
}
|
||||
|
||||
if (user.isLocal()) {
|
||||
throw new Error(
|
||||
"This user is local and as such cannot be refetched.",
|
||||
);
|
||||
}
|
||||
|
||||
const spinner = ora("Refetching user").start();
|
||||
|
||||
try {
|
||||
await user.updateFromRemote();
|
||||
} catch (error) {
|
||||
spinner.fail(
|
||||
`Failed to refetch user ${chalk.gray(user.data.username)}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
spinner.succeed(`User ${chalk.gray(user.data.username)} refetched.`);
|
||||
},
|
||||
);
|
||||
37
cli/user/token.ts
Normal file
37
cli/user/token.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { randomString } from "@/math.ts";
|
||||
import chalk from "chalk";
|
||||
// @ts-expect-error - Root import is required or the Clec type definitions won't work
|
||||
// biome-ignore lint/correctness/noUnusedImports: Root import is required or the Clec type definitions won't work
|
||||
import { type Root, defineCommand } from "clerc";
|
||||
import { Token } from "~/classes/database/token.ts";
|
||||
import { retrieveUser } from "../utils.ts";
|
||||
|
||||
export const generateTokenCommand = defineCommand(
|
||||
{
|
||||
name: "user token",
|
||||
description: "Generates a new access token for a user.",
|
||||
parameters: ["<username>"],
|
||||
},
|
||||
async (context) => {
|
||||
const { username } = context.parameters;
|
||||
|
||||
const user = await retrieveUser(username);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`User ${chalk.gray(username)} not found.`);
|
||||
}
|
||||
|
||||
const token = await Token.insert({
|
||||
accessToken: randomString(64, "base64url"),
|
||||
code: null,
|
||||
scope: "read write follow",
|
||||
tokenType: "Bearer",
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
console.info(
|
||||
`Token generated for user ${chalk.gray(user.data.username)}.`,
|
||||
);
|
||||
console.info(`Access Token: ${chalk.blue(token.data.accessToken)}`);
|
||||
},
|
||||
);
|
||||
24
cli/utils.ts
Normal file
24
cli/utils.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { parseUserAddress } from "@/api";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { Instance } from "~/classes/database/instance";
|
||||
import { User } from "~/classes/database/user";
|
||||
import { Users } from "~/drizzle/schema";
|
||||
|
||||
export const retrieveUser = async (
|
||||
usernameOrHandle: string,
|
||||
): Promise<User | null> => {
|
||||
const { username, domain } = parseUserAddress(usernameOrHandle);
|
||||
|
||||
const instance = domain ? await Instance.resolveFromHost(domain) : null;
|
||||
|
||||
const user = await User.fromSql(
|
||||
and(
|
||||
eq(Users.username, username),
|
||||
instance
|
||||
? eq(Users.instanceId, instance.data.id)
|
||||
: isNull(Users.instanceId),
|
||||
),
|
||||
);
|
||||
|
||||
return user;
|
||||
};
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import chalk from "chalk";
|
||||
import { getBorderCharacters, table } from "table";
|
||||
|
||||
/**
|
||||
* Given a JS array, return a string output to be passed to console.log
|
||||
* @param arr The array to be formatted
|
||||
* @param keys The keys to be displayed (removes all other keys from the output)
|
||||
* @param type Either "json", "csv" or nothing for a table
|
||||
* @returns The formatted string
|
||||
*/
|
||||
export const formatArray = (
|
||||
arr: Record<string, unknown>[],
|
||||
keys: string[],
|
||||
type?: "json" | "csv",
|
||||
prettyDates = false,
|
||||
): string => {
|
||||
const output = arr.map((item) => {
|
||||
const newItem = {} as Record<string, unknown>;
|
||||
|
||||
for (const key of keys) {
|
||||
newItem[key] = item[key];
|
||||
}
|
||||
|
||||
return newItem;
|
||||
});
|
||||
|
||||
if (prettyDates) {
|
||||
for (const item of output) {
|
||||
for (const key of keys) {
|
||||
const value = item[key];
|
||||
// If this is an ISO string, convert it to a nice date
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
value.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}$/)
|
||||
) {
|
||||
item[key] = Intl.DateTimeFormat(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).format(new Date(value));
|
||||
// Format using Chalk
|
||||
item[key] = chalk.underline(item[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "json":
|
||||
return JSON.stringify(output, null, 2);
|
||||
case "csv":
|
||||
return `${keys.join(",")}\n${output
|
||||
.map((item) => keys.map((key) => item[key]).join(","))
|
||||
.join("\n")}`;
|
||||
default:
|
||||
// Convert output to array of arrays for table
|
||||
return table(
|
||||
[
|
||||
keys.map((k) => chalk.bold(k)),
|
||||
...output.map((item) => keys.map((key) => item[key])),
|
||||
],
|
||||
{
|
||||
border: getBorderCharacters("norc"),
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
33
package.json
33
package.json
|
|
@ -52,27 +52,6 @@
|
|||
"msgpackr-extract",
|
||||
"sharp"
|
||||
],
|
||||
"oclif": {
|
||||
"bin": "cli",
|
||||
"dirname": "cli",
|
||||
"commands": {
|
||||
"strategy": "explicit",
|
||||
"target": "./cli/index",
|
||||
"identifier": "commands"
|
||||
},
|
||||
"additionalHelpFlags": ["-h"],
|
||||
"additionalVersionFlags": ["-v"],
|
||||
"plugins": [],
|
||||
"description": "CLI to interface with the Versia project",
|
||||
"topicSeparator": " ",
|
||||
"topics": {
|
||||
"user": {
|
||||
"description": "Manage users"
|
||||
}
|
||||
},
|
||||
"theme": "./cli/theme.json",
|
||||
"flexibleTaxonomy": true
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@types/bun": "^1.2.3",
|
||||
|
|
@ -87,7 +66,6 @@
|
|||
"drizzle-kit": "^0.30.5",
|
||||
"markdown-it-image-figures": "^2.1.1",
|
||||
"markdown-it-mathjax3": "^4.3.2",
|
||||
"oclif": "^4.17.32",
|
||||
"ts-prune": "^0.10.3",
|
||||
"typescript": "^5.7.3",
|
||||
"vitepress": "^1.6.3",
|
||||
|
|
@ -102,6 +80,11 @@
|
|||
"dependencies": {
|
||||
"@bull-board/api": "^6.7.9",
|
||||
"@bull-board/hono": "^6.7.9",
|
||||
"@clerc/plugin-completions": "^0.44.0",
|
||||
"@clerc/plugin-friendly-error": "^0.44.0",
|
||||
"@clerc/plugin-help": "^0.44.0",
|
||||
"@clerc/plugin-not-found": "^0.44.0",
|
||||
"@clerc/plugin-version": "^0.44.0",
|
||||
"@hackmd/markdown-it-task-lists": "^2.1.4",
|
||||
"@hono/prometheus": "^1.0.1",
|
||||
"@hono/swagger-ui": "^0.5.0",
|
||||
|
|
@ -111,7 +94,6 @@
|
|||
"@inquirer/input": "^4.1.6",
|
||||
"@logtape/file": "^0.9.0-dev.134+9f8d1ac0",
|
||||
"@logtape/logtape": "^0.9.0-dev.134",
|
||||
"@oclif/core": "^4.2.8",
|
||||
"@sentry/bun": "^9.2.0",
|
||||
"@versia/client": "^0.1.5",
|
||||
"@versia/federation": "^0.2.1",
|
||||
|
|
@ -121,11 +103,11 @@
|
|||
"bullmq": "^5.41.5",
|
||||
"c12": "^3.0.0",
|
||||
"chalk": "^5.4.1",
|
||||
"clerc": "^0.44.0",
|
||||
"cli-progress": "^3.12.0",
|
||||
"cli-table": "^0.3.11",
|
||||
"confbox": "^0.1.8",
|
||||
"drizzle-orm": "^0.40.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"hono": "^4.7.2",
|
||||
"html-to-text": "^9.0.5",
|
||||
"ioredis": "^5.5.0",
|
||||
|
|
@ -152,7 +134,6 @@
|
|||
"stringify-entities": "^4.0.4",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"table": "^6.9.0",
|
||||
"unzipit": "^1.4.3",
|
||||
"uqr": "^0.1.2",
|
||||
"web-push": "^3.6.7",
|
||||
"xss": "^1.0.15",
|
||||
|
|
@ -163,6 +144,6 @@
|
|||
"zod": "^3.24.1"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@bull-board/api@6.7.7": "patches/@bull-board%2Fapi@6.5.3.patch"
|
||||
"@bull-board/api@6.7.9": "patches/@bull-board%2Fapi@6.5.3.patch"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue