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:
2026-06-22 16:59:36 +09:00
parent f8c954f24b
commit dbf02241e2
56 changed files with 6732 additions and 183 deletions

3
api/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
data/
.env

18
api/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:22-alpine
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY src ./src
ENV NODE_ENV=production
ENV DATAS_PATH=/datas
ENV DB_PATH=/data/uadeck.db
EXPOSE 3000
CMD ["node", "src/index.js"]

18
api/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "uadeck-api",
"version": "4.0.0",
"type": "module",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"dependencies": {
"bcryptjs": "^3.0.2",
"better-sqlite3": "^11.9.1",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"jsonwebtoken": "^9.0.2"
}
}

102
api/src/db.js Normal file
View File

@@ -0,0 +1,102 @@
import Database from 'better-sqlite3';
import bcrypt from 'bcryptjs';
import fs from 'fs';
import path from 'path';
/**
* SQLite DB 연결 및 스키마 초기화
* @param {string} dbPath
* @returns {import('better-sqlite3').Database}
*/
export function initDb(dbPath) {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
avatar_url TEXT,
role TEXT NOT NULL DEFAULT 'user',
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS decks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
share_code TEXT NOT NULL UNIQUE,
share_type TEXT NOT NULL DEFAULT 'user',
content_hash TEXT,
user_id INTEGER,
series TEXT NOT NULL,
series_name TEXT,
name TEXT NOT NULL DEFAULT '',
cards_json TEXT NOT NULL,
is_public INTEGER NOT NULL DEFAULT 1,
view_count INTEGER NOT NULL DEFAULT 0,
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_decks_user_id ON decks(user_id);
CREATE INDEX IF NOT EXISTS idx_decks_content_hash ON decks(content_hash);
`);
const deckCols = db.prepare('PRAGMA table_info(decks)').all();
if (!deckCols.some((c) => c.name === 'preview_image')) {
db.exec('ALTER TABLE decks ADD COLUMN preview_image TEXT');
}
const userCols = db.prepare('PRAGMA table_info(users)').all();
if (!userCols.some((c) => c.name === 'default_deck_public')) {
db.exec('ALTER TABLE users ADD COLUMN default_deck_public INTEGER NOT NULL DEFAULT 1');
}
if (!userCols.some((c) => c.name === 'skip_deck_public_prompt')) {
db.exec('ALTER TABLE users ADD COLUMN skip_deck_public_prompt INTEGER NOT NULL DEFAULT 0');
}
return db;
}
/**
* 환경변수 기준 관리자 계정 시드
* ADMIN_EMAIL로 이미 가입한 경우 역할만 admin으로 승격한다.
* @param {import('better-sqlite3').Database} db
*/
export function seedAdmin(db) {
const email = process.env.ADMIN_EMAIL?.trim().toLowerCase();
const password = process.env.ADMIN_PASSWORD;
const username = process.env.ADMIN_USERNAME || 'admin';
if (!email || !password) return;
const existingAdmin = db.prepare('SELECT id FROM users WHERE role = ?').get('admin');
if (existingAdmin) return;
const existingUser = db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (existingUser) {
db.prepare(`
UPDATE users SET role = 'admin', updated_at = datetime('now')
WHERE id = ?
`).run(existingUser.id);
return;
}
const hash = bcrypt.hashSync(password, 12);
db.prepare(`
INSERT INTO users (email, username, password_hash, display_name, role)
VALUES (?, ?, ?, ?, 'admin')
`).run(email, username, hash, '관리자');
}

48
api/src/index.js Normal file
View File

@@ -0,0 +1,48 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import path from 'path';
import { fileURLToPath } from 'url';
import { initDb, seedAdmin } from './db.js';
import { createAuthRouter } from './routes/auth.js';
import { createDecksRouter } from './routes/decks.js';
import { createAdminRouter } from './routes/admin.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT) || 3000;
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'uadeck.db');
if (!process.env.DATAS_PATH) {
process.env.DATAS_PATH = path.join(__dirname, '..', '..', 'datas');
}
const db = initDb(DB_PATH);
seedAdmin(db);
const app = express();
const corsOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map((o) => o.trim())
: true;
app.use(cors({ origin: corsOrigins, credentials: true }));
app.use(express.json({ limit: '15mb' }));
app.get('/api/v1/health', (_req, res) => {
res.json({ ok: true, version: '4.0.0' });
});
app.use('/api/v1/auth', createAuthRouter(db));
app.use('/api/v1/decks', createDecksRouter(db));
app.use('/api/v1/admin', createAdminRouter(db));
app.use((err, _req, res, _next) => {
console.error(err);
res.status(500).json({ error: '서버 오류가 발생했습니다.' });
});
app.listen(PORT, () => {
console.log(`UADECK API v4.0.0 listening on :${PORT}`);
console.log(`DB: ${DB_PATH}`);
console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
});

View File

@@ -0,0 +1,91 @@
import jwt from 'jsonwebtoken';
/**
* JWT 시크릿
* @returns {string}
*/
function getSecret() {
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error('JWT_SECRET 환경변수가 설정되지 않았습니다.');
}
return secret;
}
/**
* JWT 토큰 발급
* @param {object} user
* @returns {string}
*/
export function signToken(user) {
return jwt.sign(
{ sub: user.id, role: user.role },
getSecret(),
{ expiresIn: process.env.JWT_EXPIRES || '7d' },
);
}
/**
* 선택적 인증 — 토큰 없으면 req.user = null
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
export function optionalAuth(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
req.user = null;
return next();
}
try {
const payload = jwt.verify(header.slice(7), getSecret());
req.user = { id: payload.sub, role: payload.role };
} catch {
req.user = null;
}
next();
}
/**
* 필수 인증
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
export function requireAuth(req, res, next) {
optionalAuth(req, res, () => {
if (!req.user) {
return res.status(401).json({ error: '로그인이 필요합니다.' });
}
next();
});
}
/**
* 관리자 전용 — JWT가 아닌 DB의 최신 role을 확인한다.
* @param {import('better-sqlite3').Database} db
* @returns {import('express').RequestHandler}
*/
export function createRequireAdmin(db) {
return (req, res, next) => {
requireAuth(req, res, () => {
const row = db.prepare('SELECT role FROM users WHERE id = ? AND is_active = 1').get(req.user.id);
if (!row || row.role !== 'admin') {
return res.status(403).json({ error: '관리자 권한이 필요합니다.' });
}
req.user.role = row.role;
next();
});
};
}
/**
* @deprecated createRequireAdmin(db) 사용 권장
*/
export function requireAdmin(req, res, next) {
if (req.user?.role !== 'admin') {
return res.status(403).json({ error: '관리자 권한이 필요합니다.' });
}
next();
}

250
api/src/routes/admin.js Normal file
View File

@@ -0,0 +1,250 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { createRequireAdmin } from '../middleware/auth.js';
import { contentHash, validateCards, nowIso } from '../utils.js';
import { resolveDeckCards } from '../services/cardResolver.js';
import { getPreviewUrl } from '../services/previewImage.js';
import { getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
/**
* 관리자 라우트
* @param {import('better-sqlite3').Database} db
* @returns {import('express').Router}
*/
export function createAdminRouter(db) {
const router = Router();
router.use(createRequireAdmin(db));
router.get('/users', (req, res) => {
const { q, page = 1, limit = 20 } = req.query;
const offset = (Number(page) - 1) * Number(limit);
let rows;
let total;
if (q) {
const like = `%${q}%`;
total = db.prepare(`
SELECT COUNT(*) AS c FROM users
WHERE email LIKE ? OR username LIKE ? OR display_name LIKE ?
`).get(like, like, like).c;
rows = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
FROM users
WHERE email LIKE ? OR username LIKE ? OR display_name LIKE ?
ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(like, like, like, Number(limit), offset);
} else {
total = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
rows = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?
`).all(Number(limit), offset);
}
res.json({
users: rows.map((u) => ({
...u,
avatarUrl: getAvatarUrl(u.id) || u.avatarUrl,
})),
total,
page: Number(page),
limit: Number(limit),
});
});
router.get('/users/:id', (req, res) => {
const user = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
FROM users WHERE id = ?
`).get(req.params.id);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
const deckCount = db.prepare('SELECT COUNT(*) AS c FROM decks WHERE user_id = ?').get(user.id).c;
user.avatarUrl = getAvatarUrl(user.id) || user.avatarUrl;
res.json({ user, deckCount });
});
router.put('/users/:id', (req, res) => {
const targetId = Number(req.params.id);
const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(targetId);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
const isSelf = req.user.id === targetId;
const { email, username, role, isActive, avatarImage, removeAvatar } = req.body || {};
if (isSelf) {
if (role === 'user') {
return res.status(400).json({ error: '자신의 관리자 권한은 해제할 수 없습니다.' });
}
if (isActive === false) {
return res.status(400).json({ error: '자신의 계정은 비활성화할 수 없습니다.' });
}
}
let nextAvatarUrl;
if (avatarImage) {
const saved = saveAvatarImage(targetId, avatarImage);
if (!saved) {
return res.status(400).json({ error: '프로필 이미지 저장에 실패했습니다.' });
}
nextAvatarUrl = getAvatarUrl(targetId);
} else if (removeAvatar === true) {
deleteAvatarImage(targetId);
nextAvatarUrl = null;
}
const trimmedUsername = username?.trim() ?? null;
const trimmedEmail = email?.trim().toLowerCase() ?? null;
try {
const fields = ['updated_at = ?'];
const values = [nowIso()];
if (trimmedEmail) {
fields.push('email = ?');
values.push(trimmedEmail);
}
if (trimmedUsername) {
fields.push('username = ?', 'display_name = ?');
values.push(trimmedUsername, trimmedUsername);
}
if (!isSelf && role) {
fields.push('role = ?');
values.push(role);
}
if (!isSelf && isActive !== undefined) {
fields.push('is_active = ?');
values.push(isActive ? 1 : 0);
}
if (nextAvatarUrl !== undefined) {
fields.push('avatar_url = ?');
values.push(nextAvatarUrl);
}
values.push(targetId);
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 updated = db.prepare(`
SELECT id, email, username, display_name AS displayName, avatar_url AS avatarUrl,
role, is_active AS isActive, created_at AS createdAt, updated_at AS updatedAt
FROM users WHERE id = ?
`).get(targetId);
updated.avatarUrl = getAvatarUrl(updated.id) || updated.avatarUrl;
res.json({ user: updated });
});
router.post('/users/:id/reset-password', (req, res) => {
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id);
if (!user) {
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
}
const { newPassword } = req.body || {};
if (!newPassword || newPassword.length < 8) {
return res.status(400).json({ error: '새 비밀번호는 8자 이상이어야 합니다.' });
}
const hash = bcrypt.hashSync(newPassword, 12);
db.prepare('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?').run(hash, nowIso(), req.params.id);
res.json({ ok: true, message: '비밀번호가 초기화되었습니다.' });
});
router.post('/decks/preset', async (req, res, next) => {
try {
const { series, seriesName, name, cards, isPublic } = req.body || {};
if (!series || !cards || !name) {
return res.status(400).json({ error: 'series, name, cards는 필수입니다.' });
}
const normalized = validateCards(cards);
const hash = contentHash(normalized);
const existing = db.prepare('SELECT share_code FROM decks WHERE content_hash = ? AND share_type = ?').get(hash, 'preset');
if (existing) {
return res.status(409).json({
error: '동일한 구성의 프리셋이 이미 존재합니다.',
shareCode: existing.share_code,
});
}
const cardsJson = JSON.stringify(normalized);
db.prepare(`
INSERT INTO decks (share_code, share_type, content_hash, user_id, series, series_name, name, cards_json, is_public)
VALUES (?, 'preset', ?, ?, ?, ?, ?, ?, ?)
`).run(
hash,
hash,
req.user.id,
series,
seriesName || series,
name,
cardsJson,
isPublic === false ? 0 : 1,
);
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(hash);
const { cards: resolved, totalCards } = await resolveDeckCards(series, normalized);
res.status(201).json({
v: 1,
shareCode: row.share_code,
shareType: 'preset',
series: row.series,
seriesName: row.series_name,
name: row.name,
totalCards,
cards: resolved,
});
} catch (err) {
if (err.message?.includes('카드') || err.message?.includes('덱')) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
router.get('/decks', (req, res) => {
const rows = db.prepare(`
SELECT d.id, d.share_code AS shareCode, d.share_type AS shareType, d.series, d.series_name AS seriesName,
d.name, d.is_public AS isPublic, d.view_count AS viewCount, d.created_at AS createdAt,
d.preview_image AS previewImage, d.updated_at AS updatedAt,
u.username, u.id AS userId
FROM decks d LEFT JOIN users u ON d.user_id = u.id
ORDER BY d.created_at DESC LIMIT 100
`).all();
res.json({
decks: rows.map((row) => ({
...row,
preview_url: getPreviewUrl(row.shareCode, row.previewImage),
})),
});
});
router.delete('/decks/:shareCode', (req, res) => {
const row = db.prepare('SELECT id FROM decks WHERE share_code = ?').get(req.params.shareCode);
if (!row) {
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
}
db.prepare('DELETE FROM decks WHERE id = ?').run(row.id);
res.json({ ok: true });
});
return router;
}

212
api/src/routes/auth.js Normal file
View 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;
}

290
api/src/routes/decks.js Normal file
View File

@@ -0,0 +1,290 @@
import { Router } from 'express';
import path from 'path';
import { requireAuth, optionalAuth } from '../middleware/auth.js';
import { resolveDeckCards } from '../services/cardResolver.js';
import { generateShareCode, validateCards, nowIso } from '../utils.js';
import { savePreviewImage, getPreviewFilePath, getPreviewUrl } from '../services/previewImage.js';
/**
* 덱 행을 API 응답 형식으로 변환
* @param {object} row
* @param {object|null} author
* @param {object[]} resolvedCards
* @param {number} totalCards
* @returns {object}
*/
function toDeckResponse(row, author, resolvedCards, totalCards) {
return {
v: 1,
shareCode: row.share_code,
shareType: row.share_type,
series: row.series,
seriesName: row.series_name,
name: row.name,
totalCards,
cards: resolvedCards,
author,
createdAt: row.created_at,
updatedAt: row.updated_at,
viewCount: row.view_count,
isPublic: Boolean(row.is_public),
expiresAt: row.expires_at,
previewUrl: getPreviewUrl(row.share_code, row.preview_image),
};
}
/**
* 작성자 정보 조회
* @param {import('better-sqlite3').Database} db
* @param {number|null} userId
* @returns {object|null}
*/
function getAuthor(db, userId) {
if (!userId) return null;
const user = db.prepare(`
SELECT id, username, avatar_url AS avatarUrl
FROM users WHERE id = ? AND is_active = 1
`).get(userId);
if (!user) return null;
return { ...user, displayName: user.username };
}
/**
* 덱 라우트
* @param {import('better-sqlite3').Database} db
* @returns {import('express').Router}
*/
export function createDecksRouter(db) {
const router = Router();
router.get('/presets', async (_req, res, next) => {
try {
const rows = db.prepare(`
SELECT d.share_code, d.series, d.series_name, d.name, d.view_count,
d.preview_image, d.created_at, d.updated_at,
u.display_name, u.username
FROM decks d
LEFT JOIN users u ON d.user_id = u.id
WHERE d.share_type = 'user' AND d.is_public = 1
AND (d.expires_at IS NULL OR d.expires_at > datetime('now'))
ORDER BY d.updated_at DESC
`).all();
res.json({
presets: rows.map((row) => ({
share_code: row.share_code,
series: row.series,
series_name: row.series_name,
name: row.name,
view_count: row.view_count,
author: row.username || '익명',
created_at: row.created_at,
updated_at: row.updated_at,
preview_url: getPreviewUrl(row.share_code, row.preview_image),
})),
});
} catch (err) {
next(err);
}
});
router.get('/:shareCode/preview', (req, res) => {
const filePath = getPreviewFilePath(req.params.shareCode);
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.get('/:shareCode', optionalAuth, async (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(req.params.shareCode);
if (!row) {
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
}
if (row.expires_at && new Date(row.expires_at) < new Date()) {
return res.status(410).json({ error: '만료된 공유 링크입니다.' });
}
const isOwner = req.user && row.user_id === req.user.id;
const isAdmin = req.user?.role === 'admin';
if (!row.is_public && !isOwner && !isAdmin) {
return res.status(403).json({ error: '비공개 덱입니다.' });
}
const cards = JSON.parse(row.cards_json);
const { cards: resolved, totalCards } = await resolveDeckCards(row.series, cards);
const author = getAuthor(db, row.user_id);
db.prepare('UPDATE decks SET view_count = view_count + 1 WHERE id = ?').run(row.id);
row.view_count += 1;
res.json(toDeckResponse(row, author, resolved, totalCards));
} catch (err) {
next(err);
}
});
router.get('/', requireAuth, async (req, res, next) => {
try {
const rows = db.prepare(`
SELECT share_code, share_type, series, series_name, name, is_public, view_count,
preview_image, created_at, updated_at
FROM decks WHERE user_id = ? AND share_type = 'user'
ORDER BY updated_at DESC
`).all(req.user.id);
res.json({
decks: rows.map((row) => ({
share_code: row.share_code,
share_type: row.share_type,
series: row.series,
series_name: row.series_name,
name: row.name,
is_public: row.is_public,
view_count: row.view_count,
created_at: row.created_at,
updated_at: row.updated_at,
preview_url: getPreviewUrl(row.share_code, row.preview_image),
})),
});
} catch (err) {
next(err);
}
});
router.post('/', optionalAuth, async (req, res, next) => {
try {
const { series, seriesName, name, cards, isPublic, previewImage } = req.body || {};
if (!series || !cards) {
return res.status(400).json({ error: 'series와 cards는 필수입니다.' });
}
const normalized = validateCards(cards);
const cardsJson = JSON.stringify(normalized);
const shareCode = generateShareCode();
const userId = req.user?.id ?? null;
const expiresAt = userId ? null : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
const isPublicValue = userId ? (isPublic === true ? 1 : 0) : 1;
let previewFile = null;
if (previewImage) {
previewFile = savePreviewImage(shareCode, previewImage);
if (!previewFile) {
console.warn(`[preview] 저장 실패: ${shareCode} (dataUrl 길이 ${previewImage.length})`);
}
}
db.prepare(`
INSERT INTO decks (share_code, share_type, user_id, series, series_name, name, cards_json, is_public, expires_at, preview_image)
VALUES (?, 'user', ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
shareCode,
userId,
series,
seriesName || series,
name || '',
cardsJson,
isPublicValue,
expiresAt,
previewFile,
);
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(shareCode);
const { cards: resolved, totalCards } = await resolveDeckCards(series, normalized);
const author = getAuthor(db, userId);
res.status(201).json(toDeckResponse(row, author, resolved, totalCards));
} catch (err) {
if (err.message?.includes('카드') || err.message?.includes('덱')) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
router.put('/:shareCode', requireAuth, async (req, res, next) => {
try {
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(req.params.shareCode);
if (!row) {
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
}
if (row.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: '수정 권한이 없습니다.' });
}
if (row.share_type === 'preset' && req.user.role !== 'admin') {
return res.status(403).json({ error: '레거시 프리셋 덱은 관리자만 수정할 수 있습니다.' });
}
const { name, cards, isPublic, seriesName, previewImage } = req.body || {};
let cardsJson = row.cards_json;
let series = row.series;
let previewFile = row.preview_image;
if (cards) {
const normalized = validateCards(cards);
cardsJson = JSON.stringify(normalized);
}
if (req.body?.series) {
series = req.body.series;
}
if (previewImage) {
previewFile = savePreviewImage(row.share_code, previewImage);
}
db.prepare(`
UPDATE decks SET
name = COALESCE(?, name),
series_name = COALESCE(?, series_name),
series = ?,
cards_json = ?,
is_public = COALESCE(?, is_public),
preview_image = COALESCE(?, preview_image),
updated_at = ?
WHERE id = ?
`).run(
name ?? null,
seriesName ?? null,
series,
cardsJson,
isPublic === undefined ? null : (isPublic ? 1 : 0),
previewFile,
nowIso(),
row.id,
);
const updated = db.prepare('SELECT * FROM decks WHERE id = ?').get(row.id);
const parsed = JSON.parse(updated.cards_json);
const { cards: resolved, totalCards } = await resolveDeckCards(updated.series, parsed);
res.json(toDeckResponse(updated, getAuthor(db, updated.user_id), resolved, totalCards));
} catch (err) {
if (err.message?.includes('카드') || err.message?.includes('덱')) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
router.delete('/:shareCode', requireAuth, (req, res) => {
const row = db.prepare('SELECT * FROM decks WHERE share_code = ?').get(req.params.shareCode);
if (!row) {
return res.status(404).json({ error: '덱을 찾을 수 없습니다.' });
}
if (row.user_id !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: '삭제 권한이 없습니다.' });
}
db.prepare('DELETE FROM decks WHERE id = ?').run(row.id);
res.json({ ok: true });
});
return router;
}

