Files

82 lines
5.8 KiB
JavaScript

/**
* 게이트웨이 계정 명령줄 도구(0.8.18~).
*
* 독립 실행(www 사이트 DB 없이 설치)에서는 관리자 페이지에 들어갈 첫 관리자 계정을 화면에서 만들 수 없습니다
* (관리자 페이지는 관리자 로그인이 있어야 열리므로). 이 도구가 그 첫 계정을 만들고, 이후에도 비밀번호 재설정 같은
* 운영 작업을 서버 셸에서 할 수 있게 합니다. 서비스가 돌고 있는 중에도 쓸 수 있습니다(SQLite WAL, 짧은 쓰기).
*
* 사용법(서비스 계정으로, 환경 파일을 읽어서):
* DB_FILE=... MAILBOX=admin@example.com node server/user-cli.mjs add <로그인ID> [--admin] [--name "이름"]
* DB_FILE=... node server/user-cli.mjs passwd <로그인ID>
* DB_FILE=... node server/user-cli.mjs list
*
* 비밀번호는 (1) 환경 변수 PASSWORD, (2) --password-stdin(표준 입력 한 줄), (3) 단말 입력(글자 숨김) 순으로 받습니다.
* 비밀번호 규칙은 관리자 페이지의 비밀번호 정책(기본 6자 이상)을 따릅니다(0.8.24~). 독립 실행의 첫 관리자는 서버가 시작할 때 admin / adminpasswd 로 자동 생성되므로 이 도구는 추가 계정·비밀번호 재설정에 씁니다.
* 계정 수 제한(LIMIT_ACCOUNTS, 0.9.0)은 환경 변수로 주면 여기서도 적용됩니다(서비스의 환경 파일을 그대로 넘기면 됨).
* 명령줄 인수로는 받지 않습니다(ps·셸 이력에 남기 때문).
* MAILBOX 는 계정의 메일함 주소(로그인ID@도메인)를 정하는 데 필요하므로 add 때는 꼭 서비스와 같은 값을 주세요.
*/
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { createInterface } from 'node:readline';
import { openDb, d1, migrate } from './db.mjs';
import { createUser, updateUser, listUsers, mailDomain, readPasswordPolicy } from './auth.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const [command, login, ...rest] = process.argv.slice(2);
const flag = (name) => rest.includes(name);
const option = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; };
function usage(code = 1) {
console.error('사용법: node server/user-cli.mjs add <login> [--admin] [--name 이름] | passwd <login> | list\n비밀번호: 환경 변수 PASSWORD, --password-stdin, 또는 단말 입력');
process.exit(code);
}
/** 표준 입력 전체(첫 줄)를 비밀번호로 읽습니다 — 스크립트에서 `printf '%s' "$PW" | node ... --password-stdin` 형태로 씁니다. */
async function readStdinLine() {
let data = ''; for await (const chunk of process.stdin) data += chunk;
return data.split(/\r?\n/)[0];
}
/** 단말에서 글자를 숨기고 비밀번호를 받습니다. 단말이 아니면(파이프) 표준 입력 한 줄을 읽습니다. */
async function prompt(label) {
if (!process.stdin.isTTY) return readStdinLine();
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
// 입력 글자를 화면에 쓰지 않도록 출력 함수를 가로챕니다. 질문 글은 먼저 직접 써 둡니다.
process.stdout.write(label);
rl._writeToOutput = () => {};
const answer = await new Promise((resolve) => rl.question('', resolve));
rl.close(); process.stdout.write('\n');
return answer;
}
/** 정책을 한 줄 안내문으로: "6자 이상, 대·소문자 혼용, 숫자 포함, 특수문자 포함" */
function policyText(p) { return [`${p.minLength}자 이상`, p.mixedCase && '대·소문자 혼용', p.digit && '숫자 포함', p.special && '특수문자 포함'].filter(Boolean).join(', '); }
async function password() {
if (process.env.PASSWORD) return process.env.PASSWORD;
if (flag('--password-stdin')) return readStdinLine();
const a = await prompt(`비밀번호(${policyText(await readPasswordPolicy(env))}): `);
if (process.stdin.isTTY) { const b = await prompt('비밀번호 확인: '); if (a !== b) { console.error('두 입력이 다릅니다'); process.exit(1); } }
return a;
}
if (!command || !['add', 'passwd', 'list'].includes(command) || (command !== 'list' && !login)) usage();
const file = process.env.DB_FILE || join(ROOT, 'data', 'mail-gateway.sqlite');
const db = openDb(file); migrate(db, join(ROOT, 'migrations'));
const env = { ...process.env, DB: d1(db) };
try {
if (command === 'list') {
for (const u of await listUsers(env)) console.log(`${u.login}\t${u.mailbox}\t${u.role}\t${u.status}\t${u.name || ''}`);
} else if (command === 'add') {
const id = await createUser(env, { login, name: option('--name') || '', password: await password(), role: flag('--admin') ? 'admin' : 'user' }, 'user-cli');
console.log(`계정을 만들었습니다: ${login} → ${login}@${mailDomain(env)} (${flag('--admin') ? '관리자' : '사용자'}, id ${id})`);
} else {
const row = db.prepare('SELECT id FROM mail_users WHERE login = ?').get(String(login).toLowerCase());
if (!row) { console.error(`계정이 없습니다: ${login}`); process.exit(1); }
await updateUser(env, row.id, { password: await password() });
console.log(`비밀번호를 바꿨습니다: ${login} (웹 세션은 모두 끝났습니다)`);
}
} catch (e) {
// createUser/updateUser 의 오류 코드를 사람이 읽을 말로 바꿉니다.
const messages = { invalid_login: '로그인 ID 는 영문 소문자·숫자·._- 2~64자여야 하고(기본 메일함 별칭은 독립 실행에서만 가능)', weak_password: `비밀번호가 정책에 맞지 않습니다(${e.policy ? policyText(e.policy) : '글자 수 부족'})`, login_taken: '이미 있는 로그인 ID 입니다', account_limit_reached: `계정 수가 시스템 제한(LIMIT_ACCOUNTS=${e.limit})에 이르렀습니다` };
console.error('실패:', messages[e.message] || e.message); process.exitCode = 1;
} finally { db.close(); }