관리자 기능과 계정 즐겨찾기 개선
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "uadeck-api",
|
||||
"version": "4.0.1",
|
||||
"version": "4.0.3",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)'}`);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user