diff --git a/package-lock.json b/package-lock.json index 75502b0..3da0f56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4430,11 +4430,18 @@ }, "packages/bridge-plugin": { "name": "@ignis/bridge-plugin", - "version": "0.0.0-internal" + "version": "0.0.0-internal", + "devDependencies": { + "esbuild": "^0.20.0" + } }, "packages/server-core": { "name": "@ignis/server-core", - "version": "0.0.0-internal" + "version": "0.0.0-internal", + "dependencies": { + "chokidar": "^3.6.0", + "ws": "^8.16.0" + } }, "packages/services": { "name": "@ignis/services", diff --git a/packages/server-core/package.json b/packages/server-core/package.json index a3181c5..f00000e 100644 --- a/packages/server-core/package.json +++ b/packages/server-core/package.json @@ -1,5 +1,10 @@ { "name": "@ignis/server-core", "version": "0.0.0-internal", - "private": true + "private": true, + "main": "src/index.js", + "dependencies": { + "chokidar": "^3.6.0", + "ws": "^8.16.0" + } } diff --git a/packages/server-core/src/index.js b/packages/server-core/src/index.js new file mode 100644 index 0000000..632150d --- /dev/null +++ b/packages/server-core/src/index.js @@ -0,0 +1,15 @@ +const writeCoalescer = require("./write-coalescer"); +const watcher = require("./watcher"); +const { setupWebSocket } = require("./ws"); +const { + encodeContentDispositionFilename, + resolveVaultPath, +} = require("./path-utils"); + +module.exports = { + writeCoalescer, + watcher, + setupWebSocket, + encodeContentDispositionFilename, + resolveVaultPath, +}; diff --git a/packages/server-core/src/path-utils.js b/packages/server-core/src/path-utils.js new file mode 100644 index 0000000..63e17a9 --- /dev/null +++ b/packages/server-core/src/path-utils.js @@ -0,0 +1,64 @@ +const path = require("path"); + +/** + * Encode a filename for use in Content-Disposition header. + * Handles non-ASCII characters and special characters to prevent header injection. + * Uses RFC 5987 encoding for filename* parameter when needed. + */ +function encodeContentDispositionFilename(filename) { + const hasNonASCII = /[^\x00-\x7F]/.test(filename); + + // Escape quotes and backslashes in ASCII filename + const escapedFilename = filename.replace(/["\\ ]/g, function (match) { + if (match === '"') return '\\"'; + if (match === "\\") return "\\\\"; + return match; + }); + + // Remove any control characters that could cause header injection + const sanitizedFilename = escapedFilename.replace(/[\x00-\x1F\x7F]/g, ""); + + if (!hasNonASCII) { + // Simple ASCII filename - use standard format + return `attachment; filename="${sanitizedFilename}"`; + } + + // Non-ASCII filename - use RFC 5987 encoding + // Encode using percent-encoding for UTF-8 + const encodedFilename = encodeURIComponent(filename) + .replace(/['()]/g, function (c) { + return "%" + c.charCodeAt(0).toString(16).toUpperCase(); + }) + .replace(/\*/g, "%2A"); + + // Provide both filename (ASCII fallback) and filename* (UTF-8 encoded) + // For fallback, replace non-ASCII with underscores + const asciiFallback = filename + .replace(/[^\x00-\x7F]/g, "_") + .replace(/["\\ ]/g, function (match) { + if (match === '"') return '\\"'; + if (match === "\\") return "\\\\"; + return match; + }); + + return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodedFilename}`; +} + +// Resolve a client-provided path to an absolute path within a vault. +// Strips leading slashes so paths from the client are always treated as relative to the vault root. +function resolveVaultPath(vaultRoot, relativePath) { + const cleaned = (relativePath || "").replace(/^\/+/, ""); + const resolved = path.resolve(vaultRoot, cleaned); + + const resolvedRoot = path.resolve(vaultRoot); + + if ( + resolved !== resolvedRoot && + !resolved.startsWith(resolvedRoot + path.sep) + ) { + return null; + } + return resolved; +} + +module.exports = { encodeContentDispositionFilename, resolveVaultPath }; diff --git a/server/watcher.js b/packages/server-core/src/watcher.js similarity index 100% rename from server/watcher.js rename to packages/server-core/src/watcher.js diff --git a/server/write-coalescer.js b/packages/server-core/src/write-coalescer.js similarity index 90% rename from server/write-coalescer.js rename to packages/server-core/src/write-coalescer.js index d84aaab..4d5661a 100644 --- a/server/write-coalescer.js +++ b/packages/server-core/src/write-coalescer.js @@ -5,10 +5,18 @@ // Buffered writes respond to the HTTP client right away with synthetic mtime/size. Otherwise the browser's per-host connection cap blocks unrelated reads while writes sit in the buffer. const fs = require("fs"); -const config = require("./config"); const FLUSH_TIMEOUT_MS = 10000; +// Coalesce window in ms. 0 disables coalescing. Set via configure({ writeCoalesceMs }). +let writeCoalesceMs = 0; + +function configure(opts) { + if (typeof opts?.writeCoalesceMs === "number") { + writeCoalesceMs = opts.writeCoalesceMs; + } +} + // absPath -> timestamp of last completed (or scheduled) write const lastWriteTime = new Map(); @@ -51,7 +59,7 @@ function scheduleFlush(absPath) { } clearTimeout(entry.timer); - entry.timer = setTimeout(() => flushEntry(absPath), config.writeCoalesceMs); + entry.timer = setTimeout(() => flushEntry(absPath), writeCoalesceMs); } function estimateSize(data, encoding) { @@ -67,7 +75,7 @@ function estimateSize(data, encoding) { * Fresh writes resolve with real mtime/size once data is on disk. Buffered writes resolve immediately with synthetic values; the disk flush happens later when the debounce timer fires. */ async function writeCoalesced(absPath, data, encoding) { - const windowMs = config.writeCoalesceMs; + const windowMs = writeCoalesceMs; const last = lastWriteTime.get(absPath); // Fast path: coalescing disabled or far enough from the last write. @@ -159,4 +167,4 @@ function _reset() { lastWriteTime.clear(); } -module.exports = { writeCoalesced, getPending, flushAll, _reset }; +module.exports = { writeCoalesced, getPending, flushAll, configure, _reset }; diff --git a/server/write-coalescer.test.mjs b/packages/server-core/src/write-coalescer.test.mjs similarity index 96% rename from server/write-coalescer.test.mjs rename to packages/server-core/src/write-coalescer.test.mjs index d6c4424..052501b 100644 --- a/server/write-coalescer.test.mjs +++ b/packages/server-core/src/write-coalescer.test.mjs @@ -6,15 +6,13 @@ import os from "os"; const require = createRequire(import.meta.url); const coalescer = require("./write-coalescer.js"); -const config = require("./config.js"); const SHORT_WINDOW_MS = 50; -const originalWindow = config.writeCoalesceMs; let tmpDir; beforeEach(async () => { - config.writeCoalesceMs = SHORT_WINDOW_MS; + coalescer.configure({ writeCoalesceMs: SHORT_WINDOW_MS }); coalescer._reset(); tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "coalesce-test-")); }); @@ -22,7 +20,7 @@ beforeEach(async () => { afterEach(async () => { coalescer._reset(); vi.restoreAllMocks(); - config.writeCoalesceMs = originalWindow; + coalescer.configure({ writeCoalesceMs: 0 }); await fs.promises.rm(tmpDir, { recursive: true, force: true }); }); diff --git a/server/ws.js b/packages/server-core/src/ws.js similarity index 83% rename from server/ws.js rename to packages/server-core/src/ws.js index a94ab17..ca05132 100644 --- a/server/ws.js +++ b/packages/server-core/src/ws.js @@ -1,9 +1,14 @@ const { WebSocketServer } = require("ws"); const url = require("url"); -const config = require("./config"); const watcher = require("./watcher"); -function setupWebSocket(server) { +function setupWebSocket(server, opts = {}) { + const { getVaultPath } = opts; + + if (typeof getVaultPath !== "function") { + throw new Error("setupWebSocket: opts.getVaultPath is required"); + } + const wss = new WebSocketServer({ server, path: "/ws" }); // Plugin-registered message handlers: type -> handler(msg, ws) @@ -13,12 +18,12 @@ function setupWebSocket(server) { const params = new url.URL(req.url, "http://localhost").searchParams; const vaultId = params.get("vault"); - if (!vaultId || !config.getVaultPath(vaultId)) { + if (!vaultId || !getVaultPath(vaultId)) { ws.close(4001, "Invalid or missing vault ID"); return; } - const vaultPath = config.getVaultPath(vaultId); + const vaultPath = getVaultPath(vaultId); console.log(`[ws] Client connected to vault: ${vaultId}`); // Start watching this vault (no-op if already watching) diff --git a/server/demo/demo-cleanup.js b/server/demo/demo-cleanup.js index 9739407..55513d6 100644 --- a/server/demo/demo-cleanup.js +++ b/server/demo/demo-cleanup.js @@ -5,7 +5,7 @@ const fsp = fs.promises; const path = require("path"); const config = require("../config"); -const watcher = require("../watcher"); +const { watcher } = require("@ignis/server-core"); const bootstrapRoutes = require("../routes/bootstrap"); const { diff --git a/server/index.js b/server/index.js index 0fd8427..101a050 100644 --- a/server/index.js +++ b/server/index.js @@ -4,12 +4,12 @@ const path = require("path"); const compression = require("compression"); const config = require("./config"); const { getVersion } = require("./version"); -const { setupWebSocket } = require("./ws"); -const watcher = require("./watcher"); +const { setupWebSocket, watcher, writeCoalescer } = require("@ignis/server-core"); const { updateBridgePluginInAllVaults } = require("./bridge-plugin"); const { initPlugins, shutdownPlugins } = require("./plugin-system/manager"); const pluginRoutes = require("./routes/plugins"); -const { flushAll } = require("./write-coalescer"); +writeCoalescer.configure({ writeCoalesceMs: config.writeCoalesceMs }); +const { flushAll } = writeCoalescer; const { setupDemo, wireDemoWebSocket } = require("./demo"); const ANSI_RED = "\x1b[31m"; @@ -173,7 +173,7 @@ const server = app.listen(config.port, async () => { .catch((e) => console.warn("[bootstrap] warm-up error:", e.message)); }); -const wss = setupWebSocket(server); +const wss = setupWebSocket(server, { getVaultPath: config.getVaultPath }); wireDemoWebSocket(server); async function gracefulShutdown(signal) { diff --git a/server/routes/fs.js b/server/routes/fs.js index 75be8e2..14f382d 100644 --- a/server/routes/fs.js +++ b/server/routes/fs.js @@ -3,59 +3,16 @@ const fs = require("fs"); const path = require("path"); const archiver = require("archiver"); const config = require("../config"); -const { writeCoalesced, getPending } = require("../write-coalescer"); +const { + writeCoalescer, + encodeContentDispositionFilename, + resolveVaultPath, +} = require("@ignis/server-core"); +const { writeCoalesced, getPending } = writeCoalescer; const bootstrapRoutes = require("./bootstrap"); const router = express.Router(); -/** - * Encode a filename for use in Content-Disposition header. - * Handles non-ASCII characters and special characters to prevent header injection. - * Uses RFC 5987 encoding for filename* parameter when needed. - * - * @param {string} filename - The filename to encode - * @returns {string} - Properly formatted Content-Disposition value - */ -function encodeContentDispositionFilename(filename) { - // Check if filename contains non-ASCII characters - const hasNonASCII = /[^\x00-\x7F]/.test(filename); - - // Escape quotes and backslashes in ASCII filename by prefixing with backslash - const escapedFilename = filename.replace(/["\\ ]/g, function (match) { - if (match === '"') return '\\"'; - if (match === "\\") return "\\\\"; - return match; - }); - - // Remove any control characters that could cause header injection - const sanitizedFilename = escapedFilename.replace(/[\x00-\x1F\x7F]/g, ""); - - if (!hasNonASCII) { - // Simple ASCII filename - use standard format - return `attachment; filename="${sanitizedFilename}"`; - } - - // Non-ASCII filename - use RFC 5987 encoding - // Encode using percent-encoding for UTF-8 - const encodedFilename = encodeURIComponent(filename) - .replace(/['()]/g, function (c) { - return "%" + c.charCodeAt(0).toString(16).toUpperCase(); - }) - .replace(/\*/g, "%2A"); - - // Provide both filename (ASCII fallback) and filename* (UTF-8 encoded) - // For fallback, replace non-ASCII with underscores - const asciiFallback = filename - .replace(/[^\x00-\x7F]/g, "_") - .replace(/["\\ ]/g, function (match) { - if (match === '"') return '\\"'; - if (match === "\\") return "\\\\"; - return match; - }); - - return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodedFilename}`; -} - // Resolve the vault root for a request. Reads vault ID from query or body. function getVaultRoot(req, res) { const vaultId = req.query.vault || req.body?.vault || config.defaultVaultId; @@ -76,20 +33,6 @@ function invalidateBootstrap(req) { } } -// Resolve a client-provided path to an absolute path within a vault. -// Strips leading slashes so paths from the client are always treated as relative to the vault root. -function resolveVaultPath(vaultRoot, relativePath) { - const cleaned = (relativePath || "").replace(/^\/+/, ""); - const resolved = path.resolve(vaultRoot, cleaned); - - const resolvedRoot = path.resolve(vaultRoot); - - if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) { - return null; - } - return resolved; -} - function guardPath(req, res, source = "query") { const vaultRoot = getVaultRoot(req, res); @@ -653,5 +596,3 @@ router.get("/download-zip", async (req, res) => { }); module.exports = router; -module.exports.resolveVaultPath = resolveVaultPath; -module.exports.encodeContentDispositionFilename = encodeContentDispositionFilename; diff --git a/server/routes/fs.test.mjs b/server/routes/fs.test.mjs index 34da47f..70f58b0 100644 --- a/server/routes/fs.test.mjs +++ b/server/routes/fs.test.mjs @@ -6,7 +6,7 @@ const require = createRequire(import.meta.url); const { resolveVaultPath, encodeContentDispositionFilename, -} = require("./fs.js"); +} = require("@ignis/server-core"); // -- encodeContentDispositionFilename --------------------------------