정합성·보안·인덱스 개선
- health/시작 로그 버전을 package.json 기준으로 동적화 - express-rate-limit으로 로그인·이메일 쓰기 요청 IP 기준 제한, trust proxy 설정 - nginx 보안 헤더(nosniff·X-Frame-Options·Referrer-Policy) 추가 - 공개 덱 목록용 복합 인덱스 추가 - 서버 페이지네이션은 후속 과제로 todo에 기록 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "uadeck-api",
|
||||
"version": "4.0.20",
|
||||
"version": "4.0.21",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
@@ -14,6 +14,7 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^8.5.2",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ 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 INDEX IF NOT EXISTS idx_decks_public_list
|
||||
ON decks(share_type, is_public, updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS deck_favorites (
|
||||
user_id INTEGER NOT NULL,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { initDb, seedAdmin } from './db.js';
|
||||
@@ -12,6 +13,21 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'uadeck.db');
|
||||
|
||||
/**
|
||||
* package.json에서 서비스 버전을 읽는다. (실패 시 'unknown')
|
||||
* @returns {string}
|
||||
*/
|
||||
function readAppVersion() {
|
||||
try {
|
||||
const pkgRaw = fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8');
|
||||
return JSON.parse(pkgRaw).version || 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
const APP_VERSION = readAppVersion();
|
||||
|
||||
if (!process.env.DATAS_PATH) {
|
||||
process.env.DATAS_PATH = path.join(__dirname, '..', '..', 'datas');
|
||||
}
|
||||
@@ -21,6 +37,9 @@ seedAdmin(db);
|
||||
|
||||
const app = express();
|
||||
|
||||
// nginx 프록시 1홉 뒤에서 실행 → rate limit이 실제 클라이언트 IP(X-Forwarded-For)를 쓰도록
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
const corsOrigins = process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',').map((o) => o.trim())
|
||||
: true;
|
||||
@@ -29,7 +48,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.16' });
|
||||
res.json({ ok: true, version: APP_VERSION });
|
||||
});
|
||||
|
||||
app.use('/api/v1/auth', createAuthRouter(db));
|
||||
@@ -47,7 +66,7 @@ app.use((err, _req, res, _next) => {
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`UNIARE API v4.0.16 listening on :${PORT}`);
|
||||
console.log(`UNIARE API v${APP_VERSION} listening on :${PORT}`);
|
||||
console.log(`DB: ${DB_PATH}`);
|
||||
console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
@@ -18,6 +19,24 @@ const EMAIL_LIMIT_MESSAGE =
|
||||
'이 계정의 월간 이메일 발송 한도(3회)를 초과했습니다. 관리자 zenn@sori.studio로 직접 문의해 주세요.';
|
||||
const EMAIL_LIMIT_CODE = 'MONTHLY_EMAIL_LIMIT_EXCEEDED';
|
||||
|
||||
/** 로그인 무차별 대입 방어 (IP 기준) */
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 10 * 60 * 1000,
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: '로그인 시도가 너무 많습니다. 잠시 후 다시 시도해 주세요.' },
|
||||
});
|
||||
|
||||
/** 계정·이메일 관련 쓰기 요청 남용 방어 (IP 기준) */
|
||||
const authWriteLimiter = rateLimit({
|
||||
windowMs: 10 * 60 * 1000,
|
||||
max: 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: '요청이 너무 많습니다. 잠시 후 다시 시도해 주세요.' },
|
||||
});
|
||||
|
||||
/**
|
||||
* 한국 시간 기준 월 키
|
||||
* @returns {string}
|
||||
@@ -186,7 +205,7 @@ export function createAuthRouter(db) {
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/register', async (req, res, next) => {
|
||||
router.post('/register', authWriteLimiter, async (req, res, next) => {
|
||||
const { email, username, password } = req.body || {};
|
||||
|
||||
if (!email || !username || !password) {
|
||||
@@ -256,7 +275,7 @@ export function createAuthRouter(db) {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/resend-verification', async (req, res, next) => {
|
||||
router.post('/resend-verification', authWriteLimiter, async (req, res, next) => {
|
||||
const normalizedEmail = String(req.body?.email || '').trim().toLowerCase();
|
||||
if (!isValidEmail(normalizedEmail)) {
|
||||
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
|
||||
@@ -306,7 +325,7 @@ export function createAuthRouter(db) {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/verify-email', (req, res) => {
|
||||
router.post('/verify-email', authWriteLimiter, (req, res) => {
|
||||
const token = String(req.body?.token || '');
|
||||
if (!token) {
|
||||
return res.status(400).json({
|
||||
@@ -341,7 +360,7 @@ export function createAuthRouter(db) {
|
||||
res.json({ ok: true, message: '이메일 인증이 완료되었습니다. 로그인해 주세요.' });
|
||||
});
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
router.post('/login', loginLimiter, (req, res) => {
|
||||
const { email, password } = req.body || {};
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: '이메일과 비밀번호를 입력하세요.' });
|
||||
@@ -379,7 +398,7 @@ export function createAuthRouter(db) {
|
||||
res.json({ token: signToken(safeUser), user: safeUser });
|
||||
});
|
||||
|
||||
router.post('/forgot-password', async (req, res, next) => {
|
||||
router.post('/forgot-password', authWriteLimiter, async (req, res, next) => {
|
||||
const normalizedEmail = String(req.body?.email || '').trim().toLowerCase();
|
||||
if (!isValidEmail(normalizedEmail)) {
|
||||
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
|
||||
@@ -426,7 +445,7 @@ export function createAuthRouter(db) {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/reset-password', (req, res) => {
|
||||
router.post('/reset-password', authWriteLimiter, (req, res) => {
|
||||
const token = String(req.body?.token || '');
|
||||
const newPassword = String(req.body?.newPassword || '');
|
||||
if (!token) {
|
||||
|
||||
Reference in New Issue
Block a user