- health/시작 로그 버전을 package.json 기준으로 동적화 - express-rate-limit으로 로그인·이메일 쓰기 요청 IP 기준 제한, trust proxy 설정 - nginx 보안 헤더(nosniff·X-Frame-Options·Referrer-Policy) 추가 - 공개 덱 목록용 복합 인덱스 추가 - 서버 페이지네이션은 후속 과제로 todo에 기록 Co-authored-by: Cursor <cursoragent@cursor.com>
641 lines
25 KiB
JavaScript
641 lines
25 KiB
JavaScript
import { Router } from 'express';
|
|
import rateLimit from 'express-rate-limit';
|
|
import path from 'path';
|
|
import crypto from 'crypto';
|
|
import bcrypt from 'bcryptjs';
|
|
import { signToken, requireAuth } from '../middleware/auth.js';
|
|
import { nowIso } from '../utils.js';
|
|
import { getAvatarFilePath, getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
|
|
import { sendPasswordResetEmail, sendVerificationEmail } from '../services/emailService.js';
|
|
import { deleteUserAccount } from '../services/accountDeletion.js';
|
|
|
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
const USERNAME_PATTERN = /^[\p{L}\p{N}_-]+$/u;
|
|
const VERIFICATION_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
|
const PASSWORD_RESET_TOKEN_TTL_MS = 30 * 60 * 1000;
|
|
const EMAIL_RESEND_COOLDOWN_MS = 60 * 1000;
|
|
const MONTHLY_EMAIL_LIMIT = 3;
|
|
const EMAIL_LIMIT_MESSAGE =
|
|
'이 계정의 월간 이메일 발송 한도(3회)를 초과했습니다. 관리자 zenn@sori.studio로 직접 문의해 주세요.';
|
|
const EMAIL_LIMIT_CODE = 'MONTHLY_EMAIL_LIMIT_EXCEEDED';
|
|
|
|
/** 로그인 무차별 대입 방어 (IP 기준) */
|
|
const loginLimiter = rateLimit({
|
|
windowMs: 10 * 60 * 1000,
|
|
max: 10,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.' },
|
|
});
|
|
|
|
/** 계정·이메일 관련 쓰기 요청 남용 방어 (IP 기준) */
|
|
const authWriteLimiter = rateLimit({
|
|
windowMs: 10 * 60 * 1000,
|
|
max: 30,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.' },
|
|
});
|
|
|
|
/**
|
|
* 한국 시간 기준 월 키
|
|
* @returns {string}
|
|
*/
|
|
function getKoreanMonthKey() {
|
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: 'Asia/Seoul',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
}).formatToParts(new Date());
|
|
const year = parts.find((part) => part.type === 'year')?.value;
|
|
const month = parts.find((part) => part.type === 'month')?.value;
|
|
return `${year}-${month}`;
|
|
}
|
|
|
|
/**
|
|
* 월간 이메일 발송 횟수 예약
|
|
* @param {import('better-sqlite3').Database} db
|
|
* @param {number} userId
|
|
* @param {'verify_email'|'reset_password'} type
|
|
* @returns {number|null} 예약 로그 ID. 한도 초과 시 null.
|
|
*/
|
|
function reserveEmailSend(db, userId, type) {
|
|
const monthKey = getKoreanMonthKey();
|
|
const transaction = db.transaction(() => {
|
|
db.prepare(`
|
|
DELETE FROM email_send_logs
|
|
WHERE status = 'reserved' AND created_at < datetime('now', '-10 minutes')
|
|
`).run();
|
|
const row = db.prepare(`
|
|
SELECT COUNT(*) AS count
|
|
FROM email_send_logs
|
|
WHERE user_id = ? AND month_key = ? AND status IN ('reserved', 'sent')
|
|
`).get(userId, monthKey);
|
|
if (row.count >= MONTHLY_EMAIL_LIMIT) return null;
|
|
|
|
const result = db.prepare(`
|
|
INSERT INTO email_send_logs (user_id, type, month_key, status)
|
|
VALUES (?, ?, ?, 'reserved')
|
|
`).run(userId, type, monthKey);
|
|
return Number(result.lastInsertRowid);
|
|
});
|
|
return transaction();
|
|
}
|
|
|
|
/**
|
|
* 예약한 이메일 발송을 성공 처리
|
|
* @param {import('better-sqlite3').Database} db
|
|
* @param {number} logId
|
|
*/
|
|
function completeEmailSend(db, logId) {
|
|
db.prepare(`
|
|
UPDATE email_send_logs
|
|
SET status = 'sent', sent_at = ?
|
|
WHERE id = ? AND status = 'reserved'
|
|
`).run(nowIso(), logId);
|
|
}
|
|
|
|
/**
|
|
* 실패한 이메일 발송 예약 취소
|
|
* @param {import('better-sqlite3').Database} db
|
|
* @param {number|null} logId
|
|
*/
|
|
function cancelEmailSend(db, logId) {
|
|
if (!logId) return;
|
|
db.prepare("DELETE FROM email_send_logs WHERE id = ? AND status = 'reserved'").run(logId);
|
|
}
|
|
|
|
/**
|
|
* 인증용 원문 토큰과 DB 저장용 해시 생성
|
|
* @returns {{token: string, tokenHash: string}}
|
|
*/
|
|
function createOpaqueToken() {
|
|
const token = crypto.randomBytes(32).toString('base64url');
|
|
return {
|
|
token,
|
|
tokenHash: crypto.createHash('sha256').update(token).digest('hex'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 원문 토큰 해시
|
|
* @param {string} token
|
|
* @returns {string}
|
|
*/
|
|
function hashToken(token) {
|
|
return crypto.createHash('sha256').update(token).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* 사용자 인증 토큰 저장. 같은 목적의 기존 미사용 토큰은 폐기한다.
|
|
* @param {import('better-sqlite3').Database} db
|
|
* @param {number} userId
|
|
* @param {'verify_email'|'reset_password'} type
|
|
* @param {number} ttlMs
|
|
* @returns {string}
|
|
*/
|
|
function issueAuthToken(db, userId, type, ttlMs) {
|
|
const { token, tokenHash } = createOpaqueToken();
|
|
const expiresAt = new Date(Date.now() + ttlMs).toISOString();
|
|
const transaction = db.transaction(() => {
|
|
db.prepare('DELETE FROM auth_tokens WHERE user_id = ? AND type = ? AND used_at IS NULL')
|
|
.run(userId, type);
|
|
db.prepare(`
|
|
INSERT INTO auth_tokens (user_id, type, token_hash, expires_at)
|
|
VALUES (?, ?, ?, ?)
|
|
`).run(userId, type, tokenHash, expiresAt);
|
|
});
|
|
transaction();
|
|
return token;
|
|
}
|
|
|
|
/**
|
|
* 이메일 형식 검증
|
|
* @param {string} email
|
|
* @returns {boolean}
|
|
*/
|
|
function isValidEmail(email) {
|
|
return email.length <= 254 && EMAIL_PATTERN.test(email);
|
|
}
|
|
|
|
/**
|
|
* 사용자명 형식 검증
|
|
* @param {string} username
|
|
* @returns {boolean}
|
|
*/
|
|
function isValidUsername(username) {
|
|
return username.length >= 2 && username.length <= 30 && USERNAME_PATTERN.test(username);
|
|
}
|
|
|
|
/**
|
|
* DB 사용자 행을 API 응답 형식으로 변환
|
|
* @param {object} user
|
|
* @returns {object}
|
|
*/
|
|
function toUserResponse(user) {
|
|
if (!user) return null;
|
|
return {
|
|
...user,
|
|
displayName: user.username,
|
|
avatarUrl: getAvatarUrl(user.id) || user.avatarUrl || null,
|
|
defaultDeckPublic: (user.default_deck_public ?? user.defaultDeckPublic ?? 1) !== 0,
|
|
skipDeckPublicPrompt: Boolean(user.skip_deck_public_prompt ?? user.skipDeckPublicPrompt),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 인증 라우트
|
|
* @param {import('better-sqlite3').Database} db
|
|
* @returns {import('express').Router}
|
|
*/
|
|
export function createAuthRouter(db) {
|
|
const router = Router();
|
|
|
|
router.get('/avatar/:userId', (req, res) => {
|
|
const filePath = getAvatarFilePath(req.params.userId);
|
|
if (!filePath) {
|
|
return res.status(404).json({ error: '아바타가 없습니다.' });
|
|
}
|
|
res.setHeader('Content-Type', 'image/png');
|
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
res.sendFile(path.resolve(filePath), (err) => {
|
|
if (err && !res.headersSent) {
|
|
res.status(404).json({ error: '아바타가 없습니다.' });
|
|
}
|
|
});
|
|
});
|
|
|
|
router.post('/register', authWriteLimiter, async (req, res, next) => {
|
|
const { email, username, password } = req.body || {};
|
|
|
|
if (!email || !username || !password) {
|
|
return res.status(400).json({ error: '이메일, 사용자명, 비밀번호는 필수입니다.' });
|
|
}
|
|
const normalizedEmail = String(email).trim().toLowerCase();
|
|
const trimmedUsername = String(username).trim();
|
|
const rawPassword = String(password);
|
|
if (!isValidEmail(normalizedEmail)) {
|
|
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
|
|
}
|
|
if (!isValidUsername(trimmedUsername)) {
|
|
return res.status(400).json({
|
|
error: '사용자명은 2~30자의 한글·영문·숫자·밑줄·하이픈만 사용할 수 있습니다.',
|
|
});
|
|
}
|
|
if (rawPassword.length < 8) {
|
|
return res.status(400).json({ error: '비밀번호는 8자 이상이어야 합니다.' });
|
|
}
|
|
|
|
if (db.prepare('SELECT id FROM users WHERE email = ?').get(normalizedEmail)) {
|
|
return res.status(409).json({ error: '이미 사용 중인 이메일입니다.' });
|
|
}
|
|
if (db.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE').get(trimmedUsername)) {
|
|
return res.status(409).json({ error: '이미 사용 중인 사용자명입니다.' });
|
|
}
|
|
|
|
const hash = bcrypt.hashSync(rawPassword, 12);
|
|
let userId;
|
|
let emailLogId;
|
|
try {
|
|
const result = db.prepare(`
|
|
INSERT INTO users (email, username, password_hash, display_name, email_verified_at)
|
|
VALUES (?, ?, ?, ?, NULL)
|
|
`).run(normalizedEmail, trimmedUsername, hash, trimmedUsername);
|
|
userId = Number(result.lastInsertRowid);
|
|
|
|
emailLogId = reserveEmailSend(db, userId, 'verify_email');
|
|
if (!emailLogId) {
|
|
db.prepare('DELETE FROM users WHERE id = ? AND email_verified_at IS NULL').run(userId);
|
|
return res.status(429).json({
|
|
error: EMAIL_LIMIT_MESSAGE,
|
|
code: EMAIL_LIMIT_CODE,
|
|
});
|
|
}
|
|
const token = issueAuthToken(db, userId, 'verify_email', VERIFICATION_TOKEN_TTL_MS);
|
|
await sendVerificationEmail({
|
|
email: normalizedEmail,
|
|
username: trimmedUsername,
|
|
token,
|
|
});
|
|
completeEmailSend(db, emailLogId);
|
|
res.status(201).json({
|
|
ok: true,
|
|
email: normalizedEmail,
|
|
message: '인증 메일을 보냈습니다. 이메일 인증 후 로그인해 주세요.',
|
|
});
|
|
} catch (err) {
|
|
cancelEmailSend(db, emailLogId);
|
|
if (userId) {
|
|
db.prepare('DELETE FROM users WHERE id = ? AND email_verified_at IS NULL').run(userId);
|
|
}
|
|
if (String(err.message).includes('UNIQUE')) {
|
|
return res.status(409).json({ error: '이미 사용 중인 이메일 또는 사용자명입니다.' });
|
|
}
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
router.post('/resend-verification', authWriteLimiter, async (req, res, next) => {
|
|
const normalizedEmail = String(req.body?.email || '').trim().toLowerCase();
|
|
if (!isValidEmail(normalizedEmail)) {
|
|
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
|
|
}
|
|
|
|
const user = db.prepare(`
|
|
SELECT id, email, username, email_verified_at
|
|
FROM users WHERE email = ? AND is_active = 1
|
|
`).get(normalizedEmail);
|
|
if (!user || user.email_verified_at) {
|
|
return res.json({
|
|
ok: true,
|
|
message: '인증이 필요한 계정이라면 이메일을 다시 보냈습니다.',
|
|
});
|
|
}
|
|
|
|
const recent = db.prepare(`
|
|
SELECT created_at
|
|
FROM auth_tokens
|
|
WHERE user_id = ? AND type = 'verify_email'
|
|
ORDER BY id DESC
|
|
LIMIT 1
|
|
`).get(user.id);
|
|
if (recent && Date.now() - new Date(`${recent.created_at}Z`).getTime() < EMAIL_RESEND_COOLDOWN_MS) {
|
|
return res.status(429).json({
|
|
error: '인증 메일은 1분 후 다시 요청할 수 있습니다.',
|
|
code: 'EMAIL_RESEND_COOLDOWN',
|
|
});
|
|
}
|
|
|
|
let emailLogId;
|
|
try {
|
|
emailLogId = reserveEmailSend(db, user.id, 'verify_email');
|
|
if (!emailLogId) {
|
|
return res.status(429).json({
|
|
error: EMAIL_LIMIT_MESSAGE,
|
|
code: EMAIL_LIMIT_CODE,
|
|
});
|
|
}
|
|
const token = issueAuthToken(db, user.id, 'verify_email', VERIFICATION_TOKEN_TTL_MS);
|
|
await sendVerificationEmail({ email: user.email, username: user.username, token });
|
|
completeEmailSend(db, emailLogId);
|
|
res.json({ ok: true, message: '인증 메일을 다시 보냈습니다.' });
|
|
} catch (err) {
|
|
cancelEmailSend(db, emailLogId);
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
router.post('/verify-email', authWriteLimiter, (req, res) => {
|
|
const token = String(req.body?.token || '');
|
|
if (!token) {
|
|
return res.status(400).json({
|
|
error: '인증 토큰이 없습니다.',
|
|
code: 'VERIFICATION_TOKEN_MISSING',
|
|
});
|
|
}
|
|
const tokenHash = hashToken(token);
|
|
const row = db.prepare(`
|
|
SELECT t.id, t.user_id AS userId, t.expires_at AS expiresAt, t.used_at AS usedAt,
|
|
u.email_verified_at AS emailVerifiedAt
|
|
FROM auth_tokens t
|
|
JOIN users u ON u.id = t.user_id
|
|
WHERE t.token_hash = ? AND t.type = 'verify_email' AND u.is_active = 1
|
|
`).get(tokenHash);
|
|
if (!row || row.usedAt || new Date(row.expiresAt).getTime() <= Date.now()) {
|
|
return res.status(400).json({
|
|
error: '인증 링크가 유효하지 않거나 만료되었습니다.',
|
|
code: 'VERIFICATION_TOKEN_INVALID',
|
|
});
|
|
}
|
|
|
|
const transaction = db.transaction(() => {
|
|
db.prepare(`
|
|
UPDATE users
|
|
SET email_verified_at = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`).run(nowIso(), nowIso(), row.userId);
|
|
db.prepare('UPDATE auth_tokens SET used_at = ? WHERE id = ?').run(nowIso(), row.id);
|
|
});
|
|
transaction();
|
|
res.json({ ok: true, message: '이메일 인증이 완료되었습니다. 로그인해 주세요.' });
|
|
});
|
|
|
|
router.post('/login', loginLimiter, (req, res) => {
|
|
const { email, password } = req.body || {};
|
|
if (!email || !password) {
|
|
return res.status(400).json({ error: '이메일과 비밀번호를 입력하세요.' });
|
|
}
|
|
const normalizedEmail = String(email).trim().toLowerCase();
|
|
const rawPassword = String(password);
|
|
|
|
const user = db.prepare(`
|
|
SELECT id, email, username, password_hash, display_name, avatar_url, role, is_active,
|
|
email_verified_at
|
|
FROM users WHERE email = ?
|
|
`).get(normalizedEmail);
|
|
|
|
if (!user || !user.is_active) {
|
|
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' });
|
|
}
|
|
|
|
if (!bcrypt.compareSync(rawPassword, user.password_hash)) {
|
|
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' });
|
|
}
|
|
if (!user.email_verified_at) {
|
|
return res.status(403).json({
|
|
error: '이메일 인증이 필요합니다. 가입한 이메일의 인증 링크를 확인해 주세요.',
|
|
code: 'EMAIL_NOT_VERIFIED',
|
|
});
|
|
}
|
|
|
|
db.prepare('UPDATE users SET last_login_at = ? WHERE id = ?').run(nowIso(), user.id);
|
|
const row = db.prepare(`
|
|
SELECT id, email, username, avatar_url, role, updated_at AS updatedAt, last_login_at AS lastLoginAt
|
|
FROM users WHERE id = ?
|
|
`).get(user.id);
|
|
|
|
const safeUser = toUserResponse(row);
|
|
res.json({ token: signToken(safeUser), user: safeUser });
|
|
});
|
|
|
|
router.post('/forgot-password', authWriteLimiter, async (req, res, next) => {
|
|
const normalizedEmail = String(req.body?.email || '').trim().toLowerCase();
|
|
if (!isValidEmail(normalizedEmail)) {
|
|
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
|
|
}
|
|
const genericResponse = {
|
|
ok: true,
|
|
message: '가입된 이메일이라면 비밀번호 재설정 링크를 보냈습니다.',
|
|
};
|
|
const user = db.prepare(`
|
|
SELECT id, email, username
|
|
FROM users
|
|
WHERE email = ? AND is_active = 1 AND email_verified_at IS NOT NULL
|
|
`).get(normalizedEmail);
|
|
if (!user) {
|
|
return res.json(genericResponse);
|
|
}
|
|
|
|
let emailLogId;
|
|
try {
|
|
const recent = db.prepare(`
|
|
SELECT created_at
|
|
FROM auth_tokens
|
|
WHERE user_id = ? AND type = 'reset_password'
|
|
ORDER BY id DESC
|
|
LIMIT 1
|
|
`).get(user.id);
|
|
if (recent && Date.now() - new Date(`${recent.created_at}Z`).getTime() < EMAIL_RESEND_COOLDOWN_MS) {
|
|
return res.json(genericResponse);
|
|
}
|
|
emailLogId = reserveEmailSend(db, user.id, 'reset_password');
|
|
if (!emailLogId) {
|
|
return res.status(429).json({
|
|
error: EMAIL_LIMIT_MESSAGE,
|
|
code: EMAIL_LIMIT_CODE,
|
|
});
|
|
}
|
|
const token = issueAuthToken(db, user.id, 'reset_password', PASSWORD_RESET_TOKEN_TTL_MS);
|
|
await sendPasswordResetEmail({ email: user.email, username: user.username, token });
|
|
completeEmailSend(db, emailLogId);
|
|
res.json(genericResponse);
|
|
} catch (err) {
|
|
cancelEmailSend(db, emailLogId);
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
router.post('/reset-password', authWriteLimiter, (req, res) => {
|
|
const token = String(req.body?.token || '');
|
|
const newPassword = String(req.body?.newPassword || '');
|
|
if (!token) {
|
|
return res.status(400).json({ error: '재설정 토큰이 없습니다.' });
|
|
}
|
|
if (newPassword.length < 8) {
|
|
return res.status(400).json({ error: '새 비밀번호는 8자 이상이어야 합니다.' });
|
|
}
|
|
|
|
const row = db.prepare(`
|
|
SELECT t.id, t.user_id AS userId, t.expires_at AS expiresAt, t.used_at AS usedAt
|
|
FROM auth_tokens t
|
|
JOIN users u ON u.id = t.user_id
|
|
WHERE t.token_hash = ? AND t.type = 'reset_password' AND u.is_active = 1
|
|
`).get(hashToken(token));
|
|
if (!row || row.usedAt || new Date(row.expiresAt).getTime() <= Date.now()) {
|
|
return res.status(400).json({ error: '재설정 링크가 유효하지 않거나 만료되었습니다.' });
|
|
}
|
|
|
|
const passwordHash = bcrypt.hashSync(newPassword, 12);
|
|
const transaction = db.transaction(() => {
|
|
db.prepare(`
|
|
UPDATE users
|
|
SET password_hash = ?, updated_at = ?
|
|
WHERE id = ? AND is_active = 1
|
|
`).run(passwordHash, nowIso(), row.userId);
|
|
db.prepare(`
|
|
UPDATE auth_tokens
|
|
SET used_at = ?
|
|
WHERE user_id = ? AND type = 'reset_password' AND used_at IS NULL
|
|
`).run(nowIso(), row.userId);
|
|
});
|
|
transaction();
|
|
res.json({ ok: true, message: '비밀번호가 변경되었습니다. 새 비밀번호로 로그인해 주세요.' });
|
|
});
|
|
|
|
router.get('/me', requireAuth, (req, res) => {
|
|
db.prepare('UPDATE users SET last_login_at = ? WHERE id = ? AND is_active = 1').run(
|
|
nowIso(),
|
|
req.user.id,
|
|
);
|
|
|
|
const user = db.prepare(`
|
|
SELECT id, email, username, avatar_url AS avatarUrl, role,
|
|
default_deck_public AS defaultDeckPublic,
|
|
skip_deck_public_prompt AS skipDeckPublicPrompt,
|
|
last_login_at AS lastLoginAt,
|
|
created_at AS createdAt, updated_at AS updatedAt
|
|
FROM users WHERE id = ? AND is_active = 1
|
|
`).get(req.user.id);
|
|
|
|
if (!user) {
|
|
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
|
|
}
|
|
|
|
res.json({ user: toUserResponse(user) });
|
|
});
|
|
|
|
router.put('/me', requireAuth, (req, res) => {
|
|
const { email, username, avatarImage, removeAvatar, defaultDeckPublic, skipDeckPublicPrompt } =
|
|
req.body || {};
|
|
const fields = ['updated_at = ?'];
|
|
const values = [nowIso()];
|
|
|
|
const trimmedEmail = email?.trim().toLowerCase();
|
|
const trimmedUsername = username?.trim();
|
|
|
|
if (trimmedEmail) {
|
|
const current = db.prepare('SELECT email FROM users WHERE id = ?').get(req.user.id);
|
|
if (!isValidEmail(trimmedEmail)) {
|
|
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
|
|
}
|
|
if (trimmedEmail !== current?.email) {
|
|
return res.status(400).json({
|
|
error: '인증된 이메일 주소는 현재 직접 변경할 수 없습니다.',
|
|
});
|
|
}
|
|
}
|
|
if (trimmedUsername) {
|
|
if (!isValidUsername(trimmedUsername)) {
|
|
return res.status(400).json({
|
|
error: '사용자명은 2~30자의 한글·영문·숫자·밑줄·하이픈만 사용할 수 있습니다.',
|
|
});
|
|
}
|
|
const duplicate = db.prepare(`
|
|
SELECT id FROM users
|
|
WHERE username = ? COLLATE NOCASE AND id != ?
|
|
`).get(trimmedUsername, req.user.id);
|
|
if (duplicate) {
|
|
return res.status(409).json({ error: '이미 사용 중인 사용자명입니다.' });
|
|
}
|
|
fields.push('username = ?', 'display_name = ?');
|
|
values.push(trimmedUsername, trimmedUsername);
|
|
}
|
|
|
|
if (avatarImage) {
|
|
const saved = saveAvatarImage(req.user.id, avatarImage);
|
|
if (!saved) {
|
|
return res.status(400).json({ error: '프로필 이미지 저장에 실패했습니다.' });
|
|
}
|
|
const avatarUrl = getAvatarUrl(req.user.id);
|
|
fields.push('avatar_url = ?');
|
|
values.push(avatarUrl);
|
|
} else if (removeAvatar === true) {
|
|
deleteAvatarImage(req.user.id);
|
|
fields.push('avatar_url = ?');
|
|
values.push(null);
|
|
}
|
|
|
|
if (typeof defaultDeckPublic === 'boolean') {
|
|
fields.push('default_deck_public = ?');
|
|
values.push(defaultDeckPublic ? 1 : 0);
|
|
}
|
|
if (typeof skipDeckPublicPrompt === 'boolean') {
|
|
fields.push('skip_deck_public_prompt = ?');
|
|
values.push(skipDeckPublicPrompt ? 1 : 0);
|
|
}
|
|
|
|
try {
|
|
values.push(req.user.id);
|
|
db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
|
} catch (err) {
|
|
if (String(err.message).includes('UNIQUE')) {
|
|
return res.status(409).json({ error: '이미 사용 중인 이메일 또는 사용자명입니다.' });
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const user = db.prepare(`
|
|
SELECT id, email, username, avatar_url AS avatarUrl, role,
|
|
default_deck_public AS defaultDeckPublic,
|
|
skip_deck_public_prompt AS skipDeckPublicPrompt,
|
|
created_at AS createdAt, updated_at AS updatedAt
|
|
FROM users WHERE id = ?
|
|
`).get(req.user.id);
|
|
|
|
res.json({ user: toUserResponse(user) });
|
|
});
|
|
|
|
router.post('/me/password', requireAuth, (req, res) => {
|
|
const { currentPassword, newPassword } = req.body || {};
|
|
if (!currentPassword || !newPassword) {
|
|
return res.status(400).json({ error: '현재 비밀번호와 새 비밀번호를 입력하세요.' });
|
|
}
|
|
if (newPassword.length < 8) {
|
|
return res.status(400).json({ error: '새 비밀번호는 8자 이상이어야 합니다.' });
|
|
}
|
|
|
|
const row = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(req.user.id);
|
|
if (!bcrypt.compareSync(currentPassword, row.password_hash)) {
|
|
return res.status(401).json({ error: '현재 비밀번호가 올바르지 않습니다.' });
|
|
}
|
|
|
|
const hash = bcrypt.hashSync(newPassword, 12);
|
|
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?').run(
|
|
hash,
|
|
nowIso(),
|
|
req.user.id,
|
|
);
|
|
|
|
res.json({ ok: true, message: '비밀번호가 변경되었습니다.' });
|
|
});
|
|
|
|
router.delete('/me', requireAuth, (req, res) => {
|
|
const confirmation = String(req.body?.confirmation || '');
|
|
if (confirmation !== 'DELETE') {
|
|
return res.status(400).json({
|
|
error: '탈퇴 확인 입력란에 DELETE를 정확히 입력해 주세요.',
|
|
code: 'ACCOUNT_DELETE_CONFIRMATION_REQUIRED',
|
|
});
|
|
}
|
|
|
|
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(req.user.id);
|
|
if (!user) {
|
|
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
|
|
}
|
|
if (user.role === 'admin') {
|
|
return res.status(400).json({
|
|
error: '관리자 계정은 다른 관리자가 회원 관리 화면에서 탈퇴 처리해야 합니다.',
|
|
});
|
|
}
|
|
|
|
const result = deleteUserAccount(db, user.id);
|
|
res.json({
|
|
ok: true,
|
|
...result,
|
|
message: '회원 탈퇴가 완료되었습니다.',
|
|
});
|
|
});
|
|
|
|
return router;
|
|
}
|