Files
2026-11-04 15:35:15 +08:00

198 lines
5.9 KiB
JavaScript

const http = require('http');
const fs = require('fs');
const path = require('path');
const hostname = process.env.IMAGE_HOST || '127.0.0.1';
const port = Number.parseInt(process.env.IMAGE_PORT || '3000', 10);
const directoryPath = path.resolve(__dirname, '../images');
const maxCacheBytes = Number.parseInt(process.env.IMAGE_CACHE_MAX_MB || '128', 10) * 1024 * 1024;
const maxCacheItemBytes =
Number.parseInt(process.env.IMAGE_CACHE_ITEM_MAX_MB || '4', 10) * 1024 * 1024;
const browserCacheSeconds = Number.parseInt(
process.env.IMAGE_BROWSER_CACHE_SECONDS || '3600',
10,
);
// Match complete origins, not substrings such as dnftools.com.example.org.
const allowedImageOrigin = /^https?:\/\/([a-z0-9-]+\.)+dnftools\.com(:[0-9]+)?$/i;
const mimeTypes = {
'.avif': 'image/avif',
'.bmp': 'image/bmp',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
};
/** @type {Map<string, { data: Buffer, contentType: string, etag: string, modified: string }>} */
const memoryCache = new Map();
/** @type {Map<string, Promise<{ data: Buffer, contentType: string, etag: string, modified: string }>>} */
const pendingReads = new Map();
let cachedBytes = 0;
function removeCacheEntry(filePath) {
const entry = memoryCache.get(filePath);
if (!entry) return;
cachedBytes -= entry.data.byteLength;
memoryCache.delete(filePath);
}
function cacheEntry(filePath, entry) {
if (entry.data.byteLength > maxCacheItemBytes || maxCacheBytes <= 0) return;
removeCacheEntry(filePath);
while (cachedBytes + entry.data.byteLength > maxCacheBytes && memoryCache.size) {
const oldestPath = memoryCache.keys().next().value;
if (!oldestPath) break;
removeCacheEntry(oldestPath);
}
memoryCache.set(filePath, entry);
cachedBytes += entry.data.byteLength;
}
function getCachedEntry(filePath) {
const entry = memoryCache.get(filePath);
if (!entry) return undefined;
memoryCache.delete(filePath);
memoryCache.set(filePath, entry);
return entry;
}
async function readImage(filePath) {
const cached = getCachedEntry(filePath);
if (cached) return cached;
const pending = pendingReads.get(filePath);
if (pending) return pending;
const readPromise = (async () => {
const handle = await fs.promises.open(filePath, 'r');
try {
const stat = await handle.stat();
if (!stat.isFile()) {
const error = new Error('Not a file');
error.code = 'ENOENT';
throw error;
}
const data = await handle.readFile();
const entry = {
data,
contentType: mimeTypes[path.extname(filePath).toLowerCase()] || 'application/octet-stream',
etag: `W/"${stat.size.toString(16)}-${Math.trunc(stat.mtimeMs).toString(16)}"`,
modified: stat.mtime.toUTCString(),
};
cacheEntry(filePath, entry);
return entry;
} finally {
await handle.close();
}
})();
pendingReads.set(filePath, readPromise);
try {
return await readPromise;
} finally {
pendingReads.delete(filePath);
}
}
function resolveImagePath(requestUrl) {
let pathname;
try {
pathname = decodeURIComponent(new URL(requestUrl, 'http://localhost').pathname);
} catch {
return undefined;
}
const relativePath = pathname.replace(/^[/\\]+/, '');
const filePath = path.resolve(directoryPath, relativePath);
const relativeToRoot = path.relative(directoryPath, filePath);
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot)) return undefined;
return filePath;
}
function sendText(res, statusCode, message) {
res.writeHead(statusCode, {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': Buffer.byteLength(message),
});
res.end(message);
}
const ImageServer = http.createServer(async (req, res) => {
// Exporting DOM to an image fetches icons across origins. Reflect only trusted
// origins, including on cached (304) and error responses.
res.setHeader('Vary', 'Origin');
const origin = req.headers.origin;
if (origin && allowedImageOrigin.test(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'If-None-Match, If-Modified-Since');
res.setHeader('Access-Control-Max-Age', '86400');
res.writeHead(204);
res.end();
return;
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.setHeader('Allow', 'GET, HEAD, OPTIONS');
sendText(res, 405, 'Method not allowed');
return;
}
const filePath = resolveImagePath(req.url || '/');
if (!filePath) {
sendText(res, 400, 'Invalid image path');
return;
}
try {
const entry = await readImage(filePath);
const headers = {
'Content-Type': entry.contentType,
'Content-Length': entry.data.byteLength,
'Cache-Control': `public, max-age=${browserCacheSeconds}`,
ETag: entry.etag,
'Last-Modified': entry.modified,
'X-Content-Type-Options': 'nosniff',
};
if (req.headers['if-none-match'] === entry.etag) {
res.writeHead(304, headers);
res.end();
return;
}
res.writeHead(200, headers);
res.end(req.method === 'HEAD' ? undefined : entry.data);
} catch (error) {
if (error && (error.code === 'ENOENT' || error.code === 'EISDIR')) {
sendText(res, 404, 'Image not found');
return;
}
console.error(`Failed to serve ${filePath}:`, error);
sendText(res, 500, 'Image server error');
}
});
ImageServer.keepAliveTimeout = 65_000;
ImageServer.headersTimeout = 66_000;
ImageServer.listen(port, hostname, () => {
console.log(`Image server running at http://${hostname}:${port}/`);
console.log(
`Memory cache: ${Math.round(maxCacheBytes / 1024 / 1024)} MB, browser cache: ${browserCacheSeconds}s`,
);
console.log('Press CTRL+C to stop the server');
});
exports.ImageServer = ImageServer;