관리자 기능과 계정 즐겨찾기 개선

This commit is contained in:
2026-06-23 14:32:43 +09:00
parent 00e05dbe3f
commit 93e81ce3ea
26 changed files with 804 additions and 914 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "uadeck-api",
"version": "4.0.1",
"version": "4.0.3",
"type": "module",
"main": "src/index.js",
"scripts": {

View File

@@ -52,6 +52,17 @@ export function initDb(dbPath) {
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);
CREATE TABLE IF NOT EXISTS deck_favorites (
user_id INTEGER NOT NULL,
share_code TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, share_code),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (share_code) REFERENCES decks(share_code) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_deck_favorites_share_code ON deck_favorites(share_code);
`);
const deckCols = db.prepare('PRAGMA table_info(decks)').all();

View File

@@ -29,7 +29,7 @@ app.use(cors({ origin: corsOrigins, credentials: true }));
app.use(express.json({ limit: '20mb' }));
app.get('/api/v1/health', (_req, res) => {
res.json({ ok: true, version: '4.0.1' });
res.json({ ok: true, version: '4.0.3' });
});
app.use('/api/v1/auth', createAuthRouter(db));
@@ -47,7 +47,7 @@ app.use((err, _req, res, _next) => {
});
app.listen(PORT, () => {
console.log(`UNIARE API v4.0.1 listening on :${PORT}`);
console.log(`UNIARE API v4.0.3 listening on :${PORT}`);
console.log(`DB: ${DB_PATH}`);
console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
});

View File

@@ -3,7 +3,12 @@ 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 {
getPreviewUrl,
deletePreviewImage,
listOrphanPreviewImages,
deleteOrphanPreviewImages,
} from '../services/previewImage.js';
import { getAvatarUrl, saveAvatarImage, deleteAvatarImage } from '../services/avatarImage.js';
/**
@@ -237,13 +242,29 @@ export function createAdminRouter(db) {
});
});
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 FROM decks WHERE share_code = ?').get(req.params.shareCode);
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);
res.json({ ok: true });
const previewDeleted = deletePreviewImage(row.share_code);
res.json({ ok: true, previewDeleted });
});
return router;

View File

@@ -3,7 +3,12 @@ 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';
import {
savePreviewImage,
getPreviewFilePath,
getPreviewUrl,
deletePreviewImage,
} from '../services/previewImage.js';
/**
* 덱 행을 API 응답 형식으로 변환
@@ -57,6 +62,84 @@ function getAuthor(db, userId) {
export function createDecksRouter(db) {
const router = Router();
/**
* 현재 사용자가 접근할 수 없는 즐겨찾기 정리
* @param {number} userId
*/
function cleanupUnavailableFavorites(userId) {
db.prepare(`
DELETE FROM deck_favorites
WHERE user_id = ?
AND share_code IN (
SELECT f.share_code
FROM deck_favorites f
JOIN decks d ON d.share_code = f.share_code
WHERE f.user_id = ?
AND (
(d.expires_at IS NOT NULL AND d.expires_at <= datetime('now'))
OR (d.is_public = 0 AND (d.user_id IS NULL OR d.user_id != ?))
)
)
`).run(userId, userId, userId);
}
router.get('/favorites', requireAuth, (req, res) => {
cleanupUnavailableFavorites(req.user.id);
const favorites = db.prepare(`
SELECT share_code
FROM deck_favorites
WHERE user_id = ?
ORDER BY created_at DESC
`).all(req.user.id);
res.json({ favorites: favorites.map((row) => row.share_code) });
});
router.post('/favorites', requireAuth, (req, res) => {
const shareCodes = Array.isArray(req.body?.shareCodes)
? [...new Set(req.body.shareCodes.filter((code) => typeof code === 'string' && code.length <= 64))].slice(0, 500)
: [];
const insert = db.prepare(`
INSERT OR IGNORE INTO deck_favorites (user_id, share_code)
SELECT ?, share_code
FROM decks
WHERE share_code = ?
AND (expires_at IS NULL OR expires_at > datetime('now'))
AND (is_public = 1 OR user_id = ?)
`);
const sync = db.transaction((codes) => {
codes.forEach((code) => insert.run(req.user.id, code, req.user.id));
});
sync(shareCodes);
cleanupUnavailableFavorites(req.user.id);
const favorites = db.prepare(`
SELECT share_code FROM deck_favorites WHERE user_id = ? ORDER BY created_at DESC
`).all(req.user.id);
res.json({ favorites: favorites.map((row) => row.share_code) });
});
router.put('/favorites/:shareCode', requireAuth, (req, res) => {
const deck = db.prepare(`
SELECT share_code
FROM decks
WHERE share_code = ?
AND (expires_at IS NULL OR expires_at > datetime('now'))
AND (is_public = 1 OR user_id = ?)
`).get(req.params.shareCode, req.user.id);
if (!deck) {
return res.status(404).json({ error: '즐겨찾기에 추가할 수 있는 덱을 찾을 수 없습니다.' });
}
db.prepare(`
INSERT OR IGNORE INTO deck_favorites (user_id, share_code) VALUES (?, ?)
`).run(req.user.id, deck.share_code);
res.json({ ok: true, favorite: true });
});
router.delete('/favorites/:shareCode', requireAuth, (req, res) => {
db.prepare('DELETE FROM deck_favorites WHERE user_id = ? AND share_code = ?')
.run(req.user.id, req.params.shareCode);
res.json({ ok: true, favorite: false });
});
router.get('/presets', async (_req, res, next) => {
try {
const rows = db.prepare(`
@@ -283,7 +366,8 @@ export function createDecksRouter(db) {
}
db.prepare('DELETE FROM decks WHERE id = ?').run(row.id);
res.json({ ok: true });
const previewDeleted = deletePreviewImage(row.share_code);
res.json({ ok: true, previewDeleted });
});
return router;

