Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab2d24a5c7 | ||
|
|
a070ad0434 | ||
|
|
3ab3e5e180 | ||
|
|
6c96cddd4e | ||
|
|
efbd0e0d87 |
@@ -42,7 +42,7 @@ pnpm build:next
|
||||
|
||||
`build:next` 与 `pnpm build` 一样输出到 `dist/web`,可直接由生产 Fastify 服务提供。
|
||||
|
||||
本地开发首次初始化管理员使用 `pnpm admin:init`。生产安装器会在首次安装时提供管理员初始化向导;如果选择稍后创建,执行 `sudo tallynote-admin-init` 即可。也可以使用 `sudo tallynote-admin-init --username admin --display-name 管理员 --generate` 生成一次性临时密码。
|
||||
本地开发首次初始化管理员使用 `pnpm admin:init`。生产安装器会在首次安装时提供管理员初始化向导;如果选择稍后创建,执行 `sudo /usr/local/sbin/tallynote-admin-init` 即可。也可以使用 `sudo /usr/local/sbin/tallynote-admin-init --username admin --display-name 管理员 --generate` 生成一次性临时密码。
|
||||
|
||||
默认地址为 `http://127.0.0.1:3000`,开发界面为 `http://127.0.0.1:5173`。配置项见 `.env.example`。
|
||||
|
||||
@@ -62,7 +62,13 @@ curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/ra
|
||||
curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
首次安装完成网络配置后,向导会询问是否立即创建管理员。选择创建时,用户名、显示名称和密码都在当前 SSH 终端中输入;选择稍后创建也不会阻塞服务启动,之后执行 `sudo tallynote-admin-init` 即可。升级已有安装时,向导会自动识别现有管理员并跳过创建,不会覆盖账号或账目。
|
||||
首次安装完成网络配置后,向导会询问是否立即创建管理员。选择创建时,用户名、显示名称和密码都在当前 SSH 终端中输入,并会直接回显当前输入内容;密码不会写入安装日志、配置文件或命令行参数。选择稍后创建也不会阻塞服务启动,之后执行 `sudo /usr/local/sbin/tallynote-admin-init` 即可。升级已有安装时,向导会自动识别现有管理员并跳过创建,不会覆盖账号或账目。
|
||||
|
||||
如果账号是在旧版本中用正式密码创建、但仍被标记为“首次登录需要修改密码”,可以在服务器上用当前密码修复标志位(不会更换密码):
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/tallynote-admin-init --mark-password-configured --username <用户名>
|
||||
```
|
||||
|
||||
监听方式有两个选项:`127.0.0.1` 仅本机访问(默认、更安全),或 `0.0.0.0` 允许通过局域网/公网 IP 访问。安装时可输入自定义端口(直接回车使用默认端口),安装器会检查 TCP 端口是否已被占用;选择 `0.0.0.0` 时会尝试通过 HTTPS 自动获取公网 IPv4,并将 `http://公网IP:端口` 作为默认访问地址,也可以改填域名。不能填写 `http://0.0.0.0:3000`。直连 HTTP 未加密,安装器会要求明确确认,只适合受控网络。绑定域名后应改为 HTTPS 反向代理,设置真实的 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true`、`TALLYNOTE_ALLOW_INSECURE_HTTP=false`,然后执行 `sudo systemctl restart tallynote.service`。服务启动后,安装器会先请求本机 `/health`;只有健康检查通过才会报告安装完成并输出最终访问链接。监听 `127.0.0.1` 时该链接只对服务器本机有效;需要公网或其他设备访问时请选择 `0.0.0.0`。健康检查失败时会输出 systemd 状态和最近日志并回滚本次切换。
|
||||
|
||||
|
||||
+9
-7
@@ -230,26 +230,26 @@ run_initial_admin_wizard() {
|
||||
return 0
|
||||
fi
|
||||
if (( NON_INTERACTIVE )); then
|
||||
log '非交互模式:跳过管理员初始化;稍后可执行 sudo tallynote-admin-init'
|
||||
log "非交互模式:跳过管理员初始化;稍后可执行 sudo $ADMIN_INIT_PATH"
|
||||
return 0
|
||||
fi
|
||||
[[ -r "$PROMPT_INPUT" && -w "$PROMPT_OUTPUT" ]] || {
|
||||
log '未检测到交互式终端:跳过管理员初始化;稍后可执行 sudo tallynote-admin-init'
|
||||
log "未检测到交互式终端:跳过管理员初始化;稍后可执行 sudo $ADMIN_INIT_PATH"
|
||||
return 0
|
||||
}
|
||||
[[ -x "$ADMIN_INIT_PATH" ]] || die '管理员初始化命令未安装'
|
||||
|
||||
local status choice
|
||||
if ! status=$("$ADMIN_INIT_PATH" --check 2>/dev/null); then
|
||||
log '无法检查管理员初始化状态;基础安装已完成,稍后可执行 sudo tallynote-admin-init'
|
||||
log "无法检查管理员初始化状态;基础安装已完成,稍后可执行 sudo $ADMIN_INIT_PATH"
|
||||
return 0
|
||||
fi
|
||||
[[ "$status" == empty ]] || return 0
|
||||
|
||||
exec 9<"$PROMPT_INPUT" || die '无法打开终端输入;请稍后执行 sudo tallynote-admin-init'
|
||||
exec 9<"$PROMPT_INPUT" || die "无法打开终端输入;请稍后执行 sudo $ADMIN_INIT_PATH"
|
||||
{
|
||||
printf '\n首次安装还差一步:请创建管理员账号。\n'
|
||||
printf '管理员账号用于登录 TallyNote,首次登录后需要设置正式密码。\n'
|
||||
printf '管理员账号用于登录 TallyNote;这里输入的密码会直接作为正式密码。\n'
|
||||
} > "$PROMPT_OUTPUT"
|
||||
while :; do
|
||||
prompt_value '现在创建管理员?输入 yes 继续,其他内容稍后创建' 'yes'
|
||||
@@ -258,7 +258,7 @@ run_initial_admin_wizard() {
|
||||
yes|YES|Yes|y|Y) break ;;
|
||||
no|NO|No|n|N|'')
|
||||
exec 9<&-
|
||||
log '已跳过管理员初始化;稍后可执行 sudo tallynote-admin-init'
|
||||
log "已跳过管理员初始化;稍后可执行 sudo $ADMIN_INIT_PATH"
|
||||
return 0
|
||||
;;
|
||||
*) printf '请输入 yes 或 no。\n' > "$PROMPT_OUTPUT" ;;
|
||||
@@ -267,7 +267,7 @@ run_initial_admin_wizard() {
|
||||
stage '创建首位管理员(密码不会写入安装日志)'
|
||||
if ! "$ADMIN_INIT_PATH" <&9 > "$PROMPT_OUTPUT"; then
|
||||
exec 9<&-
|
||||
log '管理员初始化未完成;基础安装已完成,稍后可执行 sudo tallynote-admin-init'
|
||||
log "管理员初始化未完成;基础安装已完成,稍后可执行 sudo $ADMIN_INIT_PATH"
|
||||
return 0
|
||||
fi
|
||||
exec 9<&-
|
||||
@@ -1538,5 +1538,7 @@ main() {
|
||||
fi
|
||||
log "访问地址:$access_url"
|
||||
log '查看服务状态:systemctl status tallynote.service'
|
||||
log "管理员初始化命令:sudo $ADMIN_INIT_PATH"
|
||||
log '如 sudo 找不到该命令,请使用上面输出的绝对路径'
|
||||
}
|
||||
main "$@"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tallynote",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
|
||||
@@ -54,9 +54,11 @@ import {
|
||||
validateNewPassword,
|
||||
verifyPassword,
|
||||
} from "./security.js";
|
||||
import { isNewerVersion } from "./update.js";
|
||||
import {
|
||||
ACTIVE_UPDATE_STATUSES,
|
||||
checkForUpdate,
|
||||
currentReleaseVersion,
|
||||
publicCheckFromCache,
|
||||
publicUpdateJob,
|
||||
reconcileOrphanedUpdateJobs,
|
||||
@@ -1012,6 +1014,15 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
const stagedJobId = input.jobId;
|
||||
const staged = database.sqlite.prepare("SELECT id, status, operation, version, asset_url AS assetUrl, asset_name AS assetName, expected_sha256 AS expectedSha256 FROM update_jobs WHERE id=? AND admin_id=?").get(stagedJobId, request.auth!.admin.id) as { id: string; status: string; operation: string; version: string; assetUrl: string; assetName: string | null; expectedSha256: string | null } | undefined;
|
||||
if (!staged || staged.status !== "staged" || staged.version !== input.version.replace(/^v/i, "")) throw new AppError(409, "UPDATE_NOT_STAGED", "更新任务尚未完成下载");
|
||||
// A package may have been downloaded before the host was upgraded by
|
||||
// another path. Never apply a staged archive that is no longer newer
|
||||
// than the release currently serving traffic.
|
||||
const effectiveCurrentVersion = currentReleaseVersion(config) ?? config.appVersion;
|
||||
if (!isNewerVersion(effectiveCurrentVersion, staged.version)) {
|
||||
const now = Date.now();
|
||||
database.sqlite.prepare("UPDATE update_jobs SET status='failed', error_message=?, completed_at=?, updated_at=? WHERE id=? AND status='staged'").run("暂存更新已过期,当前版本无需再次升级", now, now, stagedJobId);
|
||||
throw new AppError(409, "UPDATE_NOT_AVAILABLE", "暂存更新已过期,请重新检查更新");
|
||||
}
|
||||
if (staged.operation === "apply") throw new AppError(409, "UPDATE_IN_PROGRESS", "更新任务正在处理中,请稍候");
|
||||
enforceUpdateCooldown(database.sqlite, config, request.auth!.admin.id, "apply", reply);
|
||||
const now = Date.now();
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { hashPassword, normalizeUsername, validateNewPassword, temporaryPassword, verifyPassword } from "../security.js";
|
||||
import { writeAudit } from "../audit.js";
|
||||
|
||||
function arg(name: string): string | undefined {
|
||||
@@ -79,8 +79,13 @@ async function readSecret(prompt: string): Promise<string> {
|
||||
return;
|
||||
} else if (character === "\u007f" || character === "\b") {
|
||||
value = value.slice(0, -1);
|
||||
// Keep the credential visible in the SSH terminal as requested.
|
||||
// Redraw the current line so backspace behaves predictably without
|
||||
// putting the value into logs or command arguments.
|
||||
output.write("\r\u001b[2K" + prompt + value);
|
||||
} else {
|
||||
value += character;
|
||||
output.write(character);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -128,6 +133,39 @@ async function main() {
|
||||
const release = acquireInstanceLock(config);
|
||||
const database = openDatabase(config);
|
||||
try {
|
||||
const markPasswordConfigured = process.argv.includes("--mark-password-configured");
|
||||
if (markPasswordConfigured) {
|
||||
const username = arg("--username") ?? (await readSecret("用户名: "));
|
||||
const password = await readSecret("当前密码: ");
|
||||
const normalized = normalizeUsername(username);
|
||||
const admin = database.sqlite.prepare(
|
||||
"SELECT id, password_hash, must_change_password, version FROM admins WHERE username_norm = ?",
|
||||
).get(normalized) as { id: string; password_hash: string; must_change_password: number; version: number } | undefined;
|
||||
if (!admin || !(await verifyPassword(admin.password_hash, password))) {
|
||||
throw new Error("用户名或当前密码不正确");
|
||||
}
|
||||
if (!admin.must_change_password) {
|
||||
console.log("该管理员已经可以直接使用当前密码登录。");
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
database.sqlite.transaction(() => {
|
||||
const result = database.sqlite.prepare(
|
||||
"UPDATE admins SET must_change_password=0, auth_version=auth_version+1, version=version+1 WHERE id=? AND version=?",
|
||||
).run(admin.id, admin.version);
|
||||
if (result.changes !== 1) throw new Error("管理员资料已被其他操作更新,请重试");
|
||||
writeAudit(database.sqlite, {
|
||||
requestId: `cli:${randomUUID()}`,
|
||||
actorUsername: "cli",
|
||||
action: "admin.password_policy_cleared",
|
||||
targetType: "admin",
|
||||
targetId: admin.id,
|
||||
after: { username: normalized, mustChangePassword: false, changedAt: now },
|
||||
});
|
||||
})();
|
||||
console.log("已确认当前密码为正式密码,后续登录不再要求修改密码。");
|
||||
return;
|
||||
}
|
||||
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("用户名: "));
|
||||
@@ -154,8 +192,16 @@ async function main() {
|
||||
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);
|
||||
VALUES (?, ?, ?, ?, ?, 'active', ?, 1, 1, ?)
|
||||
`).run(
|
||||
id,
|
||||
username.normalize("NFKC").trim(),
|
||||
normalized,
|
||||
normalizedDisplayName,
|
||||
passwordHash,
|
||||
generate ? 1 : 0,
|
||||
now,
|
||||
);
|
||||
writeAudit(database.sqlite, {
|
||||
requestId: `cli:${randomUUID()}`,
|
||||
actorUsername: "cli",
|
||||
|
||||
@@ -439,7 +439,7 @@ function recoveryStateJobId(filePath: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function currentReleaseVersion(config: AppConfig): string | null {
|
||||
export function currentReleaseVersion(config: AppConfig): string | null {
|
||||
try {
|
||||
const target = realpathSync(config.currentLink);
|
||||
const releases = realpathSync(config.releasesDir);
|
||||
@@ -487,12 +487,48 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
|
||||
let reconciled = 0;
|
||||
const reconciledIds = new Set<string>();
|
||||
for (const row of rows) {
|
||||
// A fresh request/state marker means the privileged runner still owns the
|
||||
// hand-off. Do not expire a staged/apply row while the runner is finishing
|
||||
// a successful switch and finalization after a service restart.
|
||||
const matchingFreshRequest = requestMarkerJobId === row.id && requestFresh;
|
||||
const matchingFreshState = stateMarkerJobId === row.id && stateFresh;
|
||||
// A staged archive is actionable only while it is strictly newer than the
|
||||
// release currently serving requests. This can become false when an
|
||||
// administrator upgrades the host by another path (or another operator
|
||||
// completes the same release) before returning to this page. Treat the
|
||||
// archive as an expired terminal task so it cannot keep blocking the
|
||||
// queue or appear as an "apply" action for the current version.
|
||||
const effectiveCurrentVersion = releaseVersion ?? config.appVersion;
|
||||
if (row.status === "staged" && !isNewerVersion(effectiveCurrentVersion, row.version) && !matchingFreshRequest && !matchingFreshState) {
|
||||
const changed = database.transaction(() => {
|
||||
const result = database.prepare(`
|
||||
UPDATE update_jobs
|
||||
SET status='failed', error_message=?, completed_at=?, updated_at=?
|
||||
WHERE id=? AND status='staged'
|
||||
`).run("暂存更新已过期,当前版本无需再次升级", now, now, row.id);
|
||||
if (result.changes !== 1) return false;
|
||||
writeAudit(database, {
|
||||
requestId: row.requestId || randomUUID(),
|
||||
actorAdminId: row.adminId,
|
||||
action: "update.reconciled",
|
||||
targetType: "update",
|
||||
targetId: row.id,
|
||||
outcome: "failure",
|
||||
before: { status: row.status, operation: row.operation, version: row.version },
|
||||
after: { status: "failed", version: row.version, reason: "staged_version_not_newer" },
|
||||
});
|
||||
return true;
|
||||
})();
|
||||
if (changed) {
|
||||
reconciled += 1;
|
||||
reconciledIds.add(row.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A request that never gets claimed by the root runner must not remain in
|
||||
// the UI as an endless "queued" task. Once the short hand-off window has
|
||||
// elapsed and no recovery marker exists, release the queue explicitly;
|
||||
// a fresh state marker proves that the runner has already claimed it.
|
||||
const matchingFreshRequest = requestMarkerJobId === row.id && requestFresh;
|
||||
const matchingFreshState = stateMarkerJobId === row.id && stateFresh;
|
||||
if (row.status === "queued" && typeof row.updatedAt === "number" && !matchingFreshState && now - row.updatedAt >= QUEUED_UPDATE_TIMEOUT_MS) {
|
||||
if (matchingFreshRequest) continue;
|
||||
const changed = database.transaction(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -8,6 +8,9 @@ import Database from "better-sqlite3";
|
||||
const root = path.resolve(process.cwd());
|
||||
const cli = path.join(root, "server", "cli", "admin-init.ts");
|
||||
const tsx = path.join(root, "node_modules", "tsx", "dist", "cli.mjs");
|
||||
const ptyHelper = path.join(root, "tests", "helpers", "pty-run.py");
|
||||
const hasPython3 = spawnSync("python3", ["--version"]).status === 0;
|
||||
const ttyTest = hasPython3 ? it : it.skip;
|
||||
|
||||
function runAdmin(dataDir: string, args: string[]) {
|
||||
return spawnSync(process.execPath, [tsx, cli, ...args], {
|
||||
@@ -24,6 +27,42 @@ function runAdmin(dataDir: string, args: string[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function testEnv(dataDir: string) {
|
||||
return {
|
||||
...process.env,
|
||||
NODE_ENV: "test",
|
||||
TALLYNOTE_DATA_DIR: dataDir,
|
||||
TALLYNOTE_PUBLIC_ORIGIN: "http://127.0.0.1:3999",
|
||||
TALLYNOTE_COOKIE_SECURE: "false",
|
||||
TALLYNOTE_UPDATE_STRATEGY: "disabled",
|
||||
};
|
||||
}
|
||||
|
||||
// The CI runner has no `expect` binary. Drive the interactive CLI through a
|
||||
// real pseudo-terminal via a tiny Python pty helper (python3 ships on both
|
||||
// macOS and the Linux CI image). This avoids `expect` (not installed on CI)
|
||||
// and BSD `script` (injects a stray EOT byte from file input, corrupting the
|
||||
// first prompt value). If python3 is unavailable the tests are skipped rather
|
||||
// than failing the build.
|
||||
function runAdminTTY(dataDir: string, args: string[], inputText: string) {
|
||||
const parent = mkdtempSync(path.join(tmpdir(), "tallynote-admin-tty-"));
|
||||
const inputFile = path.join(parent, "input");
|
||||
const exitFile = path.join(parent, "exit-code");
|
||||
writeFileSync(inputFile, inputText);
|
||||
try {
|
||||
const result = spawnSync("python3", [ptyHelper, process.execPath, tsx, cli, ...args], {
|
||||
cwd: root,
|
||||
env: { ...testEnv(dataDir), PTY_STDIN_FILE: inputFile, PTY_EXIT_FILE: exitFile },
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
});
|
||||
const exitCode = existsSync(exitFile) ? Number(readFileSync(exitFile, "utf8")) : null;
|
||||
return { exitCode, output: `${result.stdout}${result.stderr}`, spawnError: result.error };
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("生产管理员初始化 CLI", () => {
|
||||
it("--check 是只读的,空数据目录不会被创建", () => {
|
||||
const parent = mkdtempSync(path.join(tmpdir(), "tallynote-admin-check-"));
|
||||
@@ -85,6 +124,46 @@ describe("生产管理员初始化 CLI", () => {
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
ttyTest("交互式输入正式密码后不会强制首次改密", () => {
|
||||
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-"));
|
||||
try {
|
||||
const result = runAdminTTY(dataDir, [], "manual-admin\n手动管理员\nStrong-password-2026!\nStrong-password-2026!\n");
|
||||
expect(result.spawnError).toBeUndefined();
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toContain("已创建首位管理员");
|
||||
expect(result.output).toContain("Strong-password-2026!");
|
||||
|
||||
const database = new Database(path.join(dataDir, "tallynote.db"));
|
||||
const admin = database.prepare("SELECT username, must_change_password FROM admins").get() as { username: string; must_change_password: number };
|
||||
expect(admin).toEqual({ username: "manual-admin", must_change_password: 0 });
|
||||
database.close();
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
ttyTest("可以验证当前密码并清除旧版本遗留的首次改密标志", () => {
|
||||
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-"));
|
||||
try {
|
||||
const first = runAdmin(dataDir, ["--username", "legacy-admin", "--display-name", "旧版管理员", "--generate"]);
|
||||
expect(first.status).toBe(0);
|
||||
const generated = first.stdout.match(/一次性密码:([^\s]+)/)?.[1];
|
||||
expect(generated).toBeTruthy();
|
||||
|
||||
const result = runAdminTTY(dataDir, ["--mark-password-configured", "--username", "legacy-admin"], `${generated}\n`);
|
||||
expect(result.spawnError).toBeUndefined();
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.output).toContain("已确认当前密码为正式密码");
|
||||
|
||||
const database = new Database(path.join(dataDir, "tallynote.db"));
|
||||
const admin = database.prepare("SELECT must_change_password FROM admins WHERE username_norm='legacy-admin'").get() as { must_change_password: number };
|
||||
expect(admin.must_change_password).toBe(0);
|
||||
database.close();
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("密码输入不是 TTY 时明确拒绝通过管道传入", () => {
|
||||
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-"));
|
||||
try {
|
||||
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal cross-platform pty driver for the admin-init CLI tests.
|
||||
|
||||
Forks a child on a real pseudo-terminal so the CLI sees a TTY and runs its
|
||||
raw-mode password prompts. Forwards a prepared input file to the child's stdin
|
||||
and copies child output to stdout. Writes the child's exit code to a file so
|
||||
the Node test can read it deterministically.
|
||||
|
||||
Used instead of `expect` (not installed on CI) or BSD `script` (injects a stray
|
||||
EOT byte when stdin is a regular file, corrupting the first prompt value).
|
||||
"""
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import sys
|
||||
|
||||
argv = sys.argv[1:]
|
||||
exit_file = os.environ.get("PTY_EXIT_FILE", "")
|
||||
stdin_file = os.environ.get("PTY_STDIN_FILE", "")
|
||||
|
||||
pid, master = pty.fork()
|
||||
if pid == 0:
|
||||
# Child: replace with the target command. argv[0] is an absolute node path.
|
||||
os.execvp(argv[0], argv)
|
||||
os._exit(127)
|
||||
|
||||
in_fd = os.open(stdin_file, os.O_RDONLY) if stdin_file else -1
|
||||
open_stdin = in_fd >= 0
|
||||
try:
|
||||
while True:
|
||||
fds = [master]
|
||||
if open_stdin:
|
||||
fds.append(in_fd)
|
||||
try:
|
||||
readable, _, _ = select.select(fds, [], [], 30.0)
|
||||
except (OSError, ValueError):
|
||||
break
|
||||
if not readable:
|
||||
break
|
||||
if master in readable:
|
||||
try:
|
||||
data = os.read(master, 4096)
|
||||
except OSError:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
os.write(1, data)
|
||||
if open_stdin and in_fd in readable:
|
||||
data = os.read(in_fd, 4096)
|
||||
if data:
|
||||
os.write(master, data)
|
||||
else:
|
||||
open_stdin = False
|
||||
os.close(in_fd)
|
||||
finally:
|
||||
try:
|
||||
_, status = os.waitpid(pid, 0)
|
||||
except ChildProcessError:
|
||||
status = 0
|
||||
code = os.waitstatus_to_exitcode(status) if hasattr(os, "waitstatus_to_exitcode") else (status >> 8)
|
||||
if exit_file:
|
||||
try:
|
||||
with open(exit_file, "w") as handle:
|
||||
handle.write(str(code))
|
||||
except OSError:
|
||||
pass
|
||||
@@ -155,6 +155,53 @@ describe("更新 API", () => {
|
||||
expect(checked.json().latest).toMatchObject({ version: "1.3.0", isNewer: true });
|
||||
});
|
||||
|
||||
it("不会应用已经等于当前版本的暂存更新", async () => {
|
||||
const session = await login("update-staged-current");
|
||||
const admin = database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-staged-current") as { id: string };
|
||||
const now = Date.now();
|
||||
const stagedId = randomUUID();
|
||||
database.sqlite.prepare(`
|
||||
INSERT INTO update_jobs(
|
||||
id, admin_id, operation, status, version, platform, release_url,
|
||||
asset_name, asset_url, expected_sha256, actual_sha256, download_path,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, 'download', 'staged', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
stagedId,
|
||||
admin.id,
|
||||
config.appVersion,
|
||||
detectPlatform().target,
|
||||
config.updateMetadataUrl,
|
||||
"current.tar.gz",
|
||||
"https://updates.example/current.tar.gz",
|
||||
"c".repeat(64),
|
||||
"c".repeat(64),
|
||||
path.join(config.dataDir, "staged-current"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
const apply = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/update/apply",
|
||||
headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf },
|
||||
payload: { jobId: stagedId, version: config.appVersion, confirm: true },
|
||||
});
|
||||
expect(apply.statusCode).toBe(409);
|
||||
// Reconciliation expires same-version staged jobs before the apply route
|
||||
// can consume them, so the public response is the generic not-staged
|
||||
// conflict while the database records the precise expiry reason.
|
||||
expect(apply.json().error.code).toBe("UPDATE_NOT_STAGED");
|
||||
expect(database.sqlite.prepare("SELECT status, error_message AS errorMessage FROM update_jobs WHERE id=?").get(stagedId)).toEqual({
|
||||
status: "failed",
|
||||
errorMessage: "暂存更新已过期,当前版本无需再次升级",
|
||||
});
|
||||
|
||||
const status = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
|
||||
expect(status.statusCode).toBe(200);
|
||||
expect(status.json().job).toBeNull();
|
||||
});
|
||||
|
||||
it("更新任务只对发起管理员可见,并隐藏内部错误详情", async () => {
|
||||
const owner = await login("update-owner");
|
||||
const other = await login("update-other");
|
||||
|
||||
@@ -32,9 +32,9 @@ function App() {
|
||||
const logoutInFlight = useRef(false);
|
||||
useDialogAccessibility();
|
||||
const notify = useCallback((message: string, kind: "success" | "error" | "info" = "info") => {
|
||||
// The placement container owns the responsive right inset. Keeping the
|
||||
// item offset at zero avoids pushing narrow-screen notices off canvas.
|
||||
const options = { content: message, duration: 4200, placement: "top-right" as const, offset: [24, 76] as [number, number], zIndex: 6000 };
|
||||
// Keep notices in the lower-right safe area so they do not compete with
|
||||
// the header controls or obscure the page title.
|
||||
const options = { content: message, duration: 4200, placement: "bottom-right" as const, offset: [24, 24] as [number, number], zIndex: 6000 };
|
||||
const show = kind === "success" ? NotificationPlugin.success : kind === "error" ? NotificationPlugin.error : NotificationPlugin.info;
|
||||
void show(options);
|
||||
}, []);
|
||||
|
||||
@@ -276,8 +276,8 @@ export default function UpdatePage({
|
||||
});
|
||||
};
|
||||
|
||||
const load = async (showLoading = true) => {
|
||||
if (loadInFlight.current) return;
|
||||
const load = async (showLoading = true): Promise<UpdateInfo | null> => {
|
||||
if (loadInFlight.current) return null;
|
||||
loadInFlight.current = true;
|
||||
if (showLoading) setLoading(true);
|
||||
setError("");
|
||||
@@ -285,18 +285,16 @@ export default function UpdatePage({
|
||||
const res = await api<UpdateInfo>("/api/update/status");
|
||||
mergeInfo(res);
|
||||
updateInfoCache = res;
|
||||
return res;
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
return null;
|
||||
} finally {
|
||||
if (showLoading) setLoading(false);
|
||||
loadInFlight.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
// Keep terminal jobs visible so operators can understand what happened and
|
||||
// recover without guessing. The polling effect below only polls active jobs.
|
||||
const job = info?.job ?? null;
|
||||
@@ -453,7 +451,16 @@ export default function UpdatePage({
|
||||
setError("");
|
||||
try {
|
||||
const result = await api<UpdateInfo>("/api/update/check", { method: "POST" });
|
||||
mergeInfo(result, true);
|
||||
// The check endpoint returns release metadata but not task state. Read
|
||||
// the status endpoint once more so reconciliation performed before the
|
||||
// check is authoritative: an expired staged task must disappear from
|
||||
// this page immediately instead of surviving until a full refresh.
|
||||
const status = await api<UpdateInfo>("/api/update/status");
|
||||
setLiveInfo((current) => ({
|
||||
...(current ?? result),
|
||||
...result,
|
||||
job: status.job,
|
||||
}));
|
||||
notify?.("版本检查完成", "info");
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 429) {
|
||||
@@ -467,6 +474,24 @@ export default function UpdatePage({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
const bootstrap = async () => {
|
||||
const snapshot = await load();
|
||||
if (disposed || !snapshot) return;
|
||||
// Status may be satisfied from the release cache. Refresh it on entry
|
||||
// only when the cached result is absent or older than one minute; this
|
||||
// keeps the page current without turning navigation into a burst of
|
||||
// rate-limited checks.
|
||||
const checkedAt = snapshot.checkedAt || 0;
|
||||
if (!checkedAt || !snapshot.latest || Date.now() - checkedAt > 60_000) await check();
|
||||
};
|
||||
void bootstrap();
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Cancel queued job handler
|
||||
const cancelJob = async () => {
|
||||
if (cancelInFlight.current || !job?.id) return;
|
||||
@@ -539,9 +564,20 @@ export default function UpdatePage({
|
||||
|
||||
const latest = info?.latest;
|
||||
const notes = notesFor(latest);
|
||||
const hasChecked = Boolean(info?.checkedAt);
|
||||
const releaseState = checking
|
||||
? "checking"
|
||||
: !hasChecked
|
||||
? "unverified"
|
||||
: !latest
|
||||
? "unavailable"
|
||||
: latest.isNewer
|
||||
? latest.compatible && latest.integrityReady ? "available" : "blocked"
|
||||
: "up-to-date";
|
||||
|
||||
const canDownload = Boolean(
|
||||
info?.strategy === "systemd" &&
|
||||
!checking &&
|
||||
latest?.isNewer &&
|
||||
latest.compatible &&
|
||||
latest.integrityReady &&
|
||||
@@ -550,14 +586,19 @@ export default function UpdatePage({
|
||||
|
||||
const canApply = Boolean(
|
||||
info?.strategy === "systemd" &&
|
||||
!checking &&
|
||||
job &&
|
||||
job.status === "staged" &&
|
||||
job.operation === "download"
|
||||
job.operation === "download" &&
|
||||
latest?.isNewer &&
|
||||
latest.version === job.version &&
|
||||
job.version !== info.currentVersion
|
||||
);
|
||||
|
||||
const hasActiveJob = Boolean(
|
||||
job && activeStatuses.has(job.status) && job.status !== "staged"
|
||||
job && activeStatuses.has(job.status) && !(job.status === "staged" && job.operation === "download")
|
||||
);
|
||||
const showJobDetails = hasActiveJob || canApply || Boolean(job?.status === "staged" && job.operation === "apply");
|
||||
|
||||
// Compute current pipeline step index (0: check, 1: download, 2: verify/stage, 3: apply/restart)
|
||||
const currentStep = useMemo(() => {
|
||||
@@ -614,7 +655,7 @@ export default function UpdatePage({
|
||||
subtitle="管理系统版本升级、更新包完整性校验与安全热重启"
|
||||
actions={
|
||||
<div className="tn-update-page-actions">
|
||||
{hasActiveJob && (
|
||||
{showJobDetails && (
|
||||
<Button
|
||||
theme="primary"
|
||||
variant="base"
|
||||
@@ -685,10 +726,18 @@ export default function UpdatePage({
|
||||
<span className="tn-metric-label">当前运行版本</span>
|
||||
<div className="tn-metric-value">v{info.currentVersion}</div>
|
||||
<div className="tn-metric-foot">
|
||||
{latest?.isNewer ? (
|
||||
<Tag theme="primary" size="small">可更新至 v{latest.version}</Tag>
|
||||
) : (
|
||||
{releaseState === "checking" ? (
|
||||
<Tag theme="default" size="small">正在检查更新</Tag>
|
||||
) : releaseState === "unverified" ? (
|
||||
<Tag theme="default" size="small">尚未检查更新</Tag>
|
||||
) : releaseState === "available" ? (
|
||||
<Tag theme="primary" size="small">可更新至 v{latest?.version}</Tag>
|
||||
) : releaseState === "up-to-date" ? (
|
||||
<Tag theme="success" size="small">已是最新版本</Tag>
|
||||
) : releaseState === "blocked" ? (
|
||||
<Tag theme="warning" size="small">发现新版本,但暂不可更新</Tag>
|
||||
) : (
|
||||
<Tag theme="default" size="small">暂未获取发布信息</Tag>
|
||||
)}
|
||||
</div>
|
||||
</Surface>
|
||||
@@ -727,8 +776,8 @@ export default function UpdatePage({
|
||||
<Surface className="tn-ascii-release-container">
|
||||
<div className="tn-ascii-release-head">
|
||||
<h3 className="tn-ascii-release-title">发布版本详情</h3>
|
||||
<Tag theme={latest.isNewer ? "primary" : "success"} variant="light-outline">
|
||||
{latest.isNewer ? "发现新版本" : "已是最新版本"}
|
||||
<Tag theme={releaseState === "available" ? "primary" : releaseState === "up-to-date" ? "success" : releaseState === "blocked" ? "warning" : "default"} variant="light-outline">
|
||||
{releaseState === "available" ? "发现新版本" : releaseState === "up-to-date" ? "已是最新版本" : releaseState === "blocked" ? "暂不可安全更新" : "尚未检查"}
|
||||
</Tag>
|
||||
</div>
|
||||
|
||||
@@ -780,7 +829,7 @@ export default function UpdatePage({
|
||||
disabled={actionBusy}
|
||||
icon={<Download size={16} />}
|
||||
>
|
||||
立即升级至 v{latest.version}
|
||||
下载更新包
|
||||
</Button>
|
||||
)}
|
||||
{canApply && (
|
||||
@@ -791,7 +840,7 @@ export default function UpdatePage({
|
||||
disabled={actionBusy}
|
||||
icon={<Zap size={16} />}
|
||||
>
|
||||
更新包已就绪,立即应用 (v{latest.version})
|
||||
立即应用并重启 v{latest.version}
|
||||
</Button>
|
||||
)}
|
||||
{hasActiveJob && (
|
||||
@@ -804,9 +853,14 @@ export default function UpdatePage({
|
||||
查看当前升级进度
|
||||
</Button>
|
||||
)}
|
||||
{!latest.isNewer && !hasActiveJob && (
|
||||
{releaseState === "blocked" && !hasActiveJob && !canApply && (
|
||||
<Button theme="default" variant="outline" size="large" onClick={() => void check()} loading={checking} disabled={checking} icon={<RefreshCw size={15} />}>
|
||||
重新检查
|
||||
</Button>
|
||||
)}
|
||||
{releaseState === "up-to-date" && !hasActiveJob && !canApply && (
|
||||
<Button theme="default" variant="outline" size="large" onClick={() => void check()} icon={<RefreshCw size={15} />}>
|
||||
检查新版本
|
||||
重新检查
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -814,10 +868,10 @@ export default function UpdatePage({
|
||||
) : (
|
||||
<Surface className="tn-empty-surface">
|
||||
<div className="tn-empty-content">
|
||||
<CheckCircle2 size={32} className="text-success" />
|
||||
<p>暂无待更新的版本信息,当前系统已是最新状态。</p>
|
||||
<Button variant="outline" onClick={() => void check()} icon={<RefreshCw size={15} />}>
|
||||
检查新版本
|
||||
{releaseState === "unverified" || releaseState === "checking" ? <RefreshCw size={32} className={releaseState === "checking" ? "tn-spin" : "text-secondary"} /> : <AlertCircle size={32} className="text-warning" />}
|
||||
<p>{releaseState === "unverified" || releaseState === "checking" ? "尚未完成版本检查,请先获取官方发布信息。" : "暂时没有可用的发布信息,请稍后重新检查。"}</p>
|
||||
<Button variant="outline" onClick={() => void check()} loading={checking} disabled={checking} icon={<RefreshCw size={15} />}>
|
||||
{releaseState === "checking" ? "正在检查" : "检查新版本"}
|
||||
</Button>
|
||||
</div>
|
||||
</Surface>
|
||||
@@ -826,7 +880,7 @@ export default function UpdatePage({
|
||||
{latest && notes && (
|
||||
<Surface className="tn-ascii-notes-container">
|
||||
<div className="tn-ascii-notes-head">
|
||||
<h3 className="tn-ascii-notes-title">本次版本更新说明</h3>
|
||||
<h3 className="tn-ascii-notes-title">发布说明</h3>
|
||||
</div>
|
||||
<div className="tn-ascii-notes-body">
|
||||
<MarkdownNotes value={notes} />
|
||||
@@ -851,7 +905,7 @@ export default function UpdatePage({
|
||||
{/* Unified Single Upgrade Modal (800px width on desktop) */}
|
||||
<Dialog
|
||||
visible={showUpgradeModal}
|
||||
header={`系统升级控制台 · v${latest?.version || ""}`}
|
||||
header={`系统升级控制台 · v${latest?.version || job?.version || ""}`}
|
||||
className="tn-dialog-large"
|
||||
width="820px"
|
||||
footer={null}
|
||||
@@ -983,7 +1037,7 @@ export default function UpdatePage({
|
||||
)}
|
||||
|
||||
{/* 场景 C: 校验通过准备就绪 (staged) (Exact ASCII) */}
|
||||
{job?.status === "staged" && (
|
||||
{job?.status === "staged" && job.operation === "download" && canApply && (
|
||||
<div className="tn-modal-card-box">
|
||||
<div className="tn-modal-card-title" style={{ color: "#2f7d5c", display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<CheckCircle2 size={18} />
|
||||
@@ -1011,6 +1065,36 @@ export default function UpdatePage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* A staged archive can become obsolete when the host or release
|
||||
metadata changes while this page is open. Keep the state visible
|
||||
but remove the apply action; the server will reconcile it on the
|
||||
next status request and the operator can perform a fresh check. */}
|
||||
{job?.status === "staged" && job.operation === "download" && !canApply && (
|
||||
<div className="tn-modal-card-box">
|
||||
<div className="tn-modal-card-title">暂存更新已失效</div>
|
||||
<p className="text-secondary" style={{ fontSize: "13px", margin: "0 0 16px", lineHeight: 1.6 }}>
|
||||
这个更新包已不是当前可安全应用的版本,系统不会重复应用。请重新检查更新以获取最新发布信息。
|
||||
</p>
|
||||
<div className="tn-modal-actions-bar">
|
||||
<Button variant="outline" onClick={() => void check()} loading={checking} disabled={checking}>
|
||||
重新检查
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job?.status === "staged" && job.operation === "apply" && (
|
||||
<div className="tn-modal-card-box">
|
||||
<div className="tn-modal-card-title">
|
||||
<div style={{ marginBottom: 12 }}><BeamBar width={180} /></div>
|
||||
应用请求已提交,正在等待更新服务接管
|
||||
</div>
|
||||
<p className="text-secondary" style={{ fontSize: "13px", margin: "0 0 16px", lineHeight: 1.6 }}>
|
||||
系统正在准备备份与重启。页面会持续同步服务状态,请不要重复提交。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 场景 D: 数据快照备份中或服务重启中 (backing_up / applying) (Exact ASCII) */}
|
||||
{(job?.status === "backing_up" || job?.status === "applying") && (
|
||||
<div className="tn-modal-card-box">
|
||||
@@ -1072,7 +1156,7 @@ export default function UpdatePage({
|
||||
)}
|
||||
|
||||
{/* Footer close button */}
|
||||
{job?.status !== "staged" && job?.status !== "completed" && !confirmReadyToDownload && (
|
||||
{!(job?.status === "staged" && canApply) && job?.status !== "completed" && !confirmReadyToDownload && (
|
||||
<div className="tn-modal-footer-close">
|
||||
<Button variant="outline" onClick={() => setShowUpgradeModal(false)}>
|
||||
关闭窗口(后台继续运行)
|
||||
|
||||
@@ -115,14 +115,14 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
|
||||
box-sizing: border-box !important;
|
||||
transition: all 0.22s cubic-bezier(0.16, 1, 0.3, 1) !important;
|
||||
}
|
||||
.t-notification__show--top-right {
|
||||
top: 76px !important;
|
||||
.t-notification__show--bottom-right {
|
||||
bottom: 24px !important;
|
||||
right: 24px !important;
|
||||
z-index: 6000 !important;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.t-notification__show--top-right {
|
||||
top: 16px !important;
|
||||
.t-notification__show--bottom-right {
|
||||
bottom: 16px !important;
|
||||
right: 16px !important;
|
||||
left: 16px !important;
|
||||
width: auto !important;
|
||||
|
||||
Reference in New Issue
Block a user