정합성·보안·인덱스 개선
- 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",
|
"name": "uadeck-api",
|
||||||
"version": "4.0.20",
|
"version": "4.0.21",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"express-rate-limit": "^8.5.2",
|
||||||
"jsonwebtoken": "^9.0.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_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_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 (
|
CREATE TABLE IF NOT EXISTS deck_favorites (
|
||||||
user_id INTEGER NOT NULL,
|
user_id INTEGER NOT NULL,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { initDb, seedAdmin } from './db.js';
|
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 PORT = Number(process.env.PORT) || 3000;
|
||||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'uadeck.db');
|
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) {
|
if (!process.env.DATAS_PATH) {
|
||||||
process.env.DATAS_PATH = path.join(__dirname, '..', '..', 'datas');
|
process.env.DATAS_PATH = path.join(__dirname, '..', '..', 'datas');
|
||||||
}
|
}
|
||||||
@@ -21,6 +37,9 @@ seedAdmin(db);
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// nginx 프록시 1홉 뒤에서 실행 → rate limit이 실제 클라이언트 IP(X-Forwarded-For)를 쓰도록
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
const corsOrigins = process.env.CORS_ORIGINS
|
const corsOrigins = process.env.CORS_ORIGINS
|
||||||
? process.env.CORS_ORIGINS.split(',').map((o) => o.trim())
|
? process.env.CORS_ORIGINS.split(',').map((o) => o.trim())
|
||||||
: true;
|
: true;
|
||||||
@@ -29,7 +48,7 @@ app.use(cors({ origin: corsOrigins, credentials: true }));
|
|||||||
app.use(express.json({ limit: '20mb' }));
|
app.use(express.json({ limit: '20mb' }));
|
||||||
|
|
||||||
app.get('/api/v1/health', (_req, res) => {
|
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));
|
app.use('/api/v1/auth', createAuthRouter(db));
|
||||||
@@ -47,7 +66,7 @@ app.use((err, _req, res, _next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
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(`DB: ${DB_PATH}`);
|
||||||
console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
|
console.log(`DATAS: ${process.env.DATAS_PATH || '(default ../datas)'}`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
@@ -18,6 +19,24 @@ const EMAIL_LIMIT_MESSAGE =
|
|||||||
'이 계정의 월간 이메일 발송 한도(3회)를 초과했습니다. 관리자 zenn@sori.studio로 직접 문의해 주세요.';
|
'이 계정의 월간 이메일 발송 한도(3회)를 초과했습니다. 관리자 zenn@sori.studio로 직접 문의해 주세요.';
|
||||||
const EMAIL_LIMIT_CODE = 'MONTHLY_EMAIL_LIMIT_EXCEEDED';
|
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}
|
* @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 || {};
|
const { email, username, password } = req.body || {};
|
||||||
|
|
||||||
if (!email || !username || !password) {
|
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();
|
const normalizedEmail = String(req.body?.email || '').trim().toLowerCase();
|
||||||
if (!isValidEmail(normalizedEmail)) {
|
if (!isValidEmail(normalizedEmail)) {
|
||||||
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
|
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 || '');
|
const token = String(req.body?.token || '');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -341,7 +360,7 @@ export function createAuthRouter(db) {
|
|||||||
res.json({ ok: true, message: '이메일 인증이 완료되었습니다. 로그인해 주세요.' });
|
res.json({ ok: true, message: '이메일 인증이 완료되었습니다. 로그인해 주세요.' });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/login', (req, res) => {
|
router.post('/login', loginLimiter, (req, res) => {
|
||||||
const { email, password } = req.body || {};
|
const { email, password } = req.body || {};
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
return res.status(400).json({ error: '이메일과 비밀번호를 입력하세요.' });
|
return res.status(400).json({ error: '이메일과 비밀번호를 입력하세요.' });
|
||||||
@@ -379,7 +398,7 @@ export function createAuthRouter(db) {
|
|||||||
res.json({ token: signToken(safeUser), user: safeUser });
|
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();
|
const normalizedEmail = String(req.body?.email || '').trim().toLowerCase();
|
||||||
if (!isValidEmail(normalizedEmail)) {
|
if (!isValidEmail(normalizedEmail)) {
|
||||||
return res.status(400).json({ error: '올바른 이메일 주소를 입력하세요.' });
|
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 token = String(req.body?.token || '');
|
||||||
const newPassword = String(req.body?.newPassword || '');
|
const newPassword = String(req.body?.newPassword || '');
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 변경사항
|
# 변경사항
|
||||||
|
|
||||||
|
## v4.0.21
|
||||||
|
|
||||||
|
### 보안·안정성 강화
|
||||||
|
|
||||||
|
로그인과 이메일 발송 요청에 과도한 반복 시도를 막는 보호를 추가하고, 페이지 응답에 표준 보안 헤더를 적용했습니다. 공개 덱 목록 조회 성능도 개선했습니다. 일반 사용에는 영향이 없으며, 비정상적으로 많은 요청만 잠시 제한됩니다.
|
||||||
|
|
||||||
## v4.0.20
|
## v4.0.20
|
||||||
|
|
||||||
### 페이지 로딩 속도 개선
|
### 페이지 로딩 속도 개선
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-07-03 v4.0.21 — 인증 rate limit은 IP 기준 + 프록시 신뢰 1홉
|
||||||
|
|
||||||
|
💡 API가 nginx 뒤에만 노출되므로 `trust proxy=1`로 X-Forwarded-For를 신뢰해야 컨테이너 IP가 아닌 실제 클라이언트 IP로 제한이 걸림.
|
||||||
|
🔧 로그인은 10회/10분으로 좁게, 이메일·계정 쓰기는 30회/10분으로 두고, health 버전은 배포 누락 방지를 위해 package.json에서 읽음.
|
||||||
|
|
||||||
## 2026-07-03 v4.0.20 — 원본 이미지 변환 전 저비용 전송 최적화부터 적용
|
## 2026-07-03 v4.0.20 — 원본 이미지 변환 전 저비용 전송 최적화부터 적용
|
||||||
|
|
||||||
💡 1.8GB PNG의 WebP 일괄 변환은 파이프라인 부담이 커, 리스크가 낮고 효과가 큰 gzip·lazy-load·캐시부터 선적용하기 위함.
|
💡 1.8GB PNG의 WebP 일괄 변환은 파이프라인 부담이 커, 리스크가 낮고 효과가 큰 gzip·lazy-load·캐시부터 선적용하기 위함.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## 프로젝트 개요
|
## 프로젝트 개요
|
||||||
|
|
||||||
- 서비스명: `UNIARE - Union Arena Deck Builder`
|
- 서비스명: `UNIARE - Union Arena Deck Builder`
|
||||||
- 현재 버전: `v4.0.20`
|
- 현재 버전: `v4.0.21`
|
||||||
- 프론트: 정적 HTML, CSS, JavaScript
|
- 프론트: 정적 HTML, CSS, JavaScript
|
||||||
- 백엔드: Express, SQLite(`better-sqlite3`)
|
- 백엔드: Express, SQLite(`better-sqlite3`)
|
||||||
- 운영: Docker Compose 기반 Nginx + API
|
- 운영: Docker Compose 기반 Nginx + API
|
||||||
@@ -78,7 +78,10 @@
|
|||||||
- 덱 삭제·미참조 정리 시 해당 공유 코드의 원본 PNG와 썸네일 WebP를 함께 삭제한다.
|
- 덱 삭제·미참조 정리 시 해당 공유 코드의 원본 PNG와 썸네일 WebP를 함께 삭제한다.
|
||||||
- 관리자는 DB에서 참조하지 않는 미리보기 파일을 조회하고 일괄 삭제할 수 있다.
|
- 관리자는 DB에서 참조하지 않는 미리보기 파일을 조회하고 일괄 삭제할 수 있다.
|
||||||
- nginx는 텍스트 자산(css/js/json/svg 등)을 gzip으로 압축 전송하고, 정적 이미지는 30일 immutable 캐시로 제공한다.
|
- nginx는 텍스트 자산(css/js/json/svg 등)을 gzip으로 압축 전송하고, 정적 이미지는 30일 immutable 캐시로 제공한다.
|
||||||
|
- nginx는 HTML에 `X-Content-Type-Options`/`X-Frame-Options`/`Referrer-Policy`, 정적 자산에 `X-Content-Type-Options: nosniff` 헤더를 적용한다.
|
||||||
- 카드 목록 화면의 카드 이미지는 `loading="lazy"`로 지연 로드한다.
|
- 카드 목록 화면의 카드 이미지는 `loading="lazy"`로 지연 로드한다.
|
||||||
|
- 인증 API는 IP 기준 rate limit을 적용한다. (로그인 10회/10분, 계정·이메일 쓰기 30회/10분) API는 nginx 프록시 1홉을 신뢰한다.
|
||||||
|
- API `/api/v1/health`는 `package.json` 버전을 반환한다.
|
||||||
|
|
||||||
## 브라우저 상태
|
## 브라우저 상태
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
## 유지보수
|
## 유지보수
|
||||||
|
|
||||||
|
- 공개 덱·내 덱 목록에 서버 커서 페이지네이션 + 서버 검색을 도입한다(옵션 B). 즐겨찾기 필터는 별도 조회로 처리하고, `preset.html`·`deckHistory.html`의 클라이언트 전량 로드 전제를 걷어낸다. 회귀 위험이 커 데이터 규모가 커질 때 착수한다.
|
||||||
- 카드 원본 이미지(약 1.8GB PNG)를 WebP로 일괄 변환하고 참조 경로·폴백을 정비해 대역폭을 절감한다.
|
- 카드 원본 이미지(약 1.8GB PNG)를 WebP로 일괄 변환하고 참조 경로·폴백을 정비해 대역폭을 절감한다.
|
||||||
- 유난히 큰 개별 이미지(예: `ua44/…EVA-1-079_p2.png` 1.2MB, `ua14/UAPR_DST-1-039.png` 1.0MB, `logo_unionarena.png` 431KB)를 재압축한다.
|
- 유난히 큰 개별 이미지(예: `ua44/…EVA-1-079_p2.png` 1.2MB, `ua14/UAPR_DST-1-039.png` 1.0MB, `logo_unionarena.png` 431KB)를 재압축한다.
|
||||||
- `cardList.html`, `result.html`의 대형 인라인 스크립트를 기능 단위 모듈로 분리한다.
|
- `cardList.html`, `result.html`의 대형 인라인 스크립트를 기능 단위 모듈로 분리한다.
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## 2026-07-03 / v4.0.21 — 정합성·보안·인덱스 개선
|
||||||
|
|
||||||
|
### 수행 작업
|
||||||
|
- `/api/v1/health` 응답과 시작 로그 버전을 `package.json` 기준으로 동적화(기존 `4.0.16` 고정값 제거)
|
||||||
|
- `express-rate-limit` 도입: 로그인 10회/10분, 계정·이메일 쓰기(회원가입·인증메일·이메일검증·비밀번호 찾기/재설정) 30회/10분(IP 기준)으로 제한
|
||||||
|
- API에 `trust proxy = 1` 설정으로 nginx 뒤에서 실제 클라이언트 IP 기준 제한 적용
|
||||||
|
- nginx 보안 헤더 추가: HTML은 `X-Content-Type-Options`/`X-Frame-Options`/`Referrer-Policy`, 정적 자산(css·js·이미지)은 `X-Content-Type-Options: nosniff`
|
||||||
|
- 공개 덱 목록 조회용 복합 인덱스 `idx_decks_public_list(share_type, is_public, updated_at)` 추가
|
||||||
|
|
||||||
|
### 검증
|
||||||
|
- 서버 모듈 `node --check` 및 auth 라우트 import 검증 통과
|
||||||
|
- nginx 설정은 표준 디렉티브만 사용(배포 시 `nginx -t` 권장)
|
||||||
|
|
||||||
## 2026-07-03 / v4.0.20 — 전송 최적화(gzip·lazy-load·캐시)
|
## 2026-07-03 / v4.0.20 — 전송 최적화(gzip·lazy-load·캐시)
|
||||||
|
|
||||||
### 수행 작업
|
### 수행 작업
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ server {
|
|||||||
image/svg+xml
|
image/svg+xml
|
||||||
application/manifest+json;
|
application/manifest+json;
|
||||||
|
|
||||||
|
# 보안 헤더 (add_header가 없는 location에 상속됨)
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
# 공유 링크 → 덱 로드 페이지
|
# 공유 링크 → 덱 로드 페이지
|
||||||
location ~ ^/d/([A-Za-z0-9]+)$ {
|
location ~ ^/d/([A-Za-z0-9]+)$ {
|
||||||
return 302 /cardList.html?d=$1;
|
return 302 /cardList.html?d=$1;
|
||||||
@@ -40,22 +45,28 @@ server {
|
|||||||
# HTML — 배포 직후 반영
|
# HTML — 배포 직후 반영
|
||||||
location ~* \.html$ {
|
location ~* \.html$ {
|
||||||
add_header Cache-Control "no-cache, must-revalidate";
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 핵심 JS — 구 URL(?v= 없음)도 최신 파일 제공 (CDN immutable 회피)
|
# 핵심 JS — 구 URL(?v= 없음)도 최신 파일 제공 (CDN immutable 회피)
|
||||||
location = /script/deck-favorites.js {
|
location = /script/deck-favorites.js {
|
||||||
add_header Cache-Control "no-cache, must-revalidate";
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /script/api.js {
|
location = /script/api.js {
|
||||||
add_header Cache-Control "no-cache, must-revalidate";
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
location = /script/auth-form.js {
|
location = /script/auth-form.js {
|
||||||
add_header Cache-Control "no-cache, must-revalidate";
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +74,7 @@ server {
|
|||||||
location ~* \.css$ {
|
location ~* \.css$ {
|
||||||
expires 1h;
|
expires 1h;
|
||||||
add_header Cache-Control "public, must-revalidate";
|
add_header Cache-Control "public, must-revalidate";
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +82,7 @@ server {
|
|||||||
location ~* \.js$ {
|
location ~* \.js$ {
|
||||||
expires 1h;
|
expires 1h;
|
||||||
add_header Cache-Control "public, must-revalidate";
|
add_header Cache-Control "public, must-revalidate";
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +90,7 @@ server {
|
|||||||
location ~* \.(png|jpg|jpeg|gif|webp|ico|svg|woff2?)$ {
|
location ~* \.(png|jpg|jpeg|gif|webp|ico|svg|woff2?)$ {
|
||||||
expires 30d;
|
expires 30d;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user