198 lines
5.9 KiB
JavaScript
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;
|