break out code into server-core

This commit is contained in:
Nystik 2026-05-21 01:59:30 +02:00
parent 4a65f142bc
commit a6807fe850
12 changed files with 129 additions and 86 deletions

11
package-lock.json generated
View file

@ -4430,11 +4430,18 @@
}, },
"packages/bridge-plugin": { "packages/bridge-plugin": {
"name": "@ignis/bridge-plugin", "name": "@ignis/bridge-plugin",
"version": "0.0.0-internal" "version": "0.0.0-internal",
"devDependencies": {
"esbuild": "^0.20.0"
}
}, },
"packages/server-core": { "packages/server-core": {
"name": "@ignis/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": { "packages/services": {
"name": "@ignis/services", "name": "@ignis/services",

View file

@ -1,5 +1,10 @@
{ {
"name": "@ignis/server-core", "name": "@ignis/server-core",
"version": "0.0.0-internal", "version": "0.0.0-internal",
"private": true "private": true,
"main": "src/index.js",
"dependencies": {
"chokidar": "^3.6.0",
"ws": "^8.16.0"
}
} }

View file

@ -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,
};

View file

@ -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 };

View file

@ -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. // 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 fs = require("fs");
const config = require("./config");
const FLUSH_TIMEOUT_MS = 10000; 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 // absPath -> timestamp of last completed (or scheduled) write
const lastWriteTime = new Map(); const lastWriteTime = new Map();
@ -51,7 +59,7 @@ function scheduleFlush(absPath) {
} }
clearTimeout(entry.timer); clearTimeout(entry.timer);
entry.timer = setTimeout(() => flushEntry(absPath), config.writeCoalesceMs); entry.timer = setTimeout(() => flushEntry(absPath), writeCoalesceMs);
} }
function estimateSize(data, encoding) { 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. * 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) { async function writeCoalesced(absPath, data, encoding) {
const windowMs = config.writeCoalesceMs; const windowMs = writeCoalesceMs;
const last = lastWriteTime.get(absPath); const last = lastWriteTime.get(absPath);
// Fast path: coalescing disabled or far enough from the last write. // Fast path: coalescing disabled or far enough from the last write.
@ -159,4 +167,4 @@ function _reset() {
lastWriteTime.clear(); lastWriteTime.clear();
} }
module.exports = { writeCoalesced, getPending, flushAll, _reset }; module.exports = { writeCoalesced, getPending, flushAll, configure, _reset };

View file

@ -6,15 +6,13 @@ import os from "os";
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const coalescer = require("./write-coalescer.js"); const coalescer = require("./write-coalescer.js");
const config = require("./config.js");
const SHORT_WINDOW_MS = 50; const SHORT_WINDOW_MS = 50;
const originalWindow = config.writeCoalesceMs;
let tmpDir; let tmpDir;
beforeEach(async () => { beforeEach(async () => {
config.writeCoalesceMs = SHORT_WINDOW_MS; coalescer.configure({ writeCoalesceMs: SHORT_WINDOW_MS });
coalescer._reset(); coalescer._reset();
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "coalesce-test-")); tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "coalesce-test-"));
}); });
@ -22,7 +20,7 @@ beforeEach(async () => {
afterEach(async () => { afterEach(async () => {
coalescer._reset(); coalescer._reset();
vi.restoreAllMocks(); vi.restoreAllMocks();
config.writeCoalesceMs = originalWindow; coalescer.configure({ writeCoalesceMs: 0 });
await fs.promises.rm(tmpDir, { recursive: true, force: true }); await fs.promises.rm(tmpDir, { recursive: true, force: true });
}); });

View file

@ -1,9 +1,14 @@
const { WebSocketServer } = require("ws"); const { WebSocketServer } = require("ws");
const url = require("url"); const url = require("url");
const config = require("./config");
const watcher = require("./watcher"); 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" }); const wss = new WebSocketServer({ server, path: "/ws" });
// Plugin-registered message handlers: type -> handler(msg, 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 params = new url.URL(req.url, "http://localhost").searchParams;
const vaultId = params.get("vault"); const vaultId = params.get("vault");
if (!vaultId || !config.getVaultPath(vaultId)) { if (!vaultId || !getVaultPath(vaultId)) {
ws.close(4001, "Invalid or missing vault ID"); ws.close(4001, "Invalid or missing vault ID");
return; return;
} }
const vaultPath = config.getVaultPath(vaultId); const vaultPath = getVaultPath(vaultId);
console.log(`[ws] Client connected to vault: ${vaultId}`); console.log(`[ws] Client connected to vault: ${vaultId}`);
// Start watching this vault (no-op if already watching) // Start watching this vault (no-op if already watching)

View file

@ -5,7 +5,7 @@ const fsp = fs.promises;
const path = require("path"); const path = require("path");
const config = require("../config"); const config = require("../config");
const watcher = require("../watcher"); const { watcher } = require("@ignis/server-core");
const bootstrapRoutes = require("../routes/bootstrap"); const bootstrapRoutes = require("../routes/bootstrap");
const { const {

View file

@ -4,12 +4,12 @@ const path = require("path");
const compression = require("compression"); const compression = require("compression");
const config = require("./config"); const config = require("./config");
const { getVersion } = require("./version"); const { getVersion } = require("./version");
const { setupWebSocket } = require("./ws"); const { setupWebSocket, watcher, writeCoalescer } = require("@ignis/server-core");
const watcher = require("./watcher");
const { updateBridgePluginInAllVaults } = require("./bridge-plugin"); const { updateBridgePluginInAllVaults } = require("./bridge-plugin");
const { initPlugins, shutdownPlugins } = require("./plugin-system/manager"); const { initPlugins, shutdownPlugins } = require("./plugin-system/manager");
const pluginRoutes = require("./routes/plugins"); const pluginRoutes = require("./routes/plugins");
const { flushAll } = require("./write-coalescer"); writeCoalescer.configure({ writeCoalesceMs: config.writeCoalesceMs });
const { flushAll } = writeCoalescer;
const { setupDemo, wireDemoWebSocket } = require("./demo"); const { setupDemo, wireDemoWebSocket } = require("./demo");
const ANSI_RED = "\x1b[31m"; 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)); .catch((e) => console.warn("[bootstrap] warm-up error:", e.message));
}); });
const wss = setupWebSocket(server); const wss = setupWebSocket(server, { getVaultPath: config.getVaultPath });
wireDemoWebSocket(server); wireDemoWebSocket(server);
async function gracefulShutdown(signal) { async function gracefulShutdown(signal) {

View file

@ -3,59 +3,16 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const archiver = require("archiver"); const archiver = require("archiver");
const config = require("../config"); 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 bootstrapRoutes = require("./bootstrap");
const router = express.Router(); 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. // Resolve the vault root for a request. Reads vault ID from query or body.
function getVaultRoot(req, res) { function getVaultRoot(req, res) {
const vaultId = req.query.vault || req.body?.vault || config.defaultVaultId; 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") { function guardPath(req, res, source = "query") {
const vaultRoot = getVaultRoot(req, res); const vaultRoot = getVaultRoot(req, res);
@ -653,5 +596,3 @@ router.get("/download-zip", async (req, res) => {
}); });
module.exports = router; module.exports = router;
module.exports.resolveVaultPath = resolveVaultPath;
module.exports.encodeContentDispositionFilename = encodeContentDispositionFilename;

View file

@ -6,7 +6,7 @@ const require = createRequire(import.meta.url);
const { const {
resolveVaultPath, resolveVaultPath,
encodeContentDispositionFilename, encodeContentDispositionFilename,
} = require("./fs.js"); } = require("@ignis/server-core");
// -- encodeContentDispositionFilename -------------------------------- // -- encodeContentDispositionFilename --------------------------------