Merge pull request #32 from Nystik-gh/v0.8.6

0.8.6: proxy rewrite, offline deploy method, realpath fix, minor fixes
This commit is contained in:
Nystik
2026-06-13 15:47:05 +02:00
committed by GitHub
24 changed files with 791 additions and 198 deletions

View File

@@ -2,6 +2,31 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.8.6] - Karm (2026-06-12)
### Added
- `OBSIDIAN_PACKAGE` env var: unpack a pre-placed `.deb`, `.asar.gz`, or `.asar` on first run instead of downloading, for offline or restricted networks.
- `PROXY_ALLOW_PRIVATE_HOSTS` env var: IPs or IPv4 CIDRs the cross-origin proxy may reach despite the private-address block.
### Changed
- `fs.promises.realpath` is answered from the client-side cache; vault load no longer issues one realpath request per folder.
### Fixed
- Sync file reads serve virtual plugin files the same as async reads.
### Security
- Cross-origin proxy rewritten for better security
- Filesystem and vault error responses no longer include absolute server paths.
- Protocol-relative (`//host`) requests route through the proxy guard.
- Vault names are validated on creation; `batch-read` caps the number of paths per request.
- Demo mode: `/api/ext/*` blocked, and several security fixes
- The `ob` CLI is spawned without a shell.
- Dependency bumps clearing npm audit.
## [0.8.5] - Karm (2026-06-07) ## [0.8.5] - Karm (2026-06-07)
### Added ### Added

View File

@@ -46,18 +46,22 @@ This kind of report makes it straightforward to add the missing shim.
If you want to contribute code: If you want to contribute code:
1. Fork the repo and create a branch for your change 1. Fork the repo and create a branch for your change
2. Run `npm run build` to verify everything builds 2. Run `npm install` once at the repo root (npm workspaces)
3. start the server with `npm run dev`. 3. Run `npm run dev` to build and start the server
4. Test your change in the browser with at least one vault open 4. Test your change in the browser with at least one vault open
5. Keep PRs focused - one fix or feature per PR 5. Run `npm test` and make sure the whole suite passes
6. Keep PRs focused - one fix or feature per PR
Changes to deliberate behavior (the fs shim's caching and write model, the proxy's request handling, anything documented as a design decision) start as an issue, not a PR. Open the issue first so the approach can be discussed; a patch against an undiscussed design change will be closed on this basis.
### Project structure ### Project structure
- `src/shims/` - Browser shims for Node.js and Electron APIs - `packages/shim/` - Browser shims for Node.js and Electron APIs
- `src/ui/` - Svelte UI components (vault manager, dialogs) - `packages/ui/` - Svelte UI components (vault manager, dialogs)
- `plugin/` - The ignis-bridge Obsidian plugin (settings, file actions) - `packages/bridge/` - The ignis-bridge Obsidian plugin (settings, file actions)
- `server/` - Express server (fs routes, WebSocket, plugin system) - `packages/server-core/` - Shared server helpers (path guards, watcher, WebSocket)
- `server/plugins/` - Server plugin packages (e.g., headless-sync) - `apps/ignis-server/` - Express server, Docker image, demo mode
- `apps/ignis-server/server/plugins/` - Server plugin packages (e.g., headless-sync)
See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for more detail. See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for more detail.
@@ -65,7 +69,7 @@ See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for more detail.
If a plugin needs a Node.js module that isn't shimmed: If a plugin needs a Node.js module that isn't shimmed:
1. Create the shim in `src/shims/node/<module>.js` 1. Create the shim in `packages/shim/src/node/<module>.js`
2. Export the functions the plugin needs (stub what you can't implement) 2. Export the functions the plugin needs (stub what you can't implement)
3. Register it in `src/shims/require.js` (import + add to `rawRegistry`) 3. Register it in `packages/shim/src/require.js` (import + add to `rawRegistry`)
4. Build and test with the plugin that needed it 4. Build and test with the plugin that needed it

View File