View File

@@ -53,3 +53,59 @@ export function getPreviewUrl(shareCode, previewImageRecord = null) {
}
return null;
}
/**
* 공유 코드에 연결된 미리보기 파일 삭제
* @param {string} shareCode
* @returns {boolean}
*/
export function deletePreviewImage(shareCode) {
const filePath = path.join(getPreviewsDir(), `${shareCode}.png`);
if (!fs.existsSync(filePath)) return false;
try {
fs.unlinkSync(filePath);
return true;
} catch (err) {
console.warn(`[preview] 삭제 실패: ${filePath}`, err);
return false;
}
}
/**
* DB에서 참조하지 않는 미리보기 파일 목록
* @param {Iterable<string>} referencedShareCodes
* @returns {Array<{fileName: string, size: number, modifiedAt: string}>}
*/
export function listOrphanPreviewImages(referencedShareCodes) {
const referenced = new Set([...referencedShareCodes].map((code) => `${code}.png`));
return fs
.readdirSync(getPreviewsDir(), { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.png'))
.filter((entry) => !referenced.has(entry.name))
.map((entry) => {
const stat = fs.statSync(path.join(getPreviewsDir(), entry.name));
return {
fileName: entry.name,
size: stat.size,
modifiedAt: stat.mtime.toISOString(),
};
})
.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
}
/**
* 미참조 미리보기 파일 일괄 삭제
* @param {Iterable<string>} referencedShareCodes
* @returns {{deletedCount: number, deletedBytes: number}}
*/
export function deleteOrphanPreviewImages(referencedShareCodes) {
const orphans = listOrphanPreviewImages(referencedShareCodes);
let deletedBytes = 0;
orphans.forEach((file) => {
fs.unlinkSync(path.join(getPreviewsDir(), file.fileName));
deletedBytes += file.size;
});
return { deletedCount: orphans.length, deletedBytes };
}