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:
250
api/src/routes/admin.js
Normal file
250
api/src/routes/admin.js
Normal 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
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;
|
||||
}
|
||||
290
api/src/routes/decks.js
Normal file
290
api/src/routes/decks.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user