Files
TallyNote/server/cli/admin-init.ts
T
Qiufeng 344985f514
TallyNote release / linux-x64 (push) Successful in 6m26s
release: 1.1.12
2026-09-02 20:23:46 +08:00

179 lines
6.9 KiB
TypeScript

import { stdin as input, stdout as output } from "node:process";
import { randomUUID } from "node:crypto";
import { StringDecoder } from "node:string_decoder";
import { openDatabase, openDatabaseReadOnly } from "../db/index.js";
import { acquireInstanceLock, loadConfig, prepareDataDirectories } from "../config.js";
import { hashPassword, normalizeUsername, validateNewPassword, temporaryPassword } from "../security.js";
import { writeAudit } from "../audit.js";
function arg(name: string): string | undefined {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
// A terminal paste can contain more than one line. Keep the unread tail for
// the next prompt instead of silently discarding credentials after the first
// newline.
let pendingInput = "";
let pendingSkipLf = false;
async function readSecret(prompt: string): Promise<string> {
if (!input.isTTY) throw new Error("admin:init 需要交互式 TTY,不能通过管道传入密码");
output.write(prompt);
return await new Promise<string>((resolve, reject) => {
let value = "";
let escapeSequence = false;
let cleaned = false;
const decoder = new StringDecoder("utf8");
const wasRaw = Boolean(input.isRaw);
const initialInput = pendingInput;
pendingInput = "";
let onData: (chunk: Buffer | string) => void;
let onSignal: () => void;
const cleanup = () => {
if (cleaned) return;
cleaned = true;
input.off("data", onData);
input.off("error", onInputError);
process.off("SIGINT", onSignal);
process.off("SIGTERM", onSignal);
input.setRawMode?.(wasRaw);
input.pause();
};
const finish = (error?: Error) => {
cleanup();
if (error) reject(error);
else {
output.write("\n");
resolve(value);
}
};
const onInputError = (error: Error) => finish(error);
onSignal = () => finish(new Error("已取消"));
const consume = (text: string) => {
let offset = 0;
for (const character of text) {
offset += character.length;
if (pendingSkipLf) {
if (character === "\n") {
pendingSkipLf = false;
continue;
}
pendingSkipLf = false;
}
if (character === "\u0003") {
finish(new Error("已取消"));
return;
}
if (escapeSequence) {
if (/[A-Za-z~]/.test(character)) escapeSequence = false;
continue;
}
if (character === "\u001b") {
escapeSequence = true;
} else if (character === "\r" || character === "\n") {
const tail = text.slice(offset);
pendingInput = tail.startsWith("\n") && character === "\r" ? tail.slice(1) : tail;
pendingSkipLf = character === "\r" && !tail.startsWith("\n");
finish();
return;
} else if (character === "\u007f" || character === "\b") {
value = value.slice(0, -1);
} else {
value += character;
}
}
};
onData = (chunk) => {
consume(typeof chunk === "string" ? chunk : decoder.write(chunk));
};
input.resume();
input.setRawMode?.(true);
process.once("SIGINT", onSignal);
process.once("SIGTERM", onSignal);
input.once("error", onInputError);
input.on("data", onData);
if (initialInput) consume(initialInput);
});
}
async function main() {
const config = loadConfig();
const checkOnly = process.argv.includes("--check");
if (checkOnly) {
let database;
try {
database = openDatabaseReadOnly(config);
} catch (error) {
if (error && typeof error === "object" && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") {
console.log("empty");
return;
}
throw error;
}
try {
const hasAdminsTable = database.sqlite
.prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'admins'")
.get();
const existing = hasAdminsTable
? database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins").get() as { count: number }
: { count: 0 };
console.log(existing.count > 0 ? "initialized" : "empty");
} finally {
database.sqlite.close();
}
return;
}
prepareDataDirectories(config);
const release = acquireInstanceLock(config);
const database = openDatabase(config);
try {
const existing = database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins").get() as { count: number };
if (existing.count > 0) throw new Error("INITIAL_ADMIN_EXISTS:管理员已经初始化");
const username = arg("--username") ?? (await readSecret("用户名: "));
const displayName = arg("--display-name") ?? (await readSecret("显示名称: "));
const generate = process.argv.includes("--generate");
let password = generate ? temporaryPassword() : await readSecret("密码(至少 12 个字符): ");
if (!generate) {
const confirmation = await readSecret("再次输入密码: ");
if (password !== confirmation) throw new Error("两次密码输入不一致");
}
const policyError = validateNewPassword(password);
if (policyError) throw new Error(policyError);
const normalized = normalizeUsername(username);
if ([...normalized].length < 3) throw new Error("用户名至少需要 3 个字符");
if ([...normalized].length > 64) throw new Error("用户名最多 64 个字符");
const normalizedDisplayName = displayName.normalize("NFKC").trim();
if ([...normalizedDisplayName].length < 1 || [...normalizedDisplayName].length > 80) throw new Error("显示名称必须为 1-80 个字符");
const passwordHash = await hashPassword(password);
const id = randomUUID();
const now = Date.now();
database.sqlite.transaction(() => {
const current = database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins").get() as { count: number };
if (current.count > 0) throw new Error("INITIAL_ADMIN_EXISTS:管理员已经初始化");
database.sqlite.prepare(`
INSERT INTO admins(id, username, username_norm, display_name, password_hash, status,
must_change_password, auth_version, version, created_at)
VALUES (?, ?, ?, ?, ?, 'active', 1, 1, 1, ?)
`).run(id, username.normalize("NFKC").trim(), normalized, normalizedDisplayName, passwordHash, now);
writeAudit(database.sqlite, {
requestId: `cli:${randomUUID()}`,
actorUsername: "cli",
action: "admin.initialized",
targetType: "admin",
targetId: id,
after: { username: normalized, displayName: normalizedDisplayName, status: "active" },
});
})();
console.log(generate ? `已创建首位管理员。一次性密码:${password}` : "已创建首位管理员。");
} finally {
database.sqlite.close();
release();
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});