374 lines
14 KiB
JavaScript
374 lines
14 KiB
JavaScript
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,
|
|
deletePreviewImage,
|
|
listOrphanPreviewImages,
|
|
deleteOrphanPreviewImages,
|
|
} from '../services/previewImage.js';
|
|
import { getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
|
|
|
|
/**
|
|
* 회원 목록에 저장 덱 수(공개/전체) 부여
|
|
* @param {import('better-sqlite3').Database} db
|
|
* @param {Array<{ id: number }>} users
|
|
* @returns {Array<object>}
|
|
*/
|
|
function attachUserDeckCounts(db, users) {
|
|
if (!users.length) return users;
|
|
|
|
const placeholders = users.map(() => '?').join(',');
|
|
const ids = users.map((u) => u.id);
|
|
const counts = db
|
|
.prepare(
|
|
`
|
|
SELECT user_id AS userId,
|
|
COUNT(*) AS deckTotal,
|
|
SUM(CASE WHEN is_public = 1 THEN 1 ELSE 0 END) AS deckPublic
|
|
FROM decks
|
|
WHERE share_type = 'user' AND user_id IN (${placeholders})
|
|
GROUP BY user_id
|
|
`,
|
|
)
|
|
.all(...ids);
|
|
const countByUser = new Map(counts.map((row) => [row.userId, row]));
|
|
|
|
return users.map((user) => {
|
|
const stats = countByUser.get(user.id);
|
|
return {
|
|
...user,
|
|
deckPublic: stats?.deckPublic ?? 0,
|
|
deckTotal: stats?.deckTotal ?? 0,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 관리자 라우트
|
|
* @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, email_verified_at AS emailVerifiedAt,
|
|
last_login_at AS lastLoginAt,
|
|
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, email_verified_at AS emailVerifiedAt,
|
|
last_login_at AS lastLoginAt,
|
|
created_at AS createdAt, updated_at AS updatedAt
|
|
FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?
|
|
`).all(Number(limit), offset);
|
|
}
|
|
|
|
res.json({
|
|
users: attachUserDeckCounts(
|
|
db,
|
|
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, email_verified_at AS emailVerifiedAt,
|
|
last_login_at AS lastLoginAt,
|
|
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 deckTotal = db.prepare(
|
|
"SELECT COUNT(*) AS c FROM decks WHERE user_id = ? AND share_type = 'user'",
|
|
).get(user.id).c;
|
|
const deckPublic = db.prepare(
|
|
"SELECT COUNT(*) AS c FROM decks WHERE user_id = ? AND share_type = 'user' AND is_public = 1",
|
|
).get(user.id).c;
|
|
user.avatarUrl = getAvatarUrl(user.id) || user.avatarUrl;
|
|
res.json({ user, deckCount: deckTotal, deckPublic, deckTotal });
|
|
});
|
|
|
|
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) {
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail) || trimmedEmail.length > 254) {
|
|
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
|
|
}
|
|
fields.push('email = ?');
|
|
values.push(trimmedEmail);
|
|
}
|
|
if (trimmedUsername) {
|
|
if (
|
|
trimmedUsername.length < 2 ||
|
|
trimmedUsername.length > 30 ||
|
|
!/^[\p{L}\p{N}_-]+$/u.test(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, targetId);
|
|
if (duplicate) {
|
|
return res.status(409).json({ error: '이미 사용 중인 사용자명입니다.' });
|
|
}
|
|
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, email_verified_at AS emailVerifiedAt,
|
|
last_login_at AS lastLoginAt,
|
|
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/verify-email', (req, res) => {
|
|
const targetId = Number(req.params.id);
|
|
const user = db.prepare(`
|
|
SELECT id, email_verified_at AS emailVerifiedAt
|
|
FROM users WHERE id = ?
|
|
`).get(targetId);
|
|
if (!user) {
|
|
return res.status(404).json({ error: '사용자를 찾을 수 없습니다.' });
|
|
}
|
|
|
|
const verifiedAt = user.emailVerifiedAt || nowIso();
|
|
const transaction = db.transaction(() => {
|
|
db.prepare(`
|
|
UPDATE users
|
|
SET email_verified_at = ?, updated_at = ?
|
|
WHERE id = ?
|
|
`).run(verifiedAt, nowIso(), targetId);
|
|
db.prepare(`
|
|
UPDATE auth_tokens
|
|
SET used_at = ?
|
|
WHERE user_id = ? AND type = 'verify_email' AND used_at IS NULL
|
|
`).run(nowIso(), targetId);
|
|
});
|
|
transaction();
|
|
|
|
res.json({
|
|
ok: true,
|
|
emailVerifiedAt: verifiedAt,
|
|
message: '이메일 인증을 승인했습니다.',
|
|
});
|
|
});
|
|
|
|
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.get('/previews/orphans', (_req, res) => {
|
|
const referencedCodes = db.prepare('SELECT share_code FROM decks').all().map((row) => row.share_code);
|
|
const files = listOrphanPreviewImages(referencedCodes);
|
|
res.json({
|
|
files,
|
|
totalCount: files.length,
|
|
totalBytes: files.reduce((sum, file) => sum + file.size, 0),
|
|
});
|
|
});
|
|
|
|
router.delete('/previews/orphans', (_req, res) => {
|
|
const referencedCodes = db.prepare('SELECT share_code FROM decks').all().map((row) => row.share_code);
|
|
res.json({ ok: true, ...deleteOrphanPreviewImages(referencedCodes) });
|
|
});
|
|
|
|
router.delete('/decks/:shareCode', (req, res) => {
|
|
const row = db.prepare('SELECT id, share_code 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);
|
|
const previewDeleted = deletePreviewImage(row.share_code);
|
|
res.json({ ok: true, previewDeleted });
|
|
});
|
|
|
|
return router;
|
|
}
|