@@ -75,14 +75,29 @@ To build from source instead of pulling the image, clone the repo and run `docke
| `DATA_ROOT` | Path to persistent data (plugin config, sync state, auth tokens) | `/app/data` | | `DATA_ROOT` | Path to persistent data (plugin config, sync state, auth tokens) | `/app/data` |
| `OBSIDIAN_VERSION` | Obsidian version to download | `1.12.7` | | `OBSIDIAN_VERSION` | Obsidian version to download | `1.12.7` |
| `OBSIDIAN_ASSETS_PATH` | Where the extracted Obsidian app files live. Override if you're pointing at a pre-extracted directory instead of letting the entrypoint download. | `/app/obsidian-app` | | `OBSIDIAN_ASSETS_PATH` | Where the extracted Obsidian app files live. Override if you're pointing at a pre-extracted directory instead of letting the entrypoint download. | `/app/obsidian-app` |
| `OBSIDIAN_PACKAGE` | Path to a pre-placed Obsidian package to unpack on first run instead of downloading, for offline or restricted networks. Accepts `.deb` (the form obsidian.md distributes), `.asar.gz`, or `.asar`. | unset |
| `AUTO_CREATE_DEFAULT` | When `true`, creates a "My Vault" vault on startup if no vaults exist. Useful for fresh installs. | `false` | | `AUTO_CREATE_DEFAULT` | When `true`, creates a "My Vault" vault on startup if no vaults exist. Useful for fresh installs. | `false` |
| `PUID` | User ID for file ownership | `1000` | | `PUID` | User ID for file ownership | `1000` |
| `PGID` | Group ID for file ownership | `1000` | | `PGID` | Group ID for file ownership | `1000` |
| `WRITE_COALESCE_MS` | Debounce window (ms) for rapid writes. On slow filesystems (rclone, NFS, SMB), set an appropriate duration. | `0` | | `WRITE_COALESCE_MS` | Debounce window (ms) for rapid writes. On slow filesystems (rclone, NFS, SMB), set an appropriate duration. | `0` |
| `WS_ORIGINS` | Comma-separated allowlist of `Origin` headers accepted on the WebSocket endpoint. When unset, any origin is accepted. | unset | | `WS_ORIGINS` | Comma-separated allowlist of `Origin` headers accepted on the WebSocket endpoint. When unset, any origin is accepted. | unset |
| `PROXY_ALLOW_PRIVATE_HOSTS` | Comma-separated IPs or IPv4 CIDRs the cross-origin proxy may reach despite the private-address block, for LAN services. Matched against the resolved IP. Reopens SSRF to the listed targets. | unset |
Demo mode adds its own set of env vars (per-session vaults, auto-cleanup, proxy allowlist, login blocking). See [`examples/demo/`](examples/demo/) if you want to run a public demo deployment. Demo mode adds its own set of env vars (per-session vaults, auto-cleanup, proxy allowlist, login blocking). See [`examples/demo/`](examples/demo/) if you want to run a public demo deployment.
## Offline / restricted-network install
If the container can't reach GitHub on first run (air-gapped or restricted networks), download Obsidian yourself from [obsidian.md](https://obsidian.md/download) (the `.deb`), mount it into the container, and point `OBSIDIAN_PACKAGE` at it:
```yaml
volumes:
- ./obsidian_1.12.7_amd64.deb:/packages/obsidian.deb:ro
environment:
- OBSIDIAN_PACKAGE=/packages/obsidian.deb
```
On first run the entrypoint unpacks that instead of downloading. Match the version this release pins (see the OCI label and CHANGELOG); a mismatch logs a warning and still boots. `.asar.gz` and `.asar` are also accepted.
## Migrating an existing vault ## Migrating an existing vault
Each subdirectory of `/vaults` is treated as a separate vault, so dropping in an existing Obsidian vault directory will make it available in Ignis. Each subdirectory of `/vaults` is treated as a separate vault, so dropping in an existing Obsidian vault directory will make it available in Ignis.

View File

@@ -0,0 +1,111 @@
// node build-image.js [--push] [--no-latest]
//
// --push build mulit-arch (amd64+arm64) and push as a manifest list, tagged with the package.json version and latest
// --no-latest don't move the latest tag
//
// Without --push, builds the host arch and loads it as <image>:dev.
const { spawnSync, execSync } = require("child_process");
const path = require("path");
const repoRoot = path.resolve(__dirname, "..", "..", "..");
const IMAGE = process.env.IGNIS_IMAGE || "nobbe/ignis";
const BUILDER = "ignis-builder";
const PLATFORMS = "linux/amd64,linux/arm64";
const args = process.argv.slice(2);
const push = args.includes("--push");
const noLatest = args.includes("--no-latest");
const unknown = args.filter((a) => a !== "--push" && a !== "--no-latest");
if (unknown.length > 0) {
console.error("[build-image] unknown arguments:", unknown.join(" "));
console.error("usage: node build-image.js [--push] [--no-latest]");
process.exit(1);
}
const version = require(path.join(repoRoot, "package.json")).version;
if (!/^\d+\.\d+\.\d+$/.test(version)) {
console.error(
`[build-image] version "${version}" is not plain X.Y.Z, refusing to tag`,
);
process.exit(1);
}
function run(cmd, cmdArgs, opts = {}) {
const result = spawnSync(cmd, cmdArgs, {
cwd: repoRoot,
stdio: "inherit",
...opts,
});
return result.status === 0;
}
let dirty = "";
try {
dirty = execSync("git status --porcelain", { cwd: repoRoot })
.toString()
.trim();
} catch {
console.warn("[build-image] could not check git status");
}
if (dirty) {
console.warn(
"[build-image] WARNING: working tree has uncommitted changes; the image will not match the committed source",
);
}
const inspect = spawnSync("docker", ["buildx", "inspect", BUILDER], {
stdio: "ignore",
});
if (inspect.status !== 0) {
console.log(`[build-image] creating buildx builder ${BUILDER}`);
const created = run("docker", [
"buildx",
"create",
"--name",
BUILDER,
"--driver",
"docker-container",
]);
if (!created) {
process.exit(1);
}
}
const buildArgs = [
"buildx",
"build",
"--builder",
BUILDER,
"-f",
"apps/ignis-server/Dockerfile",
];
if (push) {
buildArgs.push("--platform", PLATFORMS, "-t", `${IMAGE}:${version}`);
if (!noLatest) {
buildArgs.push("-t", `${IMAGE}:latest`);
}
buildArgs.push("--push");
console.log(
`[build-image] building ${PLATFORMS} and pushing ${IMAGE}:${version}${noLatest ? "" : ` + ${IMAGE}:latest`}`,
);
} else {
// Host arch only. Multi-arch builds can't be loaded into the local image store.
buildArgs.push("-t", `${IMAGE}:dev`, "--load");
console.log(`[build-image] local build, loading ${IMAGE}:dev`);
}
buildArgs.push(".");
process.exit(run("docker", buildArgs) ? 0 : 1);

View File

@@ -30,19 +30,66 @@ chown -R "$PUID:$PGID" /vaults /app/obsidian-app /app/data
OBSIDIAN_DIR="/app/obsidian-app" OBSIDIAN_DIR="/app/obsidian-app"
OBSIDIAN_VERSION="${OBSIDIAN_VERSION:-1.12.7}" OBSIDIAN_VERSION="${OBSIDIAN_VERSION:-1.12.7}"
warn_obsidian_version() {
if [ -n "$1" ] && [ "$1" != "$OBSIDIAN_VERSION" ]; then
echo "[ignis] WARNING: package is Obsidian $1, but this build is pinned to ${OBSIDIAN_VERSION}. The shim may misbehave."
fi
}
if [ ! -f "$OBSIDIAN_DIR/index.html" ]; then if [ ! -f "$OBSIDIAN_DIR/index.html" ]; then
echo "[ignis] First run. Downloading Obsidian v${OBSIDIAN_VERSION}..." if [ -n "$OBSIDIAN_PACKAGE" ]; then
# Offline / restricted networks: unpack an operator-supplied package instead of downloading.
if [ ! -f "$OBSIDIAN_PACKAGE" ]; then
echo "[ignis] ERROR: OBSIDIAN_PACKAGE='$OBSIDIAN_PACKAGE' but that file does not exist."
exit 1
fi
curl -fSL "https://github.com/obsidianmd/obsidian-releases/releases/download/v${OBSIDIAN_VERSION}/obsidian-${OBSIDIAN_VERSION}.asar.gz" \ echo "[ignis] First run. Unpacking local Obsidian package: $OBSIDIAN_PACKAGE"
-o /tmp/obsidian.asar.gz
echo "[ignis] Unpacking asar..." case "$OBSIDIAN_PACKAGE" in
gunzip /tmp/obsidian.asar.gz *.deb)
npx --yes @electron/asar extract /tmp/obsidian.asar "$OBSIDIAN_DIR" warn_obsidian_version "$(dpkg-deb -f "$OBSIDIAN_PACKAGE" Version 2>/dev/null)"
rm -rf /tmp/ob-deb
dpkg-deb -x "$OBSIDIAN_PACKAGE" /tmp/ob-deb
npx --yes @electron/asar extract \
/tmp/ob-deb/opt/Obsidian/resources/obsidian.asar "$OBSIDIAN_DIR"
rm -rf /tmp/ob-deb
;;
*.asar.gz)
warn_obsidian_version "$(basename "$OBSIDIAN_PACKAGE" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)"
cp "$OBSIDIAN_PACKAGE" /tmp/obsidian.asar.gz
gunzip -f /tmp/obsidian.asar.gz
npx --yes @electron/asar extract /tmp/obsidian.asar "$OBSIDIAN_DIR"
rm -f /tmp/obsidian.asar
;;
*.asar)
warn_obsidian_version "$(basename "$OBSIDIAN_PACKAGE" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)"
npx --yes @electron/asar extract "$OBSIDIAN_PACKAGE" "$OBSIDIAN_DIR"
;;
*)
echo "[ignis] ERROR: unsupported OBSIDIAN_PACKAGE format. Supported: .deb, .asar.gz, .asar"
exit 1
;;
esac
else
echo "[ignis] First run. Downloading Obsidian v${OBSIDIAN_VERSION}..."
rm -f /tmp/obsidian.asar curl -fSL "https://github.com/obsidianmd/obsidian-releases/releases/download/v${OBSIDIAN_VERSION}/obsidian-${OBSIDIAN_VERSION}.asar.gz" \
-o /tmp/obsidian.asar.gz
echo "[ignis] Obsidian v${OBSIDIAN_VERSION} ready." echo "[ignis] Unpacking asar..."
gunzip /tmp/obsidian.asar.gz
npx --yes @electron/asar extract /tmp/obsidian.asar "$OBSIDIAN_DIR"
rm -f /tmp/obsidian.asar
fi
if [ ! -f "$OBSIDIAN_DIR/index.html" ]; then
echo "[ignis] ERROR: setup did not produce $OBSIDIAN_DIR/index.html; the Obsidian package may be invalid."
exit 1
fi
echo "[ignis] Obsidian ready (v${OBSIDIAN_VERSION})."
else else
echo "[ignis] Obsidian already set up." echo "[ignis] Obsidian already set up."
fi fi

View File

@@ -80,6 +80,14 @@ async function provisionVault(sessionId, userVaultName) {
const storageName = makeStorageName(sessionId, userVaultName); const storageName = makeStorageName(sessionId, userVaultName);
const vaultPath = path.join(config.vaultRoot, storageName); const vaultPath = path.join(config.vaultRoot, storageName);
// keep the resolved path inside the vault root.
const root = path.resolve(config.vaultRoot);
const resolved = path.resolve(vaultPath);
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
return { error: "invalid-vault-name" };
}
await fsp.mkdir(config.vaultRoot, { recursive: true }); await fsp.mkdir(config.vaultRoot, { recursive: true });
try { try {

View File

@@ -16,6 +16,13 @@ function newSessionId() {
return crypto.randomBytes(12).toString("hex"); return crypto.randomBytes(12).toString("hex");
} }
// accept only the format we issue.
const SESSION_ID_RE = /^[a-f0-9]{24}$/;
function isValidSessionId(id) {
return typeof id === "string" && SESSION_ID_RE.test(id);
}
function prefixFor(sessionId) { function prefixFor(sessionId) {
return "demo-" + sessionId + PREFIX_SEPARATOR; return "demo-" + sessionId + PREFIX_SEPARATOR;
} }
@@ -61,20 +68,25 @@ function setSessionCookie(res, sessionId) {
res.setHeader( res.setHeader(
"Set-Cookie", "Set-Cookie",
`${COOKIE_NAME}=${sessionId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}`, `${COOKIE_NAME}=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAgeSeconds}`,
); );
} }
// Resolve the session for a request. If none exists, create one (unless options.peek is true). // Resolve the session for a request. If none exists, create one (unless options.peek is true).
function getOrCreateSession(req, res, options = {}) { function getOrCreateSession(req, res, options = {}) {
const cookies = parseCookies(req); const cookies = parseCookies(req);
const existing = cookies[COOKIE_NAME]; const raw = cookies[COOKIE_NAME];
const existing = isValidSessionId(raw) ? raw : null;
if (existing && sessions.has(existing)) { if (existing && sessions.has(existing)) {
return existing; return existing;
} }
if (existing && !sessions.has(existing)) { if (existing && !sessions.has(existing)) {
if (sessions.size >= config.demoMaxSessions) {
return null;
}
// Cookie outlived in-memory session. reuse the id to keep the prefix. // Cookie outlived in-memory session. reuse the id to keep the prefix.
sessions.set(existing, { sessions.set(existing, {
lastActivity: Date.now(), lastActivity: Date.now(),

View File

@@ -10,6 +10,7 @@ const {
sessions, sessions,
parseCookies, parseCookies,
makeStorageName, makeStorageName,
tryParseUserVaultName,
touchSession, touchSession,
} = require("./demo-sessions"); } = require("./demo-sessions");
@@ -28,6 +29,20 @@ function wireWebSocket(server) {
if (userVault && !userVault.startsWith("demo-")) { if (userVault && !userVault.startsWith("demo-")) {
u.searchParams.set("vault", makeStorageName(sessionId, userVault)); u.searchParams.set("vault", makeStorageName(sessionId, userVault));
req.url = u.pathname + u.search; req.url = u.pathname + u.search;
} else if (
userVault &&
userVault.startsWith("demo-") &&
tryParseUserVaultName(sessionId, userVault) === null
) {
// An already-prefixed vault that isn't this session's: refuse the upgrade.
const socket = rest[0];
if (socket && socket.writable) {
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
socket.destroy();
}
return;
} }
touchSession(sessionId); touchSession(sessionId);

View File

@@ -70,6 +70,11 @@ function setupDemo(app) {
// Hide server-side plugins (headless-sync) from the demo UI // Hide server-side plugins (headless-sync) from the demo UI
app.use("/api/plugins", pluginsBlocker); app.use("/api/plugins", pluginsBlocker);
// Plugin routes are not exposed in demo mode.
app.use("/api/ext", (req, res) => {
res.status(403).json({ error: "Plugin routes are disabled in demo mode" });
});
// Server settings are-fixed in demo mode. // Server settings are-fixed in demo mode.
app.use("/api/settings", (req, res) => { app.use("/api/settings", (req, res) => {
res.status(403).json({ error: "Settings are disabled in demo mode" }); res.status(403).json({ error: "Settings are disabled in demo mode" });

View File

@@ -3,8 +3,6 @@ const fs = require("fs");
const os = require("os"); const os = require("os");
const path = require("path"); const path = require("path");
const isWindows = process.platform === "win32";
// When set via configure(), HOME for the spawned ob points under the plugin's data dir so // When set via configure(), HOME for the spawned ob points under the plugin's data dir so
// ob's config dir (~/.config/obsidian-headless/) survives container recreates. // ob's config dir (~/.config/obsidian-headless/) survives container recreates.
let configuredDataDir = null; let configuredDataDir = null;
@@ -39,13 +37,11 @@ function checkInstalled() {
} }
function spawnOb(args, opts = {}) { function spawnOb(args, opts = {}) {
const home = configuredDataDir const home = configuredDataDir ? getObHome(configuredDataDir) : os.homedir();
? getObHome(configuredDataDir)
: os.homedir();
return spawn("ob", args, { return spawn("ob", args, {
env: { ...process.env, HOME: home }, env: { ...process.env, HOME: home },
shell: isWindows, shell: false,
windowsHide: true, windowsHide: true,
...opts, ...opts,
}); });

View File

@@ -98,7 +98,7 @@ router.get("/stat", async (req, res) => {
} catch (e) { } catch (e) {
res res
.status(e.code === "ENOENT" ? 404 : 500) .status(e.code === "ENOENT" ? 404 : 500)
.json({ error: e.message, code: e.code }); .json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -133,7 +133,7 @@ router.get("/readdir", async (req, res) => {
} catch (e) { } catch (e) {
res res
.status(e.code === "ENOENT" ? 404 : 500) .status(e.code === "ENOENT" ? 404 : 500)
.json({ error: e.message, code: e.code }); .json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -184,7 +184,7 @@ router.get("/readFile", async (req, res) => {
} catch (e) { } catch (e) {
res res
.status(e.code === "ENOENT" ? 404 : 500) .status(e.code === "ENOENT" ? 404 : 500)
.json({ error: e.message, code: e.code }); .json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -213,7 +213,7 @@ router.post("/writeFile", async (req, res) => {
invalidateBootstrap(req); invalidateBootstrap(req);
res.json({ ok: true, mtime: result.mtime, size: result.size }); res.json({ ok: true, mtime: result.mtime, size: result.size });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -231,7 +231,7 @@ router.post("/appendFile", async (req, res) => {
invalidateBootstrap(req); invalidateBootstrap(req);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -251,7 +251,7 @@ router.post("/mkdir", async (req, res) => {
invalidateBootstrap(req); invalidateBootstrap(req);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -280,7 +280,7 @@ router.post("/rename", async (req, res) => {
invalidateBootstrap(req); invalidateBootstrap(req);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -309,7 +309,7 @@ router.post("/copyFile", async (req, res) => {
invalidateBootstrap(req); invalidateBootstrap(req);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -331,7 +331,7 @@ router.delete("/unlink", async (req, res) => {
// File already gone - desired outcome achieved // File already gone - desired outcome achieved
res.json({ ok: true }); res.json({ ok: true });
} else { } else {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
} }
}); });
@@ -350,7 +350,7 @@ router.delete("/rmdir", async (req, res) => {
invalidateBootstrap(req); invalidateBootstrap(req);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -370,7 +370,7 @@ router.delete("/rm", async (req, res) => {
invalidateBootstrap(req); invalidateBootstrap(req);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -388,23 +388,7 @@ router.get("/access", async (req, res) => {
} catch (e) { } catch (e) {
res res
.status(e.code === "ENOENT" ? 404 : 500) .status(e.code === "ENOENT" ? 404 : 500)
.json({ error: e.message, code: e.code }); .json({ error: e.code || "internal", code: e.code });
}
});
router.get("/realpath", async (req, res) => {
const resolved = guardPath(req, res);
if (!resolved) {
return;
}
try {
const real = await fs.promises.realpath(resolved);
res.json({ path: path.relative(req._vaultRoot, real) });
} catch (e) {
res.status(500).json({ error: e.message, code: e.code });
} }
}); });
@@ -426,7 +410,7 @@ router.post("/utimes", async (req, res) => {
invalidateBootstrap(req); invalidateBootstrap(req);
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -441,6 +425,11 @@ router.post("/batch-read", async (req, res) => {
const paths = Array.isArray(req.body?.paths) ? req.body.paths : []; const paths = Array.isArray(req.body?.paths) ? req.body.paths : [];
// The indexer prefetcher (the only caller) batches at 50, so a much larger list is not legitimate.
if (paths.length > 1000) {
return res.status(400).json({ error: "too many paths in batch-read" });
}
if (paths.length === 0) { if (paths.length === 0) {
return res.json({ files: {} }); return res.json({ files: {} });
} }
@@ -531,7 +520,7 @@ router.get("/tree", async (req, res) => {
res.json(tree); res.json(tree);
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -561,7 +550,7 @@ router.get("/download", async (req, res) => {
} catch (e) { } catch (e) {
res res
.status(e.code === "ENOENT" ? 404 : 500) .status(e.code === "ENOENT" ? 404 : 500)
.json({ error: e.message, code: e.code }); .json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -599,7 +588,7 @@ router.get("/download-zip", async (req, res) => {
} catch (e) { } catch (e) {
res res
.status(e.code === "ENOENT" ? 404 : 500) .status(e.code === "ENOENT" ? 404 : 500)
.json({ error: e.message, code: e.code }); .json({ error: e.code || "internal", code: e.code });
} }
}); });

View File

@@ -1,11 +1,16 @@
const express = require("express"); const express = require("express");
const dns = require("dns").promises; const dns = require("dns");
const net = require("net"); const net = require("net");
const http = require("http");
const https = require("https");
const zlib = require("zlib");
const settings = require("../settings"); const settings = require("../settings");
const router = express.Router(); const router = express.Router();
const MAX_RESPONSE_BYTES = 50 * 1024 * 1024; const MAX_RESPONSE_BYTES = 50 * 1024 * 1024;
const MAX_REDIRECTS = 5;
const REDIRECT_CODES = new Set([301, 302, 303, 307, 308]);
function isPrivateIp(ip) { function isPrivateIp(ip) {
const type = net.isIP(ip); const type = net.isIP(ip);
@@ -47,13 +52,116 @@ function isPrivateIp(ip) {
return false; return false;
} }
function ipv4ToInt(ip) {
return ip
.split(".")
.reduce((acc, oct) => ((acc << 8) + Number(oct)) >>> 0, 0);
}
// Parse PROXY_ALLOW_PRIVATE_HOSTS into matchers.
// Exact IPs (v4 and v6) and IPv4 CIDRs are supported; IPv6 CIDR and malformed entries are ignored.
function buildAllowList(entries) {
const exact = new Set();
const cidrV4 = [];
for (const entry of entries) {
const slash = entry.indexOf("/");
if (slash === -1) {
if (net.isIP(entry)) {
exact.add(entry);
} else {
console.warn(
"[proxy] ignoring invalid PROXY_ALLOW_PRIVATE_HOSTS entry:",
entry,
);
}
continue;
}
const base = entry.slice(0, slash);
const prefix = Number(entry.slice(slash + 1));
if (
net.isIP(base) === 4 &&
Number.isInteger(prefix) &&
prefix >= 0 &&
prefix <= 32
) {
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
cidrV4.push({ network: (ipv4ToInt(base) & mask) >>> 0, mask });
} else {
console.warn(
"[proxy] ignoring unsupported PROXY_ALLOW_PRIVATE_HOSTS entry:",
entry,
);
}
}
return { exact, cidrV4 };
}
function allowsAddress(allow, ip) {
if (allow.exact.has(ip)) {
return true;
}
if (net.isIP(ip) === 4) {
const value = ipv4ToInt(ip);
for (const { network, mask } of allow.cidrV4) {
if ((value & mask) >>> 0 === network) {
return true;
}
}
}
return false;
}
const privateAllowList = buildAllowList(settings.get("proxyAllowPrivate"));
// A public address always passes; a private one passes only when listed it in PROXY_ALLOW_PRIVATE_HOSTS.
function addressAllowed(ip) {
return !isPrivateIp(ip) || allowsAddress(privateAllowList, ip);
}
function httpError(status, message) { function httpError(status, message) {
const e = new Error(message); const e = new Error(message);
e.statusCode = status; e.statusCode = status;
return e; return e;
} }
// Reject non-http(s) schemes and hosts that resolve to a private or link-local address. function safeLookup(hostname, options, callback) {
dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
if (err) {
callback(err);
return;
}
if (!addresses.length) {
callback(httpError(502, "DNS resolution failed"));
return;
}
for (const a of addresses) {
if (!addressAllowed(a.address)) {
callback(httpError(403, "Host resolves to a private address"));
return;
}
}
if (options && options.all) {
callback(null, addresses);
return;
}
callback(null, addresses[0].address, addresses[0].family);
});
}
// Reject non-http(s) schemes and hosts that resolve to a disallowed address.
async function assertPublicUrl(urlStr) { async function assertPublicUrl(urlStr) {
let parsed; let parsed;
@@ -70,7 +178,7 @@ async function assertPublicUrl(urlStr) {
const host = parsed.hostname; const host = parsed.hostname;
if (net.isIP(host)) { if (net.isIP(host)) {
if (isPrivateIp(host)) { if (!addressAllowed(host)) {
throw httpError(403, "Host not allowed"); throw httpError(403, "Host not allowed");
} }
@@ -80,18 +188,161 @@ async function assertPublicUrl(urlStr) {
let addrs; let addrs;
try { try {
addrs = await dns.lookup(host, { all: true }); addrs = await dns.promises.lookup(host, { all: true });
} catch { } catch {
throw httpError(502, "DNS resolution failed"); throw httpError(502, "DNS resolution failed");
} }
for (const a of addrs) { for (const a of addrs) {
if (isPrivateIp(a.address)) { if (!addressAllowed(a.address)) {
throw httpError(403, "Host resolves to a private address"); throw httpError(403, "Host resolves to a private address");
} }
} }
} }
function sameOrigin(a, b) {
return a.protocol === b.protocol && a.host === b.host;
}
function requestOnce(targetUrl, method, headers, body) {
return new Promise((resolve, reject) => {
const mod = targetUrl.protocol === "https:" ? https : http;
const req = mod.request(
targetUrl,
{ method, headers, lookup: safeLookup },
resolve,
);
req.on("error", reject);
if (body && method !== "GET" && method !== "HEAD") {
req.write(body);
}
req.end();
});
}
// Follow redirects manually so every hop runs through safeLookup and is re-checked.
async function proxyRequest({ url, method, headers, body }) {
let current = new URL(url);
let currentMethod = method;
let currentHeaders = headers;
let currentBody = body;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
if (current.protocol !== "http:" && current.protocol !== "https:") {
throw httpError(400, "Only http and https URLs are allowed");
}
// An IP-literal host skips DNS, so safeLookup never runs for it; check it here.
if (net.isIP(current.hostname) && !addressAllowed(current.hostname)) {
throw httpError(403, "Host not allowed");
}
const res = await requestOnce(
current,
currentMethod,
currentHeaders,
currentBody,
);
if (!REDIRECT_CODES.has(res.statusCode) || !res.headers.location) {
return res;
}
res.resume();
const next = new URL(res.headers.location, current);
// The caller did not choose the redirect target, so credentials do not cross origins.
if (!sameOrigin(current, next)) {
currentHeaders = { ...currentHeaders };
for (const key of Object.keys(currentHeaders)) {
const lower = key.toLowerCase();
if (lower === "authorization" || lower === "cookie") {
delete currentHeaders[key];
}
}
}
// 301/302/303 turn a non-GET follow-up into a GET; 307/308 preserve method and body.
if (res.statusCode !== 307 && res.statusCode !== 308) {
if (currentMethod !== "GET" && currentMethod !== "HEAD") {
currentMethod = "GET";
currentBody = null;
}
}
current = next;
}
throw httpError(508, "Too many redirects");
}
function readBody(res, maxBytes) {
return new Promise((resolve, reject) => {
const encoding = (res.headers["content-encoding"] || "").toLowerCase();
let stream = res;
let decompressor = null;
if (encoding === "gzip" || encoding === "x-gzip") {
decompressor = zlib.createGunzip();
} else if (encoding === "deflate") {
decompressor = zlib.createInflate();
} else if (encoding === "br") {
decompressor = zlib.createBrotliDecompress();
}
if (decompressor) {
stream = res.pipe(decompressor);
}
const chunks = [];
let total = 0;
let settled = false;
function fail(err) {
if (settled) {
return;
}
settled = true;
res.destroy();
if (decompressor) {
decompressor.destroy();
}
reject(err);
}
stream.on("data", (chunk) => {
total += chunk.length;
if (total > maxBytes) {
fail(httpError(413, "Upstream response too large"));
return;
}
chunks.push(chunk);
});
stream.on("end", () => {
if (settled) {
return;
}
settled = true;
resolve(Buffer.concat(chunks));
});
stream.on("error", (e) => fail(httpError(502, e.message)));
res.on("error", (e) => fail(httpError(502, e.message)));
});
}
// POST /api/proxy - forward a request to an external URL to bypass CORS. // POST /api/proxy - forward a request to an external URL to bypass CORS.
router.post("/", async (req, res) => { router.post("/", async (req, res) => {
const { url, method, headers, body, binary } = req.body; const { url, method, headers, body, binary } = req.body;
@@ -124,40 +375,29 @@ router.post("/", async (req, res) => {
} }
try { try {
// Forward the caller's headers as-is. const reqBody =
const fetchOpts = { binary && typeof body === "string" ? Buffer.from(body, "base64") : body;
const upstream = await proxyRequest({
url,
method: method || "GET", method: method || "GET",
headers: headers || {}, headers: headers || {},
}; body: reqBody,
});
if (body && method !== "GET" && method !== "HEAD") { const declaredLength = Number(upstream.headers["content-length"]);
if (binary && typeof body === "string") {
fetchOpts.body = Buffer.from(body, "base64");
} else {
fetchOpts.body = body;
}
}
const upstream = await fetch(url, fetchOpts);
const declaredLength = Number(upstream.headers.get("content-length"));
if ( if (
Number.isFinite(declaredLength) && Number.isFinite(declaredLength) &&
declaredLength > MAX_RESPONSE_BYTES declaredLength > MAX_RESPONSE_BYTES
) { ) {
upstream.destroy();
return res.status(413).json({ error: "Upstream response too large" }); return res.status(413).json({ error: "Upstream response too large" });
} }
const respArrayBuf = await upstream.arrayBuffer(); const respBody = await readBody(upstream, MAX_RESPONSE_BYTES);
if (respArrayBuf.byteLength > MAX_RESPONSE_BYTES) { // Strip hop-by-hop and encoding headers; the body is already decompressed.
return res.status(413).json({ error: "Upstream response too large" });
}
const respBody = Buffer.from(respArrayBuf);
// Strip hop-by-hop / encoding headers since the body is already decompressed.
const skipHeaders = new Set([ const skipHeaders = new Set([
"content-encoding", "content-encoding",
"transfer-encoding", "transfer-encoding",
@@ -166,21 +406,24 @@ router.post("/", async (req, res) => {
]); ]);
const respHeaders = {}; const respHeaders = {};
upstream.headers.forEach((val, key) => { for (const [key, val] of Object.entries(upstream.headers)) {
if (!skipHeaders.has(key)) { if (!skipHeaders.has(key.toLowerCase())) {
respHeaders[key] = val; respHeaders[key] = val;
} }
}); }
res.json({ res.json({
status: upstream.status, status: upstream.statusCode,
headers: respHeaders, headers: respHeaders,
body: respBody.toString("base64"), body: respBody.toString("base64"),
}); });
} catch (e) { } catch (e) {
res.status(502).json({ error: e.message }); res.status(e.statusCode || 502).json({ error: e.message });
} }
}); });
module.exports = router; module.exports = router;
module.exports.isPrivateIp = isPrivateIp; module.exports.isPrivateIp = isPrivateIp;
module.exports.proxyRequest = proxyRequest;
module.exports.buildAllowList = buildAllowList;
module.exports.allowsAddress = allowsAddress;

View File

@@ -2,7 +2,8 @@ import { describe, it, expect } from "vitest";
import { createRequire } from "module"; import { createRequire } from "module";
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const { isPrivateIp } = require("./proxy.js"); const { isPrivateIp, proxyRequest, buildAllowList, allowsAddress } =
require("./proxy.js");
describe("isPrivateIp", () => { describe("isPrivateIp", () => {
it("flags private and link-local IPv4", () => { it("flags private and link-local IPv4", () => {
@@ -60,3 +61,38 @@ describe("isPrivateIp", () => {
expect(isPrivateIp("")).toBe(false); expect(isPrivateIp("")).toBe(false);
}); });
}); });
describe("proxyRequest guard", () => {
it("rejects a hostname that resolves to a private address", async () => {
await expect(
proxyRequest({ url: "http://localhost/", method: "GET", headers: {} }),
).rejects.toMatchObject({ statusCode: 403 });
});
it("rejects a private IP literal (no DNS lookup runs for literals)", async () => {
await expect(
proxyRequest({ url: "http://127.0.0.1/", method: "GET", headers: {} }),
).rejects.toMatchObject({ statusCode: 403 });
});
});
describe("proxy private-host allow list", () => {
it("allows exact IPs and IPv4 CIDRs, rejects everything else", () => {
const allow = buildAllowList(["192.168.0.0/16", "10.1.2.3", "::1"]);
expect(allowsAddress(allow, "192.168.1.5")).toBe(true);
expect(allowsAddress(allow, "192.169.0.1")).toBe(false);
expect(allowsAddress(allow, "10.1.2.3")).toBe(true);
expect(allowsAddress(allow, "10.1.2.4")).toBe(false);
expect(allowsAddress(allow, "::1")).toBe(true);
expect(allowsAddress(allow, "8.8.8.8")).toBe(false);
});
it("ignores IPv6 CIDR and malformed entries", () => {
const allow = buildAllowList(["fd00::/8", "garbage", "192.168.0.0/33"]);
expect(allow.exact.size).toBe(0);
expect(allow.cidrV4.length).toBe(0);
expect(allowsAddress(allow, "fd00::1")).toBe(false);
});
});

View File

@@ -6,6 +6,25 @@ const bootstrapRoutes = require("./bootstrap");
const router = express.Router(); const router = express.Router();
// Vault names become directories under VAULT_ROOT; reject traversal, hidden, and reserved-device names.
const WINDOWS_RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
function isValidVaultName(name) {
if (typeof name !== "string" || name.length === 0 || name.length > 255) {
return false;
}
if (/[\/\\:*?"<>|]/.test(name)) {
return false;
}
if (name.startsWith(".")) {
return false;
}
return !WINDOWS_RESERVED.test(name);
}
// GET /api/vault/list - returns all discovered vaults (re-scans on each call) // GET /api/vault/list - returns all discovered vaults (re-scans on each call)
router.get("/list", (req, res) => { router.get("/list", (req, res) => {
config.refreshVaults(); config.refreshVaults();
@@ -41,7 +60,7 @@ router.get("/info", async (req, res) => {
router.post("/create", async (req, res) => { router.post("/create", async (req, res) => {
const name = req.body?.name; const name = req.body?.name;
if (!name || /[\/\\:*?"<>|]/.test(name)) { if (!isValidVaultName(name)) {
return res.status(400).json({ error: "Invalid vault name" }); return res.status(400).json({ error: "Invalid vault name" });
} }
@@ -62,7 +81,7 @@ router.post("/create", async (req, res) => {
return res.status(409).json({ error: "Vault already exists" }); return res.status(409).json({ error: "Vault already exists" });
} }
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -71,7 +90,7 @@ router.post("/rename", async (req, res) => {
const vaultId = req.body?.vault; const vaultId = req.body?.vault;
const newName = req.body?.name; const newName = req.body?.name;
if (!newName || /[\/\\:*?"<>|]/.test(newName)) { if (!isValidVaultName(newName)) {
return res.status(400).json({ error: "Invalid vault name" }); return res.status(400).json({ error: "Invalid vault name" });
} }
@@ -98,7 +117,7 @@ router.post("/rename", async (req, res) => {
.json({ error: "A vault with that name already exists" }); .json({ error: "A vault with that name already exists" });
} }
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });
@@ -119,7 +138,7 @@ router.delete("/remove", async (req, res) => {
res.json({ ok: true }); res.json({ ok: true });
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message, code: e.code }); res.status(500).json({ error: e.code || "internal", code: e.code });
} }
}); });

View File

@@ -17,6 +17,8 @@ const DEFAULTS = {
// Empty allows any public host. // Empty allows any public host.
proxyAllowlist: [], proxyAllowlist: [],
wsOrigins: [], wsOrigins: [],
// Private IPs/CIDRs the proxy may reach despite the SSRF guard.
proxyAllowPrivate: [],
}; };
const PROXY_MODES = ["any", "allowlist", "disabled"]; const PROXY_MODES = ["any", "allowlist", "disabled"];
@@ -24,7 +26,7 @@ const PROXY_MODES = ["any", "allowlist", "disabled"];
const KEYS = Object.keys(DEFAULTS); const KEYS = Object.keys(DEFAULTS);
// Env vars only; never persisted to the settings file. // Env vars only; never persisted to the settings file.
const ENV_ONLY_KEYS = ["wsOrigins"]; const ENV_ONLY_KEYS = ["wsOrigins", "proxyAllowPrivate"];
// Hard ceiling for request bodies. // Hard ceiling for request bodies.
const MAX_BODY_BACKSTOP = 500 * 1024 * 1024; const MAX_BODY_BACKSTOP = 500 * 1024 * 1024;
@@ -51,6 +53,10 @@ function fromEnv() {
env.wsOrigins = parseList(process.env.WS_ORIGINS); env.wsOrigins = parseList(process.env.WS_ORIGINS);
} }
if (process.env.PROXY_ALLOW_PRIVATE_HOSTS) {
env.proxyAllowPrivate = parseList(process.env.PROXY_ALLOW_PRIVATE_HOSTS);
}
return env; return env;
} }

171
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "ignis-monorepo", "name": "ignis-monorepo",
"version": "0.8.2", "version": "0.8.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ignis-monorepo", "name": "ignis-monorepo",
"version": "0.8.2", "version": "0.8.5",
"workspaces": [ "workspaces": [
"packages/*", "packages/*",
"apps/*" "apps/*"
@@ -1006,15 +1006,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "3.2.4", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/spy": "3.2.4", "@vitest/spy": "3.2.6",
"@vitest/utils": "3.2.4", "@vitest/utils": "3.2.6",
"chai": "^5.2.0", "chai": "^5.2.0",
"tinyrainbow": "^2.0.0" "tinyrainbow": "^2.0.0"
}, },
@@ -1023,13 +1023,13 @@
} }
}, },
"node_modules/@vitest/mocker": { "node_modules/@vitest/mocker": {
"version": "3.2.4", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/spy": "3.2.4", "@vitest/spy": "3.2.6",
"estree-walker": "^3.0.3", "estree-walker": "^3.0.3",
"magic-string": "^0.30.17" "magic-string": "^0.30.17"
}, },
@@ -1050,9 +1050,9 @@
} }
}, },
"node_modules/@vitest/pretty-format": { "node_modules/@vitest/pretty-format": {
"version": "3.2.4", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1063,13 +1063,13 @@
} }
}, },
"node_modules/@vitest/runner": { "node_modules/@vitest/runner": {
"version": "3.2.4", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/utils": "3.2.4", "@vitest/utils": "3.2.6",
"pathe": "^2.0.3", "pathe": "^2.0.3",
"strip-literal": "^3.0.0" "strip-literal": "^3.0.0"
}, },
@@ -1078,13 +1078,13 @@
} }
}, },
"node_modules/@vitest/snapshot": { "node_modules/@vitest/snapshot": {
"version": "3.2.4", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "3.2.4", "@vitest/pretty-format": "3.2.6",
"magic-string": "^0.30.17", "magic-string": "^0.30.17",
"pathe": "^2.0.3" "pathe": "^2.0.3"
}, },
@@ -1093,9 +1093,9 @@
} }
}, },
"node_modules/@vitest/spy": { "node_modules/@vitest/spy": {
"version": "3.2.4", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1106,13 +1106,13 @@
} }
}, },
"node_modules/@vitest/utils": { "node_modules/@vitest/utils": {
"version": "3.2.4", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@vitest/pretty-format": "3.2.4", "@vitest/pretty-format": "3.2.6",
"loupe": "^3.1.4", "loupe": "^3.1.4",
"tinyrainbow": "^2.0.0" "tinyrainbow": "^2.0.0"
}, },
@@ -1414,9 +1414,9 @@
} }
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "~3.1.2", "bytes": "~3.1.2",
@@ -1427,7 +1427,7 @@
"http-errors": "~2.0.1", "http-errors": "~2.0.1",
"iconv-lite": "~0.4.24", "iconv-lite": "~0.4.24",
"on-finished": "~2.4.1", "on-finished": "~2.4.1",
"qs": "~6.14.0", "qs": "~6.15.1",
"raw-body": "~2.5.3", "raw-body": "~2.5.3",
"type-is": "~1.6.18", "type-is": "~1.6.18",
"unpipe": "~1.0.0" "unpipe": "~1.0.0"
@@ -1438,9 +1438,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@@ -1892,9 +1892,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/es-object-atoms": { "node_modules/es-object-atoms": {
"version": "1.1.1", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0" "es-errors": "^1.3.0"
@@ -2021,14 +2021,14 @@
} }
}, },
"node_modules/express": { "node_modules/express": {
"version": "4.22.1", "version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
"body-parser": "~1.20.3", "body-parser": "~1.20.5",
"content-disposition": "~0.5.4", "content-disposition": "~0.5.4",
"content-type": "~1.0.4", "content-type": "~1.0.4",
"cookie": "~0.7.1", "cookie": "~0.7.1",
@@ -2047,7 +2047,7 @@
"parseurl": "~1.3.3", "parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12", "path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7", "proxy-addr": "~2.0.7",
"qs": "~6.14.0", "qs": "~6.15.1",
"range-parser": "~1.2.1", "range-parser": "~1.2.1",
"safe-buffer": "5.2.1", "safe-buffer": "5.2.1",
"send": "~0.19.0", "send": "~0.19.0",
@@ -2260,9 +2260,9 @@
} }
}, },
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@@ -2493,9 +2493,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.23", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/loupe": { "node_modules/loupe": {
@@ -2766,9 +2766,9 @@
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.12", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pathe": { "node_modules/pathe": {
@@ -2807,9 +2807,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
@@ -2876,9 +2876,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.2", "version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "side-channel": "^1.1.0"
@@ -3107,14 +3107,14 @@
} }
}, },
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"object-inspect": "^1.13.3", "object-inspect": "^1.13.4",
"side-channel-list": "^1.0.0", "side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1", "side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2" "side-channel-weakmap": "^1.0.2"
}, },
@@ -3126,13 +3126,13 @@
} }
}, },
"node_modules/side-channel-list": { "node_modules/side-channel-list": {
"version": "1.0.0", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-errors": "^1.3.0", "es-errors": "^1.3.0",
"object-inspect": "^1.13.3" "object-inspect": "^1.13.4"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -4160,20 +4160,20 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "3.2.4", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4", "@vitest/expect": "3.2.6",
"@vitest/mocker": "3.2.4", "@vitest/mocker": "3.2.6",
"@vitest/pretty-format": "^3.2.4", "@vitest/pretty-format": "^3.2.6",
"@vitest/runner": "3.2.4", "@vitest/runner": "3.2.6",
"@vitest/snapshot": "3.2.4", "@vitest/snapshot": "3.2.6",
"@vitest/spy": "3.2.4", "@vitest/spy": "3.2.6",
"@vitest/utils": "3.2.4", "@vitest/utils": "3.2.6",
"chai": "^5.2.0", "chai": "^5.2.0",
"debug": "^4.4.1", "debug": "^4.4.1",
"expect-type": "^1.2.1", "expect-type": "^1.2.1",
@@ -4203,8 +4203,8 @@
"@edge-runtime/vm": "*", "@edge-runtime/vm": "*",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.4", "@vitest/browser": "3.2.6",
"@vitest/ui": "3.2.4", "@vitest/ui": "3.2.6",
"happy-dom": "*", "happy-dom": "*",
"jsdom": "*" "jsdom": "*"
}, },
@@ -4394,9 +4394,9 @@
} }
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.19.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
@@ -4430,10 +4430,7 @@
}, },
"packages/bridge": { "packages/bridge": {
"name": "@ignis/bridge", "name": "@ignis/bridge",
"version": "0.0.0-internal", "version": "0.0.0-internal"
"devDependencies": {
"esbuild": "^0.20.0"
}
}, },
"packages/bridge-plugin": { "packages/bridge-plugin": {
"name": "@ignis/bridge-plugin", "name": "@ignis/bridge-plugin",

View File

@@ -1,6 +1,6 @@
{ {
"name": "ignis-monorepo", "name": "ignis-monorepo",
"version": "0.8.5", "version": "0.8.6",
"private": true, "private": true,
"description": "Monorepo for Ignis: a browser-based Obsidian client. Self-hosted server in apps/ignis-server; shim, UI, and shared libraries in packages/.", "description": "Monorepo for Ignis: a browser-based Obsidian client. Self-hosted server in apps/ignis-server; shim, UI, and shared libraries in packages/.",
"workspaces": [ "workspaces": [
@@ -11,6 +11,7 @@
"build": "node build.js", "build": "node build.js",
"dev:server": "node apps/ignis-server/server/index.js", "dev:server": "node apps/ignis-server/server/index.js",
"dev": "npm run build && npm run dev:server", "dev": "npm run build && npm run dev:server",
"docker:build": "node apps/ignis-server/scripts/build-image.js",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest"
}, },

View File

@@ -1,9 +1,8 @@
// File descriptor shim - maps fake integer fds to in-memory file buffers. // File descriptor shim. Maps fake integer fds to in-memory file buffers.
// Enables libraries like yauzl that use fs.open/fs.read/fs.close to seek
// around files without loading them via readFileSync upfront.
import { isInputCachePath, inputCacheGet } from "./input-cache.js"; import { isInputCachePath, inputCacheGet } from "./input-cache.js";
import { resolvePath } from "./transforms.js"; import { resolvePath } from "./transforms.js";
import { hasVirtualFile, getVirtualFile } from "./virtual-files.js";
let nextFd = 100; let nextFd = 100;
const openFiles = new Map(); const openFiles = new Map();
@@ -24,6 +23,15 @@ export function createFdOps(metadataCache, contentCache, transport) {
} }
const resolved = resolvePath(path); const resolved = resolvePath(path);
if (hasVirtualFile(resolved)) {
const content = getVirtualFile(resolved);
return typeof content === "string"
? new TextEncoder().encode(content)
: content;
}
const cached = contentCache.get(resolved); const cached = contentCache.get(resolved);
if (cached !== null) { if (cached !== null) {
@@ -60,7 +68,11 @@ export function createFdOps(metadataCache, contentCache, transport) {
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null; const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null;
const resolved = resolvePath(path); const resolved = resolvePath(path);
if (!hasInCache && !metadataCache.has(resolved)) { if (
!hasInCache &&
!hasVirtualFile(resolved) &&
!metadataCache.has(resolved)
) {
const err = new Error( const err = new Error(
`ENOENT: no such file or directory, open '${path}'`, `ENOENT: no such file or directory, open '${path}'`,
); );

View File

@@ -6,6 +6,7 @@ import {
resolvePath, resolvePath,
} from "./transforms.js"; } from "./transforms.js";
import { hasVirtualFile, getVirtualFile } from "./virtual-files.js"; import { hasVirtualFile, getVirtualFile } from "./virtual-files.js";
import { realpathSync } from "./realpath.js";
export function createFsPromises(metadataCache, contentCache, transport) { export function createFsPromises(metadataCache, contentCache, transport) {
return { return {
@@ -260,7 +261,8 @@ export function createFsPromises(metadataCache, contentCache, transport) {
return "/"; return "/";
} }
return transport.realpath(path); // No symlinks in the vault FS, so realpath is the identity.
return realpathSync(path);
}, },
async utimes(path, atime, mtime) { async utimes(path, atime, mtime) {

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { realpath } from "./realpath.js"; import { realpath } from "./realpath.js";
import { createFsPromises } from "./promises.js";
describe("fs realpath shim", () => { describe("fs realpath shim", () => {
it("realpath invokes the callback with the path", async () => { it("realpath invokes the callback with the path", async () => {
@@ -18,3 +19,18 @@ describe("fs realpath shim", () => {
expect(result).toBe("/a/b.md"); expect(result).toBe("/a/b.md");
}); });
}); });
describe("fs.promises realpath", () => {
it("answers locally without touching the transport", async () => {
const fs = createFsPromises({}, {}, {});
expect(await fs.realpath("/a/b.md")).toBe("/a/b.md");
});
it("maps empty and root paths to /", async () => {
const fs = createFsPromises({}, {}, {});
expect(await fs.realpath("")).toBe("/");
expect(await fs.realpath(".")).toBe("/");
});
});

View File

@@ -5,6 +5,7 @@ import {
applyWriteTransform, applyWriteTransform,
resolvePath, resolvePath,
} from "./transforms.js"; } from "./transforms.js";
import { hasVirtualFile, getVirtualFile } from "./virtual-files.js";
export function createFsSync(metadataCache, contentCache, transport) { export function createFsSync(metadataCache, contentCache, transport) {
return { return {
@@ -70,6 +71,21 @@ export function createFsSync(metadataCache, contentCache, transport) {
const wantText = encoding === "utf8" || encoding === "utf-8"; const wantText = encoding === "utf8" || encoding === "utf-8";
const resolved = resolvePath(path); const resolved = resolvePath(path);
// Virtual plugin source overrides any cache or transport version.
if (hasVirtualFile(resolved)) {
const content = getVirtualFile(resolved);
if (wantText) {
return typeof content === "string"
? content
: new TextDecoder().decode(content);
}
return typeof content === "string"
? new TextEncoder().encode(content)
: content;
}
const meta = metadataCache.get(resolved); const meta = metadataCache.get(resolved);
if (meta && meta.type === "directory") { if (meta && meta.type === "directory") {
const e = new Error("EISDIR: illegal operation on a directory, read"); const e = new Error("EISDIR: illegal operation on a directory, read");

View File

@@ -177,13 +177,6 @@ export const transport = {
return requestJson("GET", "/access", { path: normPath(path) }); return requestJson("GET", "/access", { path: normPath(path) });
}, },
async realpath(path) {
const result = await requestJson("GET", "/realpath", {
path: normPath(path),
});
return result.path;
},
async utimes(path, atime, mtime) { async utimes(path, atime, mtime) {
return requestJson("POST", "/utimes", { return requestJson("POST", "/utimes", {
path: normPath(path), path: normPath(path),

View File

@@ -2,7 +2,7 @@
function isSameOrigin(url) { function isSameOrigin(url) {
if ( if (
!url || !url ||
url.startsWith("/") || (url.startsWith("/") && !url.startsWith("//")) ||
url.startsWith("./") || url.startsWith("./") ||
url.startsWith("../") url.startsWith("../")
) { ) {

View File

@@ -0,0 +1,25 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { isSameOrigin } from "./url.js";
describe("isSameOrigin", () => {
beforeEach(() => {
global.window = { location: { origin: "https://vault.example.com" } };
});
afterEach(() => {
delete global.window;
});
it("treats a root-relative path as same-origin", () => {
expect(isSameOrigin("/api/fs/readFile")).toBe(true);
});
it("treats a protocol-relative URL as cross-origin", () => {
expect(isSameOrigin("//evil.com/x")).toBe(false);
});
it("matches the page origin and rejects a different host", () => {
expect(isSameOrigin("https://vault.example.com/x")).toBe(true);
expect(isSameOrigin("https://evil.com/x")).toBe(false);
});
});