Files
uniare.sori.studio/api/src/routes/admin.js

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;
}