break out code into server-core
This commit is contained in:
parent
4a65f142bc
commit
a6807fe850
12 changed files with 129 additions and 86 deletions
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
packages/server-core/src/index.js
Normal file
15
packages/server-core/src/index.js
Normal 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,
|
||||||
|
};
|
||||||
64
packages/server-core/src/path-utils.js
Normal file
64
packages/server-core/src/path-utils.js
Normal 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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
|
||||||
|
|
@ -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 --------------------------------
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue