215 lines
20 KiB
JavaScript
215 lines
20 KiB
JavaScript
'use strict';
|
|||
|
|
// 회원(id, 비밀번호, 이름/회사명) 저장과 세션 쿠키 처리. 의존성 없음.
|
||
|
|
// v1.3.17 보안 점검(reports/code-review-2026-10-07.md) 반영:
|
||
|
|
// S01 세션 값은 JSON(아이디·만료·세대)을 서명한 형식으로 바꿔, 점(.)이 든 아이디로 다른 계정이 되는 문제를 막았다.
|
||
|
|
// S05 비밀번호를 바꾸거나 재설정하면 사용자별 세션 세대(sessionGen)가 올라가 그 전에 발급한 세션이 모두 무효가 된다.
|
||
|
|
// 비밀번호 규칙은 lib/passpolicy.js (관리자 콘솔에서 설정, 기본은 기존 규칙 "6자 이상").
|
||
|
|
const fs = require('fs');
|
||
|
|
const path = require('path');
|
||
|
|
const crypto = require('crypto');
|
||
|
|
const passpolicy = require('./passpolicy');
|
||
|
|
|
||
|
|
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
|
||
|
|
const USERS_FILE = path.join(DATA_ROOT, 'users.json');
|
||
|
|
const SECRET_FILE = path.join(DATA_ROOT, 'session.secret');
|
||
|
|
const COOKIE = 'wh_session';
|
||
|
|
const SESSION_DAYS = 30;
|
||
|
|
const ID_RE = /^[a-z0-9][a-z0-9._-]{2,31}$/;
|
||
|
|
|
||
|
|
fs.mkdirSync(DATA_ROOT, { recursive: true });
|
||
|
|
let SECRET;
|
||
|
|
try { SECRET = fs.readFileSync(SECRET_FILE, 'utf8').trim(); } catch (_) { SECRET = ''; }
|
||
|
|
if (!SECRET) { SECRET = crypto.randomBytes(32).toString('hex'); fs.writeFileSync(SECRET_FILE, SECRET, { mode: 0o600 }); }
|
||
|
|
|
||
|
|
// v1.6.31 (요구 10): users.json 이 깨졌으면 "사용자 없음" 으로 보지 않는다(그러면 기본 관리자 생성·가입 중복 같은 2차 사고가 난다) — 예외로 멈추고 관리자 확인
|
||
|
|
const js = require('./jsonstore');
|
||
|
|
function loadUsers() { const d = js.readJson(USERS_FILE, null, { strict: true }); return d && typeof d === 'object' ? d : {}; }
|
||
|
|
function saveUsers(u) { js.writeJsonAtomic(USERS_FILE, JSON.stringify(u, null, 2), { mode: 0o600, keepBackup: true }); }
|
||
|
|
|
||
|
|
function hashPassword(pw, salt) { return crypto.scryptSync(pw, salt, 64).toString('hex'); }
|
||
|
|
function httpError(msg, status = 400) { return Object.assign(new Error(msg), { status }); }
|
||
|
|
|
||
|
|
const ADMIN_USERS = new Set((process.env.ADMIN_USERS || '').split(',').map((x) => x.trim().toLowerCase()).filter(Boolean));
|
||
|
|
function roleOf(u) { return u.role === 'admin' || ADMIN_USERS.has(u.id) ? 'admin' : 'user'; }
|
||
|
|
// v1.4.0 accountType: 'subdomain'(플랫폼 서브 도메인만) | 'domain'(자기 도메인 구매·관리). 기록이 없는 1.4.0 이전 가입자는 'domain' 으로 본다(lib/domains.accountTypeOf)
|
||
|
|
// v1.5.11 mustChangePassword: 기본 관리자(admin/admin)처럼 "알려진 비밀번호" 로 만든 계정 — 로그인 뒤 다른 일을 하기 전에 비밀번호를 바꿔야 한다(server.js 가 막는다)
|
||
|
|
// v1.5.27 (요구 6) pendingApproval: 관리자 승인 방식으로 가입해 아직 승인되지 않은 계정 — 로그인이 403 으로 막힌다. approvedAt/approvedBy 는 승인 기록
|
||
|
|
function publicUser(u) { return { id: u.id, name: u.name, role: roleOf(u), createdAt: u.createdAt, accountType: u.accountType || null, mustChangePassword: !!u.mustChangePassword, lastLoginAt: u.lastLoginAt || null, pendingApproval: !!u.pendingApproval, approvedAt: u.approvedAt || null }; }
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// v1.5.11 (요구 1): 기본 관리자 계정 admin / admin
|
||
|
|
//
|
||
|
|
// 처음 설치한 서버에는 관리자 계정이 없었다(관리자는 scripts/admin.js 나 콘솔에서 따로 만들어야 했고, 설치 안내에도 없었다).
|
||
|
|
// 그래서 설치 직후 회원가입이 막혀 있으면(웹서버 규칙·설정) 아무도 들어갈 수 없었고, 운영자가 당연히 있을 거라 여긴 admin/admin 도 없었다.
|
||
|
|
// 이제 "사용자가 한 명도 없는" 서버가 기동하면 admin / admin 을 만든다(ensureBootstrapAdmin). 비밀번호 admin 은 규칙(6자 이상)에
|
||
|
|
// 맞지 않지만 일부러 그대로 두고, 대신 mustChangePassword 표시를 붙여 첫 로그인 뒤 비밀번호를 바꾸기 전에는 아무 것도 못 하게 한다.
|
||
|
|
// 이미 사용자가 있는 서버(운영 중)에는 자동으로 만들지 않는다 — 누군가 admin/admin 으로 들어올 수 있는 구멍이 되기 때문.
|
||
|
|
// 운영자가 다른 방식으로 설치해 관리자 비밀번호를 모르는 서버(관리자는 있으나 한 번도 로그인한 적이 없음)에서는
|
||
|
|
// 초기 설정 페이지(접속 토큰)나 `node scripts/admin.js bootstrap` 으로 같은 계정을 만들 수 있다(bootstrapAdmin).
|
||
|
|
// ============================================================================
|
||
|
|
const BOOTSTRAP_ID = 'admin';
|
||
|
|
const BOOTSTRAP_PASSWORD = 'admin';
|
||
|
|
/**
|
||
|
|
* admin / admin 계정을 만든다(이미 있으면 그대로). 비밀번호 규칙을 거치지 않고 바로 저장하며 mustChangePassword 를 켠다.
|
||
|
|
* @param {string} by 기록용 출처 (boot | setup | cli)
|
||
|
|
* @returns {{created:boolean, user:object}}
|
||
|
|
*/
|
||
|
|
function bootstrapAdmin(by = 'boot') {
|
||
|
|
const users = loadUsers();
|
||
|
|
if (users[BOOTSTRAP_ID]) return { created: false, user: publicUser(users[BOOTSTRAP_ID]) };
|
||
|
|
const salt = crypto.randomBytes(16).toString('hex');
|
||
|
|
// v1.6.31: 비밀번호는 설치마다 무작위(1회 표시) — 고정값 admin 은 쓰지 않는다. 첫 로그인 뒤 변경을 강제한다
|
||
|
|
const oneTime = crypto.randomBytes(9).toString('base64url');
|
||
|
|
users[BOOTSTRAP_ID] = { id: BOOTSTRAP_ID, name: '관리자', salt, passHash: hashPassword(oneTime, salt), role: 'admin', accountType: 'domain', mustChangePassword: true, bootstrappedAt: new Date().toISOString(), bootstrappedBy: by, createdAt: new Date().toISOString() };
|
||
|
|
saveUsers(users);
|
||
|
|
return { created: true, user: publicUser(users[BOOTSTRAP_ID]), password: oneTime };
|
||
|
|
}
|
||
|
|
/** 기동 때: 사용자가 한 명도 없으면 기본 관리자를 만든다. 결과 메시지(로그용) 또는 null */
|
||
|
|
/**
|
||
|
|
* v1.6.31 (요구 6, 검토 S5): 기동 때 admin/admin 을 만들던 동작을 없앴다. 사용자가 없는 새 설치는 초기 설정 페이지(접속 토큰)에서 "최초 관리자 만들기" 로
|
||
|
|
* 운영자가 아이디·비밀번호를 정한다. 이 함수는 호환을 위해 남겨 두되 아무 것도 만들지 않고 안내만 돌려준다.
|
||
|
|
*/
|
||
|
|
function ensureBootstrapAdmin() {
|
||
|
|
if (Object.keys(loadUsers()).length) return null;
|
||
|
|
return '사용자가 없습니다 — 초기 설정 페이지(설치 스크립트가 알려 준 접속 토큰 주소)에서 최초 관리자 계정을 만드세요 (v1.6.31: 고정 비밀번호 admin/admin 은 더 이상 만들지 않습니다)';
|
||
|
|
}
|
||
|
|
function _legacyEnsureBootstrapAdmin() {
|
||
|
|
const users = loadUsers();
|
||
|
|
if (Object.keys(users).length) return null;
|
||
|
|
const r = bootstrapAdmin('boot');
|
||
|
|
return r.created ? `사용자가 없어 기본 관리자 계정을 만들었습니다: ${BOOTSTRAP_ID} / ${BOOTSTRAP_PASSWORD} (첫 로그인 뒤 비밀번호를 바꿔야 합니다)` : null;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* 관리자 계정 상태 (초기 설정 페이지·설치 점검표용).
|
||
|
|
* admins : 관리자 목록(아이디·마지막 로그인·비밀번호 변경 필요 여부)
|
||
|
|
* usable : 비밀번호가 기본값이 아닌 관리자가 하나라도 있나 (= 운영 가능한 관리자가 있나)
|
||
|
|
* defaultOpen : 기본 비밀번호(admin) 그대로인 관리자가 남아 있나 → 점검표에서 "바꾸세요"
|
||
|
|
* anyLogin : 관리자 중 한 번이라도 로그인한 계정이 있나 (운영자가 비밀번호를 아는 계정이 있다는 뜻)
|
||
|
|
*/
|
||
|
|
function adminState() {
|
||
|
|
const list = Object.values(loadUsers()).filter((u) => roleOf(u) === 'admin').map(publicUser);
|
||
|
|
return { admins: list, usable: list.some((u) => !u.mustChangePassword), defaultOpen: list.some((u) => u.mustChangePassword), anyLogin: list.some((u) => !!u.lastLoginAt), hasBootstrap: list.some((u) => u.id === BOOTSTRAP_ID), total: Object.keys(loadUsers()).length };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 회원가입. v1.5.27 (요구 6): opts.pending 이면 승인 대기 계정으로 만든다(관리자 승인 전까지 로그인 불가). 호출자(server.js)가 설정 signupApproval 을 보고 정한다.
|
||
|
|
*/
|
||
|
|
function signup({ id, password, name }, opts = {}) {
|
||
|
|
id = String(id || '').trim().toLowerCase(); name = String(name || '').trim(); password = String(password || '');
|
||
|
|
if (!ID_RE.test(id)) throw httpError('아이디는 영문 소문자/숫자로 시작하는 3~32자(영문, 숫자, . _ -)여야 합니다');
|
||
|
|
passpolicy.check(password, { id });
|
||
|
|
if (!name || name.length > 80) throw httpError('이름(회사명)을 입력하세요 (80자 이내)');
|
||
|
|
const users = loadUsers();
|
||
|
|
if (users[id]) throw httpError('이미 사용 중인 아이디입니다', 409);
|
||
|
|
const salt = crypto.randomBytes(16).toString('hex');
|
||
|
|
// v1.4.0 새 가입자의 계정 종류 (관리자 설정). 플랫폼 도메인이 없으면(서비스 주소가 IP 등) 서브 도메인을 쓸 수 없으므로 자체 도메인으로 둔다
|
||
|
|
let accountType = 'subdomain'; try { const dc = require('./domains').config(); accountType = dc.defaultAccountType === 'domain' || !dc.platformDomain ? 'domain' : 'subdomain'; } catch (_) {}
|
||
|
|
users[id] = { id, name, salt, passHash: hashPassword(password, salt), role: 'user', accountType, createdAt: new Date().toISOString(), ...(opts.pending ? { pendingApproval: true } : {}) };
|
||
|
|
saveUsers(users);
|
||
|
|
return publicUser(users[id]);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 관리자 생성/승격 등 내부용: 역할을 지정해 계정을 만든다(이미 있으면 역할만 갱신) */
|
||
|
|
function createUser({ id, password, name, role = 'user' }) {
|
||
|
|
const users = loadUsers(); id = String(id || '').trim().toLowerCase();
|
||
|
|
if (!users[id]) { signup({ id, password, name }); }
|
||
|
|
const u = loadUsers(); u[id].role = role; if (password && users[id]) { passpolicy.check(password, { id }); u[id].salt = crypto.randomBytes(16).toString('hex'); u[id].passHash = hashPassword(String(password), u[id].salt); u[id].sessionGen = (u[id].sessionGen || 0) + 1; }
|
||
|
|
saveUsers(u); return publicUser(u[id]);
|
||
|
|
}
|
||
|
|
/** 본인 비밀번호 변경 (현재 비밀번호 확인) */
|
||
|
|
function changePassword(id, current, next) {
|
||
|
|
const users = loadUsers(); const u = users[id]; if (!u) throw httpError('사용자가 없습니다', 404);
|
||
|
|
const h = Buffer.from(hashPassword(String(current || ''), u.salt)); const e = Buffer.from(u.passHash);
|
||
|
|
if (h.length !== e.length || !crypto.timingSafeEqual(h, e)) throw httpError('현재 비밀번호가 올바르지 않습니다', 400);
|
||
|
|
passpolicy.check(next, { id, label: '새 비밀번호' });
|
||
|
|
// v1.5.11: 기본 관리자(admin/admin)가 새 비밀번호를 "admin" 으로 다시 두는 것은 막는다 (규칙상 6자 미만이라 어차피 걸리지만 규칙을 느슨하게 바꾼 경우 대비)
|
||
|
|
if (u.mustChangePassword && String(next) === BOOTSTRAP_PASSWORD) throw httpError('기본 비밀번호(admin)는 다시 쓸 수 없습니다. 다른 비밀번호를 정하세요', 400);
|
||
|
|
// S05: 세션 세대를 올려 다른 곳에 남아 있는 세션을 끊는다 (본인 화면은 server.js 가 새 세션 쿠키를 다시 준다)
|
||
|
|
u.salt = crypto.randomBytes(16).toString('hex'); u.passHash = hashPassword(String(next), u.salt); u.passwordChangedAt = new Date().toISOString(); u.sessionGen = (u.sessionGen || 0) + 1; delete u.mustChangePassword; saveUsers(users); return publicUser(u);
|
||
|
|
}
|
||
|
|
/** 관리자용 비밀번호 재설정 (v1.5.11: 초기 설정 페이지의 "관리자 비밀번호 재설정" 도 이것을 쓴다 — 재설정하면 기본 비밀번호 표시가 풀린다) */
|
||
|
|
function resetPassword(id, next) {
|
||
|
|
const users = loadUsers(); const u = users[id]; if (!u) throw httpError('사용자가 없습니다', 404);
|
||
|
|
passpolicy.check(next, { id, label: '새 비밀번호' });
|
||
|
|
if (String(next) === BOOTSTRAP_PASSWORD) throw httpError('기본 비밀번호(admin)는 쓸 수 없습니다. 다른 비밀번호를 정하세요', 400);
|
||
|
|
u.salt = crypto.randomBytes(16).toString('hex'); u.passHash = hashPassword(String(next), u.salt); u.passwordChangedAt = new Date().toISOString(); u.sessionGen = (u.sessionGen || 0) + 1; delete u.mustChangePassword; saveUsers(users); return publicUser(u);
|
||
|
|
}
|
||
|
|
/** v1.4.0 관리자: 계정 종류 바꾸기 */
|
||
|
|
function setAccountType(id, type) { const users = loadUsers(); if (!users[id]) throw httpError('사용자가 없습니다', 404); if (!['subdomain', 'domain'].includes(type)) throw httpError('계정 종류는 subdomain(서브 도메인) 또는 domain(자기 도메인) 입니다'); users[id].accountType = type; saveUsers(users); return publicUser(users[id]); }
|
||
|
|
/** v1.5.27 (요구 6) 가입 승인: 대기 표시를 지우고 승인 기록을 남긴다. 거절(reject)은 계정을 지운다 */
|
||
|
|
function approve(id, by) { const users = loadUsers(); const u = users[id]; if (!u) throw httpError('사용자가 없습니다', 404); if (!u.pendingApproval) return publicUser(u); delete u.pendingApproval; u.approvedAt = new Date().toISOString(); u.approvedBy = by || null; saveUsers(users); return publicUser(u); }
|
||
|
|
function reject(id) { const users = loadUsers(); const u = users[id]; if (!u) throw httpError('사용자가 없습니다', 404); if (!u.pendingApproval) throw httpError('승인 대기 계정이 아닙니다', 400); delete users[id]; saveUsers(users); return { ok: true, id }; }
|
||
|
|
function pendingCount() { return Object.values(loadUsers()).filter((u) => u.pendingApproval).length; }
|
||
|
|
function setRole(id, role) { const users = loadUsers(); if (!users[id]) throw httpError('사용자가 없습니다', 404); users[id].role = role === 'admin' ? 'admin' : 'user'; saveUsers(users); return publicUser(users[id]); }
|
||
|
|
function listUsers() { return Object.values(loadUsers()).map(publicUser).sort((a, b) => a.createdAt.localeCompare(b.createdAt)); }
|
||
|
|
|
||
|
|
function login({ id, password }) {
|
||
|
|
id = String(id || '').trim().toLowerCase(); password = String(password || '');
|
||
|
|
const u = loadUsers()[id];
|
||
|
|
if (!u) throw httpError('아이디 또는 비밀번호가 올바르지 않습니다', 401);
|
||
|
|
const h = Buffer.from(hashPassword(password, u.salt)); const e = Buffer.from(u.passHash);
|
||
|
|
if (h.length !== e.length || !crypto.timingSafeEqual(h, e)) throw httpError('아이디 또는 비밀번호가 올바르지 않습니다', 401);
|
||
|
|
// v1.5.27 (요구 6): 관리자 승인 대기 계정은 비밀번호가 맞아도 들어올 수 없다 (승인되면 pendingApproval 이 지워진다)
|
||
|
|
if (u.pendingApproval) throw Object.assign(httpError('가입 신청이 접수되었습니다. 관리자가 승인하면 로그인할 수 있습니다', 403), { pendingApproval: true });
|
||
|
|
// v1.5.11: 마지막 로그인 시각 — 설치 점검표가 "운영자가 비밀번호를 아는 관리자 계정이 있는지" 판단하는 데 쓴다
|
||
|
|
try { const users = loadUsers(); if (users[id]) { users[id].lastLoginAt = new Date().toISOString(); saveUsers(users); } } catch (_) {}
|
||
|
|
return publicUser(u);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getUser(id) { const u = loadUsers()[id]; return u ? publicUser(u) : null; }
|
||
|
|
/**
|
||
|
|
* v1.7.3 추가(운영자 요청): 비밀번호 "지문" — 저장된 해시(salt+passHash)의 sha256 앞 24자. 비밀번호가 바뀌면 달라지고, 이 값으로는 로그인할 수 없다.
|
||
|
|
* 메일 서버(Arimail) 관리자 비밀번호 동기화(lib/mailsys.syncServiceAdmin)가 "마지막으로 맞춘 비밀번호와 같은지" 를 판단하는 데 쓴다.
|
||
|
|
*/
|
||
|
|
function passwordFingerprint(id) { const u = loadUsers()[String(id || '').toLowerCase()]; if (!u || !u.passHash) return null; return crypto.createHash('sha256').update(`${u.salt || ''}:${u.passHash}`).digest('hex').slice(0, 24); }
|
||
|
|
|
||
|
|
// ---- 세션 쿠키 (v1.3.17): base64url(JSON {u: 아이디, e: 만료 ms, g: 세션 세대}) + '.' + HMAC ----
|
||
|
|
// 1.3.16 까지는 "아이디.만료" 를 점으로 나눠 읽어, 점이 든 아이디(victim.probe)는 앞부분을 다른 사용자로, 뒷부분을 만료로 읽었고
|
||
|
|
// 만료가 숫자가 아니면(NaN) 비교가 통과했다(S01). 이제 구조화된 값만 받고, 만료는 유한한 미래 시각이어야 하며,
|
||
|
|
// 세대가 사용자 레코드의 sessionGen 과 같아야 한다(S05). 옛 형식 쿠키는 모두 무효 → 다시 로그인.
|
||
|
|
function sign(payload) { return crypto.createHmac('sha256', SECRET).update(payload).digest('base64url'); }
|
||
|
|
/** v1.6.31 (검토 S6): HTTPS 로 들어온 요청(또는 공개 주소가 https)이면 Secure 를 붙여 평문 HTTP 로 쿠키가 새지 않게 한다 */
|
||
|
|
function makeSessionCookie(userId, opts = {}) {
|
||
|
|
const exp = Date.now() + SESSION_DAYS * 86400000; const u = loadUsers()[userId];
|
||
|
|
const payload = Buffer.from(JSON.stringify({ v: 2, u: userId, e: exp, g: (u && u.sessionGen) || 0 })).toString('base64url');
|
||
|
|
const v = `${payload}.${sign(payload)}`;
|
||
|
|
return `${COOKIE}=${v}; Path=/; HttpOnly; SameSite=Lax${opts.secure ? '; Secure' : ''}; Max-Age=${SESSION_DAYS * 86400}`;
|
||
|
|
}
|
||
|
|
function clearSessionCookie() { return `${COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; }
|
||
|
|
/**
|
||
|
|
* v1.6.31 (요구 4): CSRF 토큰 — 세션(아이디·세대)에 묶인 HMAC. 빌더·관리자 화면의 fetch 가 X-WM-CSRF 헤더로 보낸다.
|
||
|
|
* 생성 사이트 페이지(같은 origin)는 이 값을 얻을 수 없다(토큰을 주는 API 는 출처가 빌더 화면일 때만 응답) → Referer 를 없애도 세션을 쓸 수 없다.
|
||
|
|
*/
|
||
|
|
function csrfToken(user) { const u = loadUsers()[user.id]; return crypto.createHmac('sha256', SECRET).update(`csrf|${user.id}|${(u && u.sessionGen) || 0}`).digest('base64url'); }
|
||
|
|
function csrfOk(user, token) { if (!user || !token) return false; const a = Buffer.from(csrfToken(user)); const b = Buffer.from(String(token)); return a.length === b.length && crypto.timingSafeEqual(a, b); }
|
||
|
|
function parseCookies(req) {
|
||
|
|
const out = {};
|
||
|
|
for (const part of (req.headers.cookie || '').split(';')) { const i = part.indexOf('='); if (i > 0) out[part.slice(0, i).trim()] = part.slice(i + 1).trim(); }
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
/** 요청의 세션 쿠키를 검증해 사용자(공개 정보)를 돌려준다. 없거나 잘못되면 null. */
|
||
|
|
function userFromRequest(req) {
|
||
|
|
const v = parseCookies(req)[COOKIE];
|
||
|
|
if (!v) return null;
|
||
|
|
const parts = v.split('.'); if (parts.length !== 2) return null; // 서명 값(base64url)에는 점이 없다 → 정확히 두 조각
|
||
|
|
const [payload, sig] = parts; if (!payload || !sig) return null;
|
||
|
|
const a = Buffer.from(sign(payload)); const b = Buffer.from(sig);
|
||
|
|
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
||
|
|
let d; try { d = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); } catch (_) { return null; }
|
||
|
|
if (!d || d.v !== 2 || typeof d.u !== 'string' || !Number.isFinite(d.e) || d.e < Date.now()) return null;
|
||
|
|
const u = loadUsers()[d.u]; if (!u) return null;
|
||
|
|
if ((u.sessionGen || 0) !== (Number(d.g) || 0)) return null; // 비밀번호 변경·재설정 뒤의 옛 세션
|
||
|
|
return publicUser(u);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** 프로필 변경(이름/회사명). 사이트의 ownerName 은 다음 저장 때 갱신되지 않으므로 표시용으로만 쓴다 */
|
||
|
|
function updateProfile(id, patch) { const users = loadUsers(); const u = users[id]; if (!u) throw Object.assign(new Error('사용자가 없습니다'), { status: 404 }); if (patch.name) u.name = String(patch.name).trim().slice(0, 80); saveUsers(users); return publicUser(u); }
|
||
|
|
/**
|
||
|
|
* v1.3.16 사용자의 화면 언어 선택 저장. at 은 고른 시각 — 관리자가 서비스 기본 언어를 "강제" 한 시각(settings.uiLang.forcedAt)보다
|
||
|
|
* 나중에 고른 경우에만 그 사용자 선택이 이긴다(server.js effectiveUiLang).
|
||
|
|
*/
|
||
|
|
function setUiLang(id, lang) { const users = loadUsers(); const u = users[id]; if (!u) throw httpError('사용자가 없습니다', 404); u.uiLang = String(lang || '').slice(0, 20); u.uiLangAt = new Date().toISOString(); saveUsers(users); return { uiLang: u.uiLang, uiLangAt: u.uiLangAt }; }
|
||
|
|
/** 사용자의 화면 언어 선택 (없으면 null) */
|
||
|
|
function uiLangOf(id) { const u = id ? loadUsers()[id] : null; return u && u.uiLang ? { lang: u.uiLang, at: u.uiLangAt || '' } : null; }
|
||
|
|
module.exports = { passwordFingerprint, csrfToken, csrfOk, approve, reject, pendingCount, setAccountType, setUiLang, uiLangOf, parseCookies, updateProfile, signup, login, getUser, createUser, setRole, listUsers, changePassword, resetPassword, makeSessionCookie, clearSessionCookie, userFromRequest, bootstrapAdmin, ensureBootstrapAdmin, adminState, BOOTSTRAP_ID, BOOTSTRAP_PASSWORD }; // v1.5.11 기본 관리자·관리자 상태
|