feat: 管理员账号体系 + 动态邀请码管理; 各端内置数据固定 uuid 对齐
This commit is contained in:
@@ -4,14 +4,28 @@ import { getDb } from './db.mjs';
|
||||
|
||||
export function register({ username, password, device_name, invite_code }) {
|
||||
const d = getDb();
|
||||
// 多用户管控:邀请码(设置后必填)+ 用户数上限(0=不限)
|
||||
const invite = process.env.INVITE_CODE;
|
||||
if (invite && invite_code !== invite) throw new Error('邀请码无效');
|
||||
// 多用户管控:管理员颁发邀请码;env INVITE_CODE 作为静态兜底
|
||||
const maxUsers = Number(process.env.MAX_USERS || 0);
|
||||
if (maxUsers > 0) {
|
||||
const cnt = d.prepare('SELECT COUNT(*) AS c FROM users').get().c;
|
||||
if (cnt >= maxUsers) throw new Error('注册名额已满,请联系管理员');
|
||||
}
|
||||
const userCount = d.prepare('SELECT COUNT(*) AS c FROM users').get().c;
|
||||
let isAdmin = 0;
|
||||
if (userCount === 0) {
|
||||
isAdmin = 1; // 首位注册者自动成为管理员
|
||||
} else {
|
||||
const code = String(invite_code || '').trim().toUpperCase();
|
||||
if (code) {
|
||||
const row = d.prepare('SELECT id, valid FROM invite_codes WHERE code=?').get(code);
|
||||
if (!row || !row.valid) throw new Error('邀请码无效或已使用');
|
||||
var __usedInviteId = row.id; // eslint-disable-line no-var
|
||||
} else if (process.env.INVITE_CODE && invite_code === process.env.INVITE_CODE) {
|
||||
// 静态兜底码
|
||||
} else {
|
||||
throw new Error('需要邀请码,请联系管理员');
|
||||
}
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]{3,32}$/.test(username || '')) throw new Error('用户名需为 3-32 位字母数字-_');
|
||||
if (!password || password.length < 8) throw new Error('密码至少 8 位');
|
||||
const exists = d.prepare('SELECT 1 FROM users WHERE username=?').get(username);
|
||||
@@ -20,8 +34,12 @@ export function register({ username, password, device_name, invite_code }) {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
|
||||
const user_id = crypto.randomUUID();
|
||||
d.prepare('INSERT INTO users (id, username, password_hash, salt) VALUES (?,?,?,?)')
|
||||
.run(user_id, username, hash, salt);
|
||||
d.prepare('INSERT INTO users (id, username, password_hash, salt, is_admin) VALUES (?,?,?,?,?)')
|
||||
.run(user_id, username, hash, salt, isAdmin);
|
||||
if (typeof __usedInviteId === 'number') {
|
||||
d.prepare("UPDATE invite_codes SET used_by=?, used_at=datetime('now','localtime'), valid=0 WHERE id=?")
|
||||
.run(user_id, __usedInviteId);
|
||||
}
|
||||
return issueToken(user_id, device_name);
|
||||
}
|
||||
|
||||
@@ -56,6 +74,12 @@ function issueToken(user_id, device_name) {
|
||||
return { token, user_id, device_id, device_name: name, expires_at: expires };
|
||||
}
|
||||
|
||||
export function isUserAdmin(user_id) {
|
||||
const d = getDb();
|
||||
const row = d.prepare('SELECT is_admin FROM users WHERE id=?').get(user_id);
|
||||
return !!row && !!row.is_admin;
|
||||
}
|
||||
|
||||
export function authUser(req) {
|
||||
const d = getDb();
|
||||
const header = req.headers['authorization'] || '';
|
||||
|
||||
@@ -81,6 +81,18 @@ CREATE TABLE IF NOT EXISTS attachments (
|
||||
PRIMARY KEY (user_id, sha256)
|
||||
);
|
||||
`);
|
||||
// 管理员与邀请码(动态颁发制;首个注册用户自动成为管理员)
|
||||
const hasAdmin = db.prepare("SELECT COUNT(*) AS c FROM pragma_table_info('users') WHERE name='is_admin'").get().c;
|
||||
if (Number(hasAdmin) === 0) db.exec("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0;");
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS invite_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
used_by TEXT,
|
||||
used_at TEXT,
|
||||
valid INTEGER NOT NULL DEFAULT 1
|
||||
);`);
|
||||
return db;
|
||||
}
|
||||
|
||||
|
||||
+49
-17
@@ -5,7 +5,8 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getDb } from './db.mjs';
|
||||
import { createAppCommands } from './app-commands.mjs';
|
||||
import { register, login, authUser } from './auth.mjs';
|
||||
import { register, login, authUser, isUserAdmin } from './auth.mjs';
|
||||
import crypto from 'node:crypto';
|
||||
import { push, pull } from './sync.mjs';
|
||||
import { upload, download } from './attachments.mjs';
|
||||
|
||||
@@ -64,21 +65,52 @@ const server = http.createServer(async (req, res) => {
|
||||
return send(200, true, login(body));
|
||||
}
|
||||
|
||||
// ---- 管理(X-Admin-Token)----
|
||||
if (req.method === 'GET' && route === '/admin/users') {
|
||||
const expect = process.env.ADMIN_TOKEN;
|
||||
if (!expect || req.headers['x-admin-token'] !== expect) {
|
||||
return send(403, false, '管理接口未启用或令牌错误');
|
||||
}
|
||||
// ---- 管理(仅 is_admin 登录会话) ----
|
||||
if (route === '/admin/users' || route === '/admin/invites' || route.startsWith('/admin/invites/generate') || route.startsWith('/admin/invites/revoke')) {
|
||||
let auth;
|
||||
try { auth = authUser(req); }
|
||||
catch (e) { return send(e.code || 401, false, e.message); }
|
||||
if (!isUserAdmin(auth.user_id)) return send(403, false, '需要管理员账号');
|
||||
|
||||
const db = getDb();
|
||||
const users = db.prepare(`
|
||||
SELECT u.id, u.username, u.created_at,
|
||||
(SELECT COUNT(*) FROM devices dv WHERE dv.user_id = u.id) AS devices,
|
||||
(SELECT COUNT(*) FROM current c WHERE c.user_id = u.id AND c.op = 'upsert') AS records,
|
||||
(SELECT COUNT(*) FROM attachments a WHERE a.user_id = u.id) AS attachments,
|
||||
(SELECT COALESCE(SUM(size), 0) FROM attachments a WHERE a.user_id = u.id) AS attachment_bytes
|
||||
FROM users u ORDER BY u.created_at`).all();
|
||||
return send(200, true, { total: users.length, users });
|
||||
if (req.method === 'GET' && route === '/admin/users') {
|
||||
const users = db.prepare(`
|
||||
SELECT u.id, u.username, u.is_admin, u.created_at,
|
||||
(SELECT COUNT(*) FROM devices dv WHERE dv.user_id = u.id) AS devices,
|
||||
(SELECT COUNT(*) FROM current c WHERE c.user_id = u.id AND c.op = 'upsert') AS records,
|
||||
(SELECT COUNT(*) FROM attachments a WHERE a.user_id = u.id) AS attachments,
|
||||
(SELECT COALESCE(SUM(size), 0) FROM attachments a WHERE a.user_id = u.id) AS attachment_bytes
|
||||
FROM users u ORDER BY u.created_at`).all()
|
||||
.map(u => ({ ...u, is_admin: !!u.is_admin }));
|
||||
return send(200, true, { total: users.length, users });
|
||||
}
|
||||
if (req.method === 'GET' && route === '/admin/invites') {
|
||||
const invites = db.prepare(`
|
||||
SELECT i.id, i.code, i.created_at, i.used_by, i.used_at, i.valid,
|
||||
u.username AS used_by_name
|
||||
FROM invite_codes i LEFT JOIN users u ON u.id = i.used_by
|
||||
ORDER BY i.id DESC`).all()
|
||||
.map(i => ({ ...i, valid: !!i.valid }));
|
||||
return send(200, true, { total: invites.length, invites });
|
||||
}
|
||||
if (req.method === 'POST' && route === '/admin/invites/generate') {
|
||||
const body = await readJson(req);
|
||||
const count = Math.min(50, Math.max(1, Number(body.count) || 1));
|
||||
const codes = [];
|
||||
const ins = db.prepare('INSERT INTO invite_codes (code, created_by) VALUES (?,?)');
|
||||
for (let i = 0; i < count; i++) {
|
||||
const code = 'FP-' + crypto.randomBytes(5).toString('hex').toUpperCase();
|
||||
ins.run(code, auth.user_id);
|
||||
codes.push(code);
|
||||
}
|
||||
return send(200, true, { codes });
|
||||
}
|
||||
if (req.method === 'POST' && route === '/admin/invites/revoke') {
|
||||
const body = await readJson(req);
|
||||
const r = db.prepare('UPDATE invite_codes SET valid=0 WHERE id=?').run(Number(body.id));
|
||||
return send(200, true, { revoked: r });
|
||||
}
|
||||
return send(404, false, 'not found');
|
||||
}
|
||||
|
||||
// ---- 同步与 Web API(鉴权区) ----
|
||||
@@ -88,8 +120,8 @@ const server = http.createServer(async (req, res) => {
|
||||
catch (e) { return send(e.code || 401, false, e.message); }
|
||||
|
||||
if (req.method === 'GET' && route === '/auth/me') {
|
||||
const row = getDb().prepare('SELECT id, username FROM users WHERE id=?').get(auth.user_id);
|
||||
return send(200, true, { user_id: auth.user_id, username: row?.username ?? '', device_id: auth.device_id });
|
||||
const row = getDb().prepare('SELECT id, username, is_admin FROM users WHERE id=?').get(auth.user_id);
|
||||
return send(200, true, { user_id: auth.user_id, username: row?.username ?? '', is_admin: !!row?.is_admin, device_id: auth.device_id });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && route === '/sync/push') {
|
||||
|
||||
Reference in New Issue
Block a user