UNPKG

signalk-server

Version:

An implementation of a [Signal K](http://signalk.org) server for boats.

231 lines 8.58 kB
"use strict"; /* * 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