View File

@@ -0,0 +1,65 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* 아바타 이미지 저장 디렉터리
* @returns {string}
*/
export function getAvatarsDir() {
const dir = process.env.AVATARS_PATH || path.join(__dirname, '..', '..', 'data', 'avatars');
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
return dir;
}
/**
* @param {number|string} userId
* @returns {string}
*/
export function getAvatarFilePath(userId) {
const filePath = path.join(getAvatarsDir(), `${userId}.png`);
return fs.existsSync(filePath) ? filePath : null;
}
/**
* @param {number|string} userId
* @returns {string|null}
*/
export function getAvatarUrl(userId) {
return getAvatarFilePath(userId) ? `/api/v1/auth/avatar/${userId}` : null;
}
/**
* base64 data URL을 PNG로 저장
* @param {number|string} userId
* @param {string} dataUrl
* @returns {string|null}
*/
export function saveAvatarImage(userId, dataUrl) {
if (!dataUrl || !dataUrl.startsWith('data:image')) {
return null;
}
const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, '');
if (!base64) {
return null;
}
const fileName = `${userId}.png`;
const filePath = path.join(getAvatarsDir(), fileName);
fs.writeFileSync(filePath, Buffer.from(base64, 'base64'));
return fs.existsSync(filePath) ? fileName : null;
}
/**
* 아바타 이미지 삭제
* @param {number|string} userId
*/
export function deleteAvatarImage(userId) {
const filePath = path.join(getAvatarsDir(), `${userId}.png`);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}

