v4.0.0: 회원·덱 API, 인증 UI, 프로필 및 레이아웃 통일
Express/SQLite API와 Docker 구성을 추가하고 로그인·덱 저장·공개 덱·관리자·프로필 화면을 구현했다. 결과 화면 저장 UX, 헤더 프로필, 애드센스용 app-shell 레이아웃(1280px) 및 문서를 반영했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
212
api/src/routes/auth.js
Normal file
212
api/src/routes/auth.js
Normal file
@@ -0,0 +1,212 @@
|
||||
import { Router } from 'express';
|
||||
import path from 'path';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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', (req, res) => {
|
||||
const { email, username, password } = req.body || {};
|
||||
|
||||
if (!email || !username || !password) {
|
||||
return res.status(400).json({ error: '이메일, 사용자명, 비밀번호는 필수입니다.' });
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({ error: '비밀번호는 8자 이상이어야 합니다.' });
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(password, 12);
|
||||
|
||||
try {
|
||||
const trimmedUsername = username.trim();
|
||||
const result = db.prepare(`
|
||||
INSERT INTO users (email, username, password_hash, display_name)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(email.trim().toLowerCase(), trimmedUsername, hash, trimmedUsername);
|
||||
|
||||
const user = db.prepare(`
|
||||
SELECT id, email, username, role, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM users WHERE id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
const safeUser = toUserResponse(user);
|
||||
const token = signToken(safeUser);
|
||||
res.status(201).json({ token, user: safeUser });
|
||||
} catch (err) {
|
||||
if (String(err.message).includes('UNIQUE')) {
|
||||
return res.status(409).json({ error: '이미 사용 중인 이메일 또는 사용자명입니다.' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
const { email, password } = req.body || {};
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: '이메일과 비밀번호를 입력하세요.' });
|
||||
}
|
||||
|
||||
const user = db.prepare(`
|
||||
SELECT id, email, username, password_hash, display_name, avatar_url, role, is_active
|
||||
FROM users WHERE email = ?
|
||||
`).get(email.trim().toLowerCase());
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' });
|
||||
}
|
||||
|
||||
if (!bcrypt.compareSync(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: '이메일 또는 비밀번호가 올바르지 않습니다.' });
|
||||
}
|
||||
|
||||
const row = db.prepare(`
|
||||
SELECT id, email, username, avatar_url, role, updated_at AS updatedAt
|
||||
FROM users WHERE id = ?
|
||||
`).get(user.id);
|
||||
|
||||
const safeUser = toUserResponse(row);
|
||||
res.json({ token: signToken(safeUser), user: safeUser });
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
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 = ? 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) {
|
||||
fields.push('email = ?');
|
||||
values.push(trimmedEmail);
|
||||
}
|
||||
if (trimmedUsername) {
|
||||
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: '비밀번호가 변경되었습니다.' });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
Reference in New Issue
Block a user