signalk-server
Version:
An implementation of a [Signal K](http://signalk.org) server for boats.
231 lines • 8.58 kB
JavaScript
;
/*
* Copyright 2026 Signal K contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.createIconBytesCache = createIconBytesCache;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const debug_1 = require("../debug");
const safe_name_1 = require("./safe-name");
const debug = (0, debug_1.createDebug)('signalk-server:appstore:icon-bytes');
const FETCH_TIMEOUT_MS = 15_000;
const MAX_BYTES = 1024 * 1024; // 1 MB hard cap per icon (typical icons are 5-100 KB)
// Only content types with an "image/" prefix are written to disk — this
// protects against a malicious plugin pointing signalk.appIcon at an HTML
// page or executable. SVG is accepted because <img src=".svg"> renders it
// safely without executing embedded scripts.
const ALLOWED_CONTENT_TYPES = /^image\//i;
const EXTENSION_FOR_CT = {
'image/svg+xml': 'svg',
'image/png': 'png',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/webp': 'webp',
'image/gif': 'gif',
'image/x-icon': 'ico',
'image/vnd.microsoft.icon': 'ico'
};
function extForContentType(ct) {
const base = ct.split(';')[0].trim().toLowerCase();
return EXTENSION_FOR_CT[base];
}
function createIconBytesCache(cacheDir) {
const root = path_1.default.join(cacheDir, 'icon-bytes');
function ensureRoot() {
try {
fs_1.default.mkdirSync(root, { recursive: true });
}
catch (err) {
debug.enabled && debug('mkdir %s failed: %O', root, err);
}
}
function purgeOldVersions(pkgName, keepVersion) {
// Drop the existsSync probe and readdir directly: avoids a TOCTOU
// window where another process removes `root` between the two
// calls. ENOENT just means there's nothing to purge.
let entries;
try {
entries = fs_1.default.readdirSync(root);
}
catch (err) {
if (err.code !== 'ENOENT') {
debug.enabled && debug('purgeOldVersions %s failed: %O', pkgName, err);
}
return;
}
const prefix = `${(0, safe_name_1.safeName)(pkgName)}@`;
const keep = keepVersion ? `${prefix}${keepVersion}.` : undefined;
for (const entry of entries) {
if (!entry.startsWith(prefix))
continue;
if (keep && entry.startsWith(keep))
continue;
try {
fs_1.default.unlinkSync(path_1.default.join(root, entry));
debug.enabled && debug('purged stale icon %s', entry);
}
catch (err) {
if (err.code !== 'ENOENT') {
debug.enabled && debug('purge %s failed: %O', entry, err);
}
}
}
}
function findStored(pkgName) {
const prefix = `${(0, safe_name_1.safeName)(pkgName)}@`;
let best;
// Skip the existsSync pre-check: another process could remove
// root between the check and the readdirSync call. Let
// readdirSync throw ENOENT and let the catch below treat it the
// same as any other read error — consistent with purgeOldVersions
// in the same file.
try {
for (const entry of fs_1.default.readdirSync(root)) {
if (!entry.startsWith(prefix))
continue;
const full = path_1.default.join(root, entry);
const stat = fs_1.default.statSync(full);
if (!best || stat.mtimeMs > best.mtime) {
best = { entry, mtime: stat.mtimeMs };
}
}
}
catch (err) {
debug.enabled && debug('findStored %s failed: %O', pkgName, err);
return undefined;
}
if (!best)
return undefined;
const full = path_1.default.join(root, best.entry);
const ext = best.entry.split('.').pop() || '';
const contentType = guessContentTypeFromExt(ext);
// Re-stat for the size: a concurrent purgeOldVersions or a manual
// /appstore/refresh between the loop and here could have removed
// the file. Treat that as "not stored" rather than throwing.
let size;
try {
size = fs_1.default.statSync(full).size;
}
catch (err) {
debug.enabled && debug('findStored %s re-stat failed: %O', pkgName, err);
return undefined;
}
return {
path: full,
contentType,
size,
writtenAt: best.mtime
};
}
async function downloadBytes(pkgName, version, cdnUrl) {
let contentType;
let bytes;
try {
const res = await fetch(cdnUrl, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
});
if (!res.ok) {
debug.enabled && debug('GET %s returned %d', cdnUrl, res.status);
return null;
}
contentType = res.headers.get('content-type') || '';
if (!ALLOWED_CONTENT_TYPES.test(contentType)) {
debug.enabled &&
debug('rejected %s: content-type %s not image/*', cdnUrl, contentType);
return null;
}
// Reject by Content-Length up front when the server sets it,
// so a misconfigured or malicious URL advertising a 500 MB
// payload doesn't tie up the download buffer at all.
const declaredLength = Number(res.headers.get('content-length') || '0');
if (declaredLength > MAX_BYTES) {
debug.enabled &&
debug('rejected %s: declared size %d exceeds %d', cdnUrl, declaredLength, MAX_BYTES);
return null;
}
const ab = await res.arrayBuffer();
if (ab.byteLength > MAX_BYTES) {
debug.enabled &&
debug('rejected %s: size %d exceeds %d', cdnUrl, ab.byteLength, MAX_BYTES);
return null;
}
bytes = new Uint8Array(ab);
}
catch (err) {
debug.enabled && debug('GET %s failed: %O', cdnUrl, err);
return null;
}
const ext = extForContentType(contentType);
if (!ext) {
debug.enabled &&
debug('no extension mapping for content-type %s', contentType);
return null;
}
ensureRoot();
const filename = `${(0, safe_name_1.safeName)(pkgName)}@${version}.${ext}`;
const full = path_1.default.join(root, filename);
try {
await fs_1.default.promises.writeFile(full, bytes);
purgeOldVersions(pkgName, version);
return {
path: full,
contentType: contentType.split(';')[0].trim(),
size: bytes.byteLength,
writtenAt: Date.now()
};
}
catch (err) {
debug.enabled && debug('write %s failed: %O', full, err);
return null;
}
}
return {
storeRoot() {
return root;
},
read(pkgName) {
return findStored(pkgName);
},
async download(pkgName, version, cdnUrl) {
return downloadBytes(pkgName, version, cdnUrl);
},
invalidate() {
try {
fs_1.default.rmSync(root, { recursive: true, force: true });
}
catch (err) {
debug.enabled && debug('invalidate failed: %O', err);
}
}
};
}
function guessContentTypeFromExt(ext) {
switch (ext.toLowerCase()) {
case 'svg':
return 'image/svg+xml';
case 'png':
return 'image/png';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'webp':
return 'image/webp';
case 'gif':
return 'image/gif';
case 'ico':
return 'image/x-icon';
default:
return 'application/octet-stream';
}
}
//# sourceMappingURL=icon-bytes.js.map