View File

@@ -0,0 +1,65 @@
import { pathToFileURL } from 'url';
import path from 'path';
/** @type {Map<string, Map<string, object>>} */
const cache = new Map();
/**
* 시리즈 카드 맵 로드
* @param {string} seriesKey
* @returns {Promise<Map<string, object>>}
*/
export async function getCardMap(seriesKey) {
if (cache.has(seriesKey)) {
return cache.get(seriesKey);
}
const datasPath = process.env.DATAS_PATH || path.join(process.cwd(), '..', 'datas');
const filePath = path.join(datasPath, `${seriesKey}.js`);
let mod;
try {
mod = await import(pathToFileURL(filePath).href);
} catch {
throw new Error(`시리즈 데이터를 찾을 수 없습니다: ${seriesKey}`);
}
const byNumber = new Map();
for (const card of mod.cardList) {
byNumber.set(card.Number, card);
}
cache.set(seriesKey, byNumber);
return byNumber;
}
/**
* 저장된 덱 카드를 풀 카드 객체로 합성
* @param {string} seriesKey
* @param {Array<{number: string, count: number, key: boolean}>} cards
* @returns {Promise<{cards: object[], totalCards: number}>}
*/
export async function resolveDeckCards(seriesKey, cards) {
const map = await getCardMap(seriesKey);
const resolved = [];
let totalCards = 0;
for (const item of cards) {
const base = map.get(item.number);
if (!base) continue;
totalCards += item.count;
resolved.push({
number: base.Number,
count: item.count,
key: item.key,
imgSrc: base.imgSrc,
color: base.color,
rarity: base.rarity,
trigger: base.trigger ?? null,
parallel: Boolean(base.parallel),
});
}
return { cards: resolved, totalCards };
}

