Skip to content

Commit 1f12e77

Browse files
committed
feat: 添加图像 API 中间件,优化 CORS 和缓存控制;更新响应头处理和测试用例
1 parent d6c6b38 commit 1f12e77

4 files changed

Lines changed: 138 additions & 21 deletions

File tree

src/index.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { attachAdminPath } from './middleware/auth.js';
1010
import { csrfProtection } from './middleware/csrf.js';
1111
import { createPublicRouter } from './routes/publicRoutes.js';
1212
import { createAdminRouter } from './routes/adminRoutes.js';
13+
import { imageApiMiddleware, staticImageHeaders } from './utils/response.js';
1314

1415
const app = express();
1516
const store = new ImageStore();
@@ -38,6 +39,8 @@ app.use(
3839
})
3940
);
4041

42+
app.use(`${appBasePath}/api`, imageApiMiddleware);
43+
4144
app.use(
4245
cors({
4346
origin: config.corsOrigin === '*' ? '*' : config.corsOrigin.split(',').map((item) => item.trim()),
@@ -60,9 +63,7 @@ app.use(
6063
express.static(config.imageRoot, {
6164
index: false,
6265
dotfiles: 'ignore',
63-
setHeaders(res) {
64-
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
65-
}
66+
setHeaders: staticImageHeaders
6667
})
6768
);
6869

src/routes/publicRoutes.js

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import express from 'express';
22
import { config, deviceNames } from '../config.js';
33
import { publicImageJson } from '../imageStore.js';
4-
import { jsonError, noStore, renderView, escapeHtml } from '../utils/response.js';
4+
import { absoluteUrl, imageApiMiddleware, jsonError, renderView, escapeHtml, requestBaseUrl } from '../utils/response.js';
55
import { isValidDevice, isValidGalleryName } from '../utils/file.js';
66

77
function parseDevice(value, fallback = 'all') {
@@ -16,14 +16,6 @@ function parseLimit(value) {
1616
return Math.min(parsed, 100);
1717
}
1818

19-
function requestBaseUrl(req) {
20-
const forwardedHost = req.get('x-forwarded-host');
21-
const host = forwardedHost ? forwardedHost.split(',')[0].trim() : req.get('host');
22-
const forwardedProto = req.get('x-forwarded-proto');
23-
const protocol = forwardedProto ? forwardedProto.split(',')[0].trim() : req.protocol;
24-
return host ? `${protocol}://${host}` : config.publicBaseUrl;
25-
}
26-
2719
function galleryRows(galleries) {
2820
if (galleries.length === 0) {
2921
return '<tr><td colspan="5">暂无图库,请在后台创建图库或手动放入图片目录。</td></tr>';
@@ -51,6 +43,8 @@ function galleryRows(galleries) {
5143
export function createPublicRouter(store) {
5244
const router = express.Router();
5345

46+
router.use('/api', imageApiMiddleware);
47+
5448
router.get('/', async (req, res, next) => {
5549
try {
5650
const stats = await store.getStats();
@@ -88,7 +82,6 @@ export function createPublicRouter(store) {
8882
});
8983

9084
async function randomHandler(req, res, galleryFromPath) {
91-
noStore(res);
9285
const gallery = galleryFromPath || req.query.gallery;
9386
const device = parseDevice(req.query.device, 'all');
9487
const type = req.query.type || 'redirect';
@@ -112,14 +105,9 @@ export function createPublicRouter(store) {
112105
return res.json(publicImageJson(image, total, requestBaseUrl(req)));
113106
}
114107
if (type === 'redirect') {
115-
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
116-
return res.redirect(302, image.path);
108+
return res.redirect(302, absoluteUrl(req, image.path));
117109
}
118-
return res.sendFile(image.absolutePath, {
119-
headers: {
120-
'Cache-Control': 'no-store'
121-
}
122-
});
110+
return res.sendFile(image.absolutePath);
123111
}
124112

125113
router.get('/api/random', (req, res, next) => {

src/utils/response.js

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from 'node:fs/promises';
22
import path from 'node:path';
3+
import { config } from '../config.js';
34

45
const viewRoot = path.resolve(process.cwd(), 'views');
56

@@ -22,7 +23,46 @@ export async function renderView(filename, data = {}) {
2223
}
2324

2425
export function noStore(res) {
25-
res.setHeader('Cache-Control', 'no-store');
26+
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
27+
res.setHeader('Pragma', 'no-cache');
28+
res.setHeader('Expires', '0');
29+
}
30+
31+
export function imageCors(res) {
32+
res.setHeader('Access-Control-Allow-Origin', '*');
33+
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
34+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Range');
35+
res.setHeader('Access-Control-Expose-Headers', 'Location, Content-Length, Content-Type, Cache-Control');
36+
}
37+
38+
export function imageApiHeaders(res) {
39+
imageCors(res);
40+
noStore(res);
41+
}
42+
43+
export function imageApiMiddleware(req, res, next) {
44+
imageApiHeaders(res);
45+
if (req.method === 'OPTIONS') return res.sendStatus(204);
46+
return next();
47+
}
48+
49+
export function staticImageHeaders(res) {
50+
imageCors(res);
51+
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
52+
}
53+
54+
export function requestBaseUrl(req) {
55+
const forwardedHost = req.get('x-forwarded-host');
56+
const host = forwardedHost ? forwardedHost.split(',')[0].trim() : req.get('host');
57+
const forwardedProto = req.get('x-forwarded-proto');
58+
const protocol = forwardedProto ? forwardedProto.split(',')[0].trim() : req.protocol;
59+
return host ? `${protocol}://${host}` : config.publicBaseUrl;
60+
}
61+
62+
export function absoluteUrl(req, urlPath) {
63+
if (/^https?:\/\//i.test(urlPath)) return urlPath;
64+
const pathWithSlash = urlPath.startsWith('/') ? urlPath : `/${urlPath}`;
65+
return `${requestBaseUrl(req)}${pathWithSlash}`;
2666
}
2767

2868
export function jsonError(res, status, message, extra = {}) {

src/utils/response.test.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import {
3+
absoluteUrl,
4+
imageApiMiddleware,
5+
imageCors,
6+
noStore,
7+
requestBaseUrl,
8+
staticImageHeaders
9+
} from './response.js';
10+
11+
function mockResponse() {
12+
const headers = new Map();
13+
return {
14+
headers,
15+
setHeader: vi.fn((name, value) => headers.set(name.toLowerCase(), value)),
16+
sendStatus: vi.fn()
17+
};
18+
}
19+
20+
function mockRequest(headers = {}, protocol = 'http') {
21+
return {
22+
protocol,
23+
get(name) {
24+
return headers[name.toLowerCase()];
25+
}
26+
};
27+
}
28+
29+
describe('response image headers', () => {
30+
it('sets CORS headers for image responses', () => {
31+
const res = mockResponse();
32+
33+
imageCors(res);
34+
35+
expect(res.headers.get('access-control-allow-origin')).toBe('*');
36+
expect(res.headers.get('access-control-allow-methods')).toBe('GET, HEAD, OPTIONS');
37+
expect(res.headers.get('access-control-expose-headers')).toContain('Location');
38+
});
39+
40+
it('sets no-cache headers for API responses', () => {
41+
const res = mockResponse();
42+
43+
noStore(res);
44+
45+
expect(res.headers.get('cache-control')).toBe('no-store, no-cache, must-revalidate, proxy-revalidate');
46+
expect(res.headers.get('pragma')).toBe('no-cache');
47+
expect(res.headers.get('expires')).toBe('0');
48+
});
49+
50+
it('keeps static images cacheable while allowing cross-origin use', () => {
51+
const res = mockResponse();
52+
53+
staticImageHeaders(res);
54+
55+
expect(res.headers.get('access-control-allow-origin')).toBe('*');
56+
expect(res.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
57+
});
58+
59+
it('answers API preflight with shared image API headers', () => {
60+
const req = { method: 'OPTIONS' };
61+
const res = mockResponse();
62+
const next = vi.fn();
63+
64+
imageApiMiddleware(req, res, next);
65+
66+
expect(res.headers.get('access-control-allow-origin')).toBe('*');
67+
expect(res.headers.get('cache-control')).toBe('no-store, no-cache, must-revalidate, proxy-revalidate');
68+
expect(res.sendStatus).toHaveBeenCalledWith(204);
69+
expect(next).not.toHaveBeenCalled();
70+
});
71+
});
72+
73+
describe('response URL helpers', () => {
74+
it('builds base URL from forwarded headers', () => {
75+
const req = mockRequest({
76+
'x-forwarded-host': 'img.example.com',
77+
'x-forwarded-proto': 'https'
78+
});
79+
80+
expect(requestBaseUrl(req)).toBe('https://img.example.com');
81+
});
82+
83+
it('converts image paths to absolute URLs for redirects', () => {
84+
const req = mockRequest({ host: 'localhost:3000' });
85+
86+
expect(absoluteUrl(req, '/image/images/anime/pc/001.jpg')).toBe('http://localhost:3000/image/images/anime/pc/001.jpg');
87+
});
88+
});

0 commit comments

Comments
 (0)