mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 17:45:42 +00:00
21e0d02e3d
Prevent false UI success when Go refuses deleting the last Super Admin on dual-SQLite; mirror before local delete and keep installer admin reset unchanged.
676 lines
26 KiB
JavaScript
676 lines
26 KiB
JavaScript
/**
|
|
* BetterDesk Console - Node ↔ Go user sync service
|
|
*
|
|
* Background:
|
|
* The Node.js panel persists users in its own auth.db (or auth schema in
|
|
* PostgreSQL), while the Go server keeps a separate `users` table in
|
|
* db_v2.sqlite3 (or PostgreSQL). Org membership on the Go side links via
|
|
* `org_users.server_user_id` — i.e. only users present in the Go store can
|
|
* be linked to organizations through `Add User → Add Existing`
|
|
* (Issue #125).
|
|
*
|
|
* Historically, the Node panel created users only locally, so the Go
|
|
* `users` table only contained the seeded admin. This service mirrors
|
|
* user create/update/delete operations to the Go server via its REST API,
|
|
* so the org "available users" dropdown stays consistent with the Users
|
|
* admin page.
|
|
*
|
|
* All mirror calls are best-effort: failures are logged but do not break
|
|
* the panel-side request — users remain functional locally even when the
|
|
* Go server is unreachable. Backfill helpers are run at startup to
|
|
* reconcile drift in both directions. SQLite installs can recover local
|
|
* auth.db users from the Go db_v2.sqlite3 users table, preserving password
|
|
* hashes so panel login keeps working after an update recreated auth.db.
|
|
*/
|
|
|
|
const crypto = require('crypto');
|
|
const { apiClient } = require('./betterdeskApi');
|
|
const { assertSafeApiId } = require('../lib/goApiPath');
|
|
const db = require('./database');
|
|
|
|
// Roles supported by the Go server (auth/roles.go). Anything outside this
|
|
// list is downgraded to 'viewer' so the mirror call does not fail.
|
|
const GO_VALID_ROLES = new Set([
|
|
'super_admin',
|
|
'admin',
|
|
'server_admin',
|
|
'global_admin',
|
|
'operator',
|
|
'viewer',
|
|
'pro',
|
|
]);
|
|
|
|
function normalizeRole(role) {
|
|
return GO_VALID_ROLES.has(role) ? role : 'viewer';
|
|
}
|
|
|
|
function normalizeUsername(username) {
|
|
return String(username || '').trim().toLowerCase();
|
|
}
|
|
|
|
/** Matches Go auth.IsSuperAdminRole / panel isSuperAdminRole (Discussion #99). */
|
|
function isSuperAdminRole(role) {
|
|
return role === 'super_admin' || role === 'admin';
|
|
}
|
|
|
|
/**
|
|
* Fetch Go users via API. Distinguishes empty list from API failure (Issue #315).
|
|
* @returns {Promise<{ users: object[], ok: boolean, status?: number, error?: string }>}
|
|
*/
|
|
async function listGoUsers() {
|
|
try {
|
|
const { data } = await apiClient.get('/users');
|
|
if (!Array.isArray(data)) {
|
|
return { users: [], ok: false, status: 502, error: 'invalid_go_users_payload' };
|
|
}
|
|
return { users: data, ok: true };
|
|
} catch (err) {
|
|
const status = err.response?.status;
|
|
console.warn(
|
|
`[userSync] listGoUsers failed: status=${status} ${err.message}` +
|
|
(status === 500
|
|
? ' — Go user list broken; admin-delete parity check cannot run'
|
|
: '')
|
|
);
|
|
return { users: [], ok: false, status: status || 502, error: err.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pre-flight for deleting a Super Admin on dual-SQLite installs (Issue #315).
|
|
* Shared PostgreSQL uses one users table — local countAdmins is enough.
|
|
*
|
|
* @param {string} username
|
|
* @returns {Promise<{ ok: boolean, status?: number, reason?: string, goAdminCount?: number }>}
|
|
*/
|
|
async function assertGoAllowsSuperAdminDelete(username) {
|
|
if (!username) return { ok: true, reason: 'no-username' };
|
|
if (db.type === 'postgres') return { ok: true, reason: 'shared-db' };
|
|
|
|
const listed = await listGoUsers();
|
|
if (!listed.ok) {
|
|
return { ok: false, status: listed.status || 502, reason: 'go_users_unavailable' };
|
|
}
|
|
|
|
const lower = normalizeUsername(username);
|
|
const goUser = listed.users.find(u => normalizeUsername(u.username) === lower);
|
|
if (!goUser) {
|
|
// Panel-only row — nothing to mirror; local last-admin check still applies.
|
|
return { ok: true, reason: 'not-on-go', goAdminCount: listed.users.filter(u => isSuperAdminRole(u.role)).length };
|
|
}
|
|
if (!isSuperAdminRole(goUser.role)) {
|
|
return { ok: true, reason: 'not-go-super-admin' };
|
|
}
|
|
|
|
const goAdminCount = listed.users.filter(u => isSuperAdminRole(u.role)).length;
|
|
if (goAdminCount <= 1) {
|
|
// Node may have other super_admins that never mirrored — refuse before local delete.
|
|
return { ok: false, status: 409, reason: 'last_admin_go', goAdminCount };
|
|
}
|
|
return { ok: true, goAdminCount };
|
|
}
|
|
|
|
function sqliteTableExists(sqliteDb, tableName) {
|
|
if (!/^[a-zA-Z0-9_]+$/.test(tableName)) return false;
|
|
try {
|
|
return !!sqliteDb
|
|
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
.get(tableName);
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function sqliteColumns(sqliteDb, tableName) {
|
|
if (!/^[a-zA-Z0-9_]+$/.test(tableName)) return new Set();
|
|
try {
|
|
return new Set(sqliteDb.prepare(`PRAGMA table_info(${tableName})`).all().map(c => c.name));
|
|
} catch (_) {
|
|
return new Set();
|
|
}
|
|
}
|
|
|
|
function readGoUsersFromSqlite() {
|
|
if (db.type !== 'sqlite') return [];
|
|
if (typeof db.getDb !== 'function') return [];
|
|
|
|
let goDb;
|
|
try {
|
|
goDb = db.getDb();
|
|
} catch (err) {
|
|
console.warn(`[userSync] Go->Node backfill: cannot open Go SQLite DB: ${err.message}`);
|
|
return [];
|
|
}
|
|
|
|
if (!sqliteTableExists(goDb, 'users')) return [];
|
|
const cols = sqliteColumns(goDb, 'users');
|
|
if (!cols.has('username') || !cols.has('password_hash')) return [];
|
|
|
|
const select = [
|
|
cols.has('id') ? 'id' : '0 AS id',
|
|
'username',
|
|
'password_hash',
|
|
cols.has('role') ? "COALESCE(role, 'viewer') AS role" : "'viewer' AS role",
|
|
cols.has('auth_provider') ? "COALESCE(auth_provider, 'local') AS auth_provider" : "'local' AS auth_provider",
|
|
cols.has('totp_secret') ? "COALESCE(totp_secret, '') AS totp_secret" : "'' AS totp_secret",
|
|
cols.has('totp_enabled') ? 'COALESCE(totp_enabled, 0) AS totp_enabled' : '0 AS totp_enabled',
|
|
cols.has('created_at') ? 'created_at' : "datetime('now') AS created_at",
|
|
cols.has('last_login') ? 'last_login' : 'NULL AS last_login',
|
|
];
|
|
|
|
try {
|
|
return goDb.prepare(`SELECT ${select.join(', ')} FROM users ORDER BY id`).all();
|
|
} catch (err) {
|
|
console.warn(`[userSync] Go->Node backfill: cannot read Go users: ${err.message}`);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function getGoSqliteDbForWrite() {
|
|
if (db.type !== 'sqlite') return null;
|
|
if (typeof db.getDb !== 'function') return null;
|
|
|
|
try {
|
|
return db.getDb();
|
|
} catch (err) {
|
|
console.warn(`[userSync] Go SQLite write unavailable: ${err.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function localUserHasTotp(user) {
|
|
return !!(user && user.totp_enabled && String(user.totp_secret || '').trim() !== '');
|
|
}
|
|
|
|
function mirrorTotpToGoSqlite(username, { enabled, secret } = {}) {
|
|
const normalized = normalizeUsername(username);
|
|
if (!normalized) return false;
|
|
|
|
const goDb = getGoSqliteDbForWrite();
|
|
if (!goDb) return false;
|
|
if (!sqliteTableExists(goDb, 'users')) return false;
|
|
|
|
const cols = sqliteColumns(goDb, 'users');
|
|
if (!cols.has('username') || !cols.has('totp_secret') || !cols.has('totp_enabled')) {
|
|
console.warn('[userSync] TOTP mirror skipped: Go users table lacks TOTP columns');
|
|
return false;
|
|
}
|
|
|
|
const row = goDb.prepare('SELECT id FROM users WHERE lower(username) = ?').get(normalized);
|
|
if (!row) {
|
|
console.warn(`[userSync] TOTP mirror skipped: Go user '${username}' not found`);
|
|
return false;
|
|
}
|
|
|
|
if (enabled) {
|
|
const totpSecret = String(secret || '').trim();
|
|
if (!totpSecret) {
|
|
console.warn(`[userSync] TOTP enable mirror skipped for '${username}': missing secret`);
|
|
return false;
|
|
}
|
|
const recoverySql = cols.has('totp_recovery_codes') ? ', totp_recovery_codes = NULL' : '';
|
|
goDb.prepare(`UPDATE users SET totp_secret = ?, totp_enabled = 1${recoverySql} WHERE id = ?`).run(totpSecret, row.id);
|
|
console.log(`[userSync] Mirrored TOTP enable -> Go SQLite: '${username}'`);
|
|
return true;
|
|
}
|
|
|
|
const recoverySql = cols.has('totp_recovery_codes') ? ', totp_recovery_codes = NULL' : '';
|
|
goDb.prepare(`UPDATE users SET totp_secret = '', totp_enabled = 0${recoverySql} WHERE id = ?`).run(row.id);
|
|
console.log(`[userSync] Mirrored TOTP disable -> Go SQLite: '${username}'`);
|
|
return true;
|
|
}
|
|
|
|
function randomPassword() {
|
|
// 32 hex chars — used only as a Go-side placeholder when the panel hash
|
|
// cannot be copied (API-only path). Prefer insertGoUserWithPasswordHash.
|
|
return crypto.randomBytes(16).toString('hex');
|
|
}
|
|
|
|
/**
|
|
* Insert a missing Go SQLite user with the panel password_hash so RustDesk
|
|
* client login (Go /api/login) accepts the same local password as the panel.
|
|
* Returns true on success.
|
|
*/
|
|
function insertGoUserWithPasswordHash(username, passwordHash, role, authProvider = 'local') {
|
|
const normalized = String(username || '').trim();
|
|
const hash = String(passwordHash || '').trim();
|
|
if (!normalized || !hash) return false;
|
|
|
|
const goDb = getGoSqliteDbForWrite();
|
|
if (!goDb) return false;
|
|
if (!sqliteTableExists(goDb, 'users')) return false;
|
|
|
|
const cols = sqliteColumns(goDb, 'users');
|
|
if (!cols.has('username') || !cols.has('password_hash') || !cols.has('role')) {
|
|
console.warn('[userSync] Go hash insert skipped: users table missing required columns');
|
|
return false;
|
|
}
|
|
|
|
const provider = ['local', 'ldap', 'oidc'].includes(String(authProvider || '').trim())
|
|
? String(authProvider).trim()
|
|
: 'local';
|
|
const goRole = normalizeRole(role);
|
|
|
|
try {
|
|
if (cols.has('auth_provider')) {
|
|
goDb.prepare(
|
|
`INSERT INTO users (username, password_hash, role, auth_provider) VALUES (?, ?, ?, ?)`
|
|
).run(normalized, hash, goRole, provider);
|
|
} else {
|
|
goDb.prepare(
|
|
`INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)`
|
|
).run(normalized, hash, goRole);
|
|
}
|
|
console.log(`[userSync] backfill: inserted Go SQLite user '${normalized}' with panel password hash (${goRole})`);
|
|
return true;
|
|
} catch (err) {
|
|
// UNIQUE username — already present (race with API or concurrent startup).
|
|
if (String(err.message || '').includes('UNIQUE')) return true;
|
|
console.warn(`[userSync] Go hash insert failed for '${normalized}': ${err.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function readGoUsersFromApi() {
|
|
const listed = await listGoUsers();
|
|
return listed.users;
|
|
}
|
|
|
|
async function findGoUserByUsername(username) {
|
|
if (!username) return null;
|
|
const users = await readGoUsersFromApi();
|
|
const lower = normalizeUsername(username);
|
|
return users.find(u => normalizeUsername(u.username) === lower) || null;
|
|
}
|
|
|
|
async function resolveGoUserId(localUserId) {
|
|
const id = Number(localUserId);
|
|
if (!Number.isInteger(id) || id <= 0) return null;
|
|
|
|
let localUser;
|
|
try {
|
|
localUser = await db.getUserById(id);
|
|
} catch (err) {
|
|
console.warn(`[userSync] resolveGoUserId(${localUserId}) local lookup failed: ${err.message}`);
|
|
return null;
|
|
}
|
|
if (!localUser) return null;
|
|
|
|
const goUser = await findGoUserByUsername(localUser.username);
|
|
return goUser?.id || localUser.id;
|
|
}
|
|
|
|
/**
|
|
* Mirror a freshly created Node user into the Go users table.
|
|
* Safe to call when the user already exists on the Go side (logged + ignored).
|
|
*
|
|
* PostgreSQL shared-DB: the panel INSERT already wrote the row Go reads — skip
|
|
* POST /users (would always 409). Issue #301.
|
|
*/
|
|
async function mirrorCreate(username, password, role) {
|
|
if (!username || !password) return;
|
|
if (db.type === 'postgres') {
|
|
return;
|
|
}
|
|
try {
|
|
await apiClient.post('/users', {
|
|
username,
|
|
password,
|
|
role: normalizeRole(role),
|
|
});
|
|
console.log(`[userSync] Mirrored create -> Go: '${username}' (${normalizeRole(role)})`);
|
|
} catch (err) {
|
|
const status = err.response?.status;
|
|
// 409 = username already exists on Go side → still sync the role/password.
|
|
// allowCreate:false prevents mirrorUpdate → mirrorCreate recursion (Issue #301).
|
|
if (status === 409) {
|
|
await mirrorUpdate(username, { password, role, allowCreate: false });
|
|
return;
|
|
}
|
|
console.warn(`[userSync] mirrorCreate('${username}') failed: status=${status} ${err.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mirror an update (role and/or password) to the Go side.
|
|
* Looks up the Go user by username (IDs differ between stores).
|
|
*
|
|
* @param {object} [opts]
|
|
* @param {string} [opts.password]
|
|
* @param {string} [opts.role]
|
|
* @param {boolean} [opts.allowCreate=true] When false (e.g. after POST 409), never
|
|
* call mirrorCreate — avoids unbounded INSERT loops when GET /users fails.
|
|
*/
|
|
async function mirrorUpdate(username, { password, role, allowCreate = true } = {}) {
|
|
if (!username) return;
|
|
if (!password && !role) return;
|
|
|
|
let goUser = await findGoUserByUsername(username);
|
|
|
|
// If the user does not yet exist on Go and we have a plaintext password,
|
|
// create the record so subsequent operations (org linking) work.
|
|
if (!goUser && password && allowCreate) {
|
|
await mirrorCreate(username, password, role);
|
|
return;
|
|
}
|
|
if (!goUser) {
|
|
// No Go record: either no plaintext password, or create was forbidden
|
|
// after a 409 (list API broken / empty) — do not retry INSERT.
|
|
if (!allowCreate) {
|
|
console.warn(
|
|
`[userSync] mirrorUpdate('${username}'): conflict (409) but GET /users ` +
|
|
'could not resolve the user; skipping create to avoid retry loop (issue #301)'
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const body = {};
|
|
if (password) body.password = password;
|
|
if (role) body.role = normalizeRole(role);
|
|
if (Object.keys(body).length === 0) return;
|
|
|
|
try {
|
|
const safeId = assertSafeApiId(goUser.id, 'userId');
|
|
await apiClient.put(`/users/${encodeURIComponent(safeId)}`, body);
|
|
console.log(`[userSync] Mirrored update -> Go: '${username}'${body.role ? ` role=${body.role}` : ''}${body.password ? ' password=***' : ''}`);
|
|
} catch (err) {
|
|
const status = err.response?.status;
|
|
console.warn(`[userSync] mirrorUpdate('${username}') failed: status=${status} ${err.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mirror a delete to the Go side. Looks up the user by username first.
|
|
*
|
|
* Issue #315: returns a result object so the panel can fail closed (no local
|
|
* delete / rollback) when Go refuses last-admin (409). Shared PostgreSQL skips
|
|
* the HTTP mirror — local delete already removes the shared row.
|
|
*
|
|
* @returns {Promise<{ ok: boolean, skipped?: boolean|string, status?: number, conflict?: boolean }>}
|
|
*/
|
|
async function mirrorDelete(username) {
|
|
if (!username) return { ok: true, skipped: true };
|
|
if (db.type === 'postgres') return { ok: true, skipped: 'shared-db' };
|
|
|
|
const goUser = await findGoUserByUsername(username);
|
|
if (!goUser) return { ok: true, skipped: 'not-on-go' };
|
|
try {
|
|
const safeId = assertSafeApiId(goUser.id, 'userId');
|
|
await apiClient.delete(`/users/${encodeURIComponent(safeId)}`);
|
|
console.log(`[userSync] Mirrored delete -> Go: '${username}'`);
|
|
return { ok: true };
|
|
} catch (err) {
|
|
const status = err.response?.status;
|
|
// 409 = "Cannot delete the last admin user" — caller must not leave
|
|
// panel/Go desynced (false success + backfill restore).
|
|
console.warn(`[userSync] mirrorDelete('${username}') failed: status=${status} ${err.message}`);
|
|
return {
|
|
ok: false,
|
|
status: status || 502,
|
|
conflict: status === 409,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function mirrorTotpEnable(username, { secret } = {}) {
|
|
if (!username || !secret) return;
|
|
if (db.type === 'postgres') {
|
|
// PostgreSQL uses the shared users table; the local enable already
|
|
// updates the row read by the Go API.
|
|
return;
|
|
}
|
|
mirrorTotpToGoSqlite(username, { enabled: true, secret });
|
|
}
|
|
|
|
async function mirrorTotpDisable(username) {
|
|
if (!username) return;
|
|
if (db.type === 'postgres') {
|
|
return;
|
|
}
|
|
mirrorTotpToGoSqlite(username, { enabled: false });
|
|
}
|
|
|
|
/**
|
|
* Backfill: ensure every Node panel user has a matching Go-side user record.
|
|
* Called once at startup. On SQLite dual-DB installs, missing Go users are
|
|
* created with the panel password_hash so RustDesk client login accepts the
|
|
* same local password. Falls back to a random API password only when the hash
|
|
* cannot be copied (then a panel password reset is required for client login).
|
|
* PostgreSQL shared-DB installs normally already share the users table.
|
|
*/
|
|
async function backfillFromNode() {
|
|
let nodeUsers;
|
|
try {
|
|
nodeUsers = await db.getAllUsers();
|
|
} catch (err) {
|
|
console.warn(`[userSync] backfill: cannot read Node users: ${err.message}`);
|
|
return;
|
|
}
|
|
if (!Array.isArray(nodeUsers) || nodeUsers.length === 0) return;
|
|
|
|
let goUsers;
|
|
try {
|
|
const { data } = await apiClient.get('/users');
|
|
goUsers = Array.isArray(data) ? data : [];
|
|
} catch (err) {
|
|
const status = err.response?.status;
|
|
console.warn(`[userSync] backfill: cannot read Go users: status=${status} ${err.message}`);
|
|
return;
|
|
}
|
|
|
|
const goUsernames = new Set(goUsers.map(u => String(u.username || '').toLowerCase()));
|
|
const missing = nodeUsers.filter(u => !goUsernames.has(String(u.username || '').toLowerCase()));
|
|
if (missing.length === 0) {
|
|
console.log(`[userSync] backfill: all ${nodeUsers.length} panel users already present on Go side`);
|
|
if (db.type === 'sqlite') {
|
|
for (const u of nodeUsers) {
|
|
if (localUserHasTotp(u)) {
|
|
mirrorTotpToGoSqlite(u.username, { enabled: true, secret: u.totp_secret });
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
console.log(`[userSync] backfill: mirroring ${missing.length} panel user(s) to Go server`);
|
|
for (const u of missing) {
|
|
const hash = String(u.password_hash || '').trim();
|
|
if (db.type === 'sqlite' && hash && insertGoUserWithPasswordHash(u.username, hash, u.role, u.auth_provider)) {
|
|
continue;
|
|
}
|
|
try {
|
|
await apiClient.post('/users', {
|
|
username: u.username,
|
|
password: randomPassword(),
|
|
role: normalizeRole(u.role),
|
|
});
|
|
console.log(`[userSync] backfill: created Go user '${u.username}' (${normalizeRole(u.role)}) via API (placeholder password)`);
|
|
} catch (err) {
|
|
const status = err.response?.status;
|
|
if (status === 409) continue; // race — already exists, fine.
|
|
console.warn(`[userSync] backfill: failed to create '${u.username}': status=${status} ${err.message}`);
|
|
}
|
|
}
|
|
|
|
if (db.type === 'sqlite') {
|
|
for (const u of nodeUsers) {
|
|
if (localUserHasTotp(u)) {
|
|
mirrorTotpToGoSqlite(u.username, { enabled: true, secret: u.totp_secret });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PostgreSQL / shared-DB: reconcile auth_provider and role from Go REST API.
|
|
*/
|
|
async function backfillFromGoPostgres() {
|
|
const goUsers = await readGoUsersFromApi();
|
|
if (goUsers.length === 0) return { imported: 0, synced: 0 };
|
|
|
|
let localUsers;
|
|
try {
|
|
localUsers = await db.getAllUsers();
|
|
} catch (err) {
|
|
console.warn(`[userSync] Go->Node API sync: cannot read local users: ${err.message}`);
|
|
return { imported: 0, error: err.message };
|
|
}
|
|
|
|
let synced = 0;
|
|
const localByName = new Map((localUsers || []).map(u => [normalizeUsername(u.username), u]));
|
|
|
|
for (const goUser of goUsers) {
|
|
const local = localByName.get(normalizeUsername(goUser.username));
|
|
if (!local) continue;
|
|
|
|
const goProvider = String(goUser.auth_provider || 'local').trim() || 'local';
|
|
const goRole = normalizeRole(goUser.role);
|
|
const localProvider = String(local.auth_provider || 'local').trim() || 'local';
|
|
|
|
if (goProvider !== localProvider || goRole !== local.role) {
|
|
try {
|
|
await db.syncUserFromGo(local.id, { authProvider: goProvider, role: goRole });
|
|
synced++;
|
|
} catch (err) {
|
|
console.warn(`[userSync] Go->Node API sync failed for '${local.username}': ${err.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (synced > 0) {
|
|
console.log(`[userSync] Go->Node API sync: updated auth_provider/role for ${synced} user(s)`);
|
|
}
|
|
return { imported: 0, synced };
|
|
}
|
|
|
|
/**
|
|
* Backfill: recover Node auth.db users from the Go server SQLite database.
|
|
*
|
|
* SQLite dual-DB installs read db_v2.sqlite3 directly (preserves password hashes).
|
|
* PostgreSQL uses the Go REST API to reconcile auth_provider/role only.
|
|
*/
|
|
async function backfillFromGo() {
|
|
if (db.type === 'postgres') {
|
|
return backfillFromGoPostgres();
|
|
}
|
|
if (typeof db.getAuthDb !== 'function') return { imported: 0, skipped: 'no-auth-db' };
|
|
|
|
const goUsers = readGoUsersFromSqlite()
|
|
.filter(u => normalizeUsername(u.username) && String(u.password_hash || '').trim() !== '');
|
|
if (goUsers.length === 0) return { imported: 0 };
|
|
|
|
let localUsers;
|
|
try {
|
|
localUsers = await db.getAllUsersForBackup();
|
|
} catch (err) {
|
|
console.warn(`[userSync] Go->Node backfill: cannot read local users: ${err.message}`);
|
|
return { imported: 0, error: err.message };
|
|
}
|
|
|
|
const localByUsername = new Set((localUsers || []).map(u => normalizeUsername(u.username)));
|
|
const localIds = new Set((localUsers || []).map(u => Number(u.id)).filter(Number.isInteger));
|
|
const missing = goUsers.filter(u => !localByUsername.has(normalizeUsername(u.username)));
|
|
|
|
let authDb;
|
|
try {
|
|
authDb = db.getAuthDb();
|
|
} catch (err) {
|
|
console.warn(`[userSync] Go->Node backfill: cannot open local auth DB: ${err.message}`);
|
|
return { imported: 0, error: err.message };
|
|
}
|
|
|
|
const insertWithId = authDb.prepare(`
|
|
INSERT INTO users (id, username, password_hash, role, auth_provider, created_at, last_login, totp_secret, totp_enabled)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
const insertWithoutId = authDb.prepare(`
|
|
INSERT INTO users (username, password_hash, role, auth_provider, created_at, last_login, totp_secret, totp_enabled)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
|
|
let imported = 0;
|
|
for (const user of missing) {
|
|
const username = String(user.username || '').trim();
|
|
const passwordHash = String(user.password_hash || '').trim();
|
|
const role = normalizeRole(user.role);
|
|
const authProvider = String(user.auth_provider || 'local').trim() || 'local';
|
|
const createdAt = user.created_at || new Date().toISOString();
|
|
const lastLogin = user.last_login || null;
|
|
const totpSecret = user.totp_secret || null;
|
|
const totpEnabled = user.totp_enabled ? 1 : 0;
|
|
const goId = Number(user.id);
|
|
|
|
try {
|
|
if (Number.isInteger(goId) && goId > 0 && !localIds.has(goId)) {
|
|
insertWithId.run(goId, username, passwordHash, role, authProvider, createdAt, lastLogin, totpSecret, totpEnabled);
|
|
localIds.add(goId);
|
|
} else {
|
|
insertWithoutId.run(username, passwordHash, role, authProvider, createdAt, lastLogin, totpSecret, totpEnabled);
|
|
}
|
|
localByUsername.add(normalizeUsername(username));
|
|
imported++;
|
|
console.log(`[userSync] Go->Node backfill: restored local user '${username}' (${role})`);
|
|
} catch (err) {
|
|
console.warn(`[userSync] Go->Node backfill: failed to restore '${username}': ${err.message}`);
|
|
}
|
|
}
|
|
|
|
if (imported > 0) {
|
|
console.log(`[userSync] Go->Node backfill: restored ${imported} user(s) into local auth DB`);
|
|
try {
|
|
localUsers = await db.getAllUsersForBackup();
|
|
} catch (_) { /* keep previous snapshot */ }
|
|
}
|
|
|
|
const synced = syncExistingAuthFromGo(authDb, goUsers, localUsers);
|
|
if (synced > 0) {
|
|
console.log(`[userSync] Go->Node backfill: synced auth_provider/role for ${synced} existing user(s)`);
|
|
}
|
|
|
|
return { imported, synced };
|
|
}
|
|
|
|
function syncExistingAuthFromGo(authDb, goUsers, localUsers) {
|
|
if (!authDb || !Array.isArray(goUsers) || goUsers.length === 0) return 0;
|
|
const localByName = new Map((localUsers || []).map(u => [normalizeUsername(u.username), u]));
|
|
const updateStmt = authDb.prepare('UPDATE users SET role = ?, auth_provider = ? WHERE id = ?');
|
|
let synced = 0;
|
|
|
|
for (const goUser of goUsers) {
|
|
const local = localByName.get(normalizeUsername(goUser.username));
|
|
if (!local) continue;
|
|
|
|
const goProvider = String(goUser.auth_provider || 'local').trim() || 'local';
|
|
const goRole = normalizeRole(goUser.role);
|
|
const localProvider = String(local.auth_provider || 'local').trim() || 'local';
|
|
|
|
if (goProvider !== localProvider || goRole !== local.role) {
|
|
try {
|
|
updateStmt.run(goRole, goProvider, local.id);
|
|
synced++;
|
|
} catch (err) {
|
|
console.warn(`[userSync] Go->Node sync failed for '${local.username}': ${err.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return synced;
|
|
}
|
|
|
|
module.exports = {
|
|
findGoUserByUsername,
|
|
resolveGoUserId,
|
|
isSuperAdminRole,
|
|
listGoUsers,
|
|
assertGoAllowsSuperAdminDelete,
|
|
mirrorCreate,
|
|
mirrorUpdate,
|
|
mirrorDelete,
|
|
mirrorTotpEnable,
|
|
mirrorTotpDisable,
|
|
insertGoUserWithPasswordHash,
|
|
backfillFromGo,
|
|
backfillFromNode,
|
|
};
|