View File

@@ -0,0 +1,55 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* 미리보기 이미지 저장 디렉터리
* @returns {string}
*/
export function getPreviewsDir() {
const dir = process.env.PREVIEWS_PATH || path.join(__dirname, '..', '..', 'data', 'previews');
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
return dir;
}
/**
* base64 data URL을 PNG 파일로 저장
* @param {string} shareCode
* @param {string} dataUrl
* @returns {string} 파일명
*/
export function savePreviewImage(shareCode, dataUrl) {
if (!dataUrl || !dataUrl.startsWith('data:image')) {
return null;
}
const base64 = dataUrl.replace(/^data:image\/\w+;base64,/, '');
const fileName = `${shareCode}.png`;
fs.writeFileSync(path.join(getPreviewsDir(), fileName), Buffer.from(base64, 'base64'));
return fileName;
}
/**
* @param {string} shareCode
* @returns {string|null}
*/
export function getPreviewFilePath(shareCode) {
const filePath = path.join(getPreviewsDir(), `${shareCode}.png`);
return fs.existsSync(filePath) ? filePath : null;
}
/**
* @param {string} shareCode
* @param {string|null} [previewImageRecord] DB preview_image 컬럼
* @returns {string|null}
*/
export function getPreviewUrl(shareCode, previewImageRecord = null) {
const hasFile = Boolean(getPreviewFilePath(shareCode));
if (hasFile || previewImageRecord) {
return `/api/v1/decks/${shareCode}/preview`;
}
return null;
}

85
api/src/utils.js Normal file
View File

@@ -0,0 +1,85 @@
import crypto from 'crypto';
const SHARE_ALPHABET = '0123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
/**
* URL-safe 공유 코드 생성
* @param {number} [length=8]
* @returns {string}
*/
export function generateShareCode(length = 8) {
const bytes = crypto.randomBytes(length);
let code = '';
for (let i = 0; i < length; i++) {
code += SHARE_ALPHABET[bytes[i] % SHARE_ALPHABET.length];
}
return code;
}
/**
* 덱 본문 정규화 (해시용)
* @param {Array<{number: string, count: number, key: boolean}>} cards
* @returns {string}
*/
export function normalizeCards(cards) {
const sorted = [...cards]
.map((c) => ({
number: String(c.number).trim(),
count: Number(c.count),
key: Boolean(c.key),
}))
.sort((a, b) => a.number.localeCompare(b.number));
return JSON.stringify(sorted);
}
/**
* 프리셋 덱용 내용 해시 (8자)
* @param {Array<{number: string, count: number, key: boolean}>} cards
* @returns {string}
*/
export function contentHash(cards) {
const hash = crypto.createHash('sha256').update(normalizeCards(cards)).digest('hex');
return hash.slice(0, 8);
}
/**
* 덱 카드 배열 검증
* @param {unknown} cards
* @returns {Array<{number: string, count: number, key: boolean}>}
*/
export function validateCards(cards) {
if (!Array.isArray(cards) || cards.length === 0) {
throw new Error('카드 목록이 비어 있습니다.');
}
let total = 0;
const normalized = cards.map((c) => {
if (!c.number || c.count == null) {
throw new Error('카드 형식이 올바르지 않습니다.');
}
const count = Number(c.count);
if (!Number.isInteger(count) || count < 1 || count > 4) {
throw new Error(`잘못된 수량: ${c.number}`);
}
total += count;
return {
number: String(c.number).trim(),
count,
key: Boolean(c.key),
};
});
if (total > 50) {
throw new Error('덱은 최대 50장까지 가능합니다.');
}
return normalized;
}
/**
* ISO 현재 시각
* @returns {string}
*/
export function nowIso() {
return new Date().toISOString();
}