mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
Compare commits
30 Commits
v0.8.3+obs
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c22ecb5fef | ||
|
|
6394a99808 | ||
|
|
1ed6a89133 | ||
|
|
b36338f9f5 | ||
|
|
6e0878a2f4 | ||
|
|
cb258e97bf | ||
|
|
ccf424af47 | ||
|
|
7758f533bd | ||
|
|
911ebc00af | ||
|
|
542360c681 | ||
|
|
62d87af7dd | ||
|
|
9d01ce71bc | ||
|
|
3f47618aaf | ||
|
|
5a5acb935a | ||
|
|
c3a9d511b2 | ||
|
|
35348093a6 | ||
|
|
a51b2d3ffa | ||
|
|
04be97e48c | ||
|
|
7688de599a | ||
|
|
a7824ac284 | ||
|
|
b43d12f702 | ||
|
|
938a698795 | ||
|
|
3129ed377c | ||
|
|
44bb01f162 | ||
|
|
b88f9fdc0e | ||
|
|
f0b7f65a36 | ||
|
|
05a3908a7a | ||
|
|
b90752e0ad | ||
|
|
caaf6b3144 | ||
|
|
3833ef2668 |
56
CHANGELOG.md
56
CHANGELOG.md
@@ -2,6 +2,62 @@
|
|||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Server settings panel in the Ignis settings tab.
|
||||||
|
- `assert`, `constants`, and `stream` shims, plus callback-style `fs` methods and `realpath`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Write coalescing is now off by default (`WRITE_COALESCE_MS=0`).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Native menus now stay disabled on platforms where its default is true
|
||||||
|
- `/app/data` is now created and owned by the runtime user.
|
||||||
|
- Caddy reverse-proxy example uses the current `basic_auth` directive.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Cross-origin proxy rejects requests that resolve to private, loopback, or link-local addresses (SSRF guard).
|
||||||
|
|
||||||
|
## [0.8.4] - Karm (2026-06-03)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Codeblocks calling clipboard APIs no longer causes reccursion error.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Hardened same-origin checks, virtual-plugin URL validation, token file permissions, and log line bounds.
|
||||||
|
|
||||||
## [0.8.3] - Karm (2026-06-01)
|
## [0.8.3] - Karm (2026-06-01)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -74,12 +74,12 @@ Compatibility for specific community plugins is tracked in [Issue #9](https://gi
|
|||||||
**Server-side integration.**
|
**Server-side integration.**
|
||||||
- Adds a plugin system inside the server itself, separate from Obsidian's community plugin system (WIP).
|
- Adds a plugin system inside the server itself, separate from Obsidian's community plugin system (WIP).
|
||||||
- Ignis-specific settings appear as their own tabs inside Obsidian's Settings modal.
|
- Ignis-specific settings appear as their own tabs inside Obsidian's Settings modal.
|
||||||
|
- Server runtime settings (cache sizes, request body limit, etc.) are configurable from the Ignis settings panel.
|
||||||
- Status bar indicators surface server state and headless sync activity.
|
- Status bar indicators surface server state and headless sync activity.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
**Planned:**
|
**Planned:**
|
||||||
- Server parameter configuration from the Ignis settings panel (LRU cache size, write coalesce window, etc.)
|
|
||||||
- Continued shim work to support more community plugins.
|
- Continued shim work to support more community plugins.
|
||||||
- Server-side plugin system improvements.
|
- Server-side plugin system improvements.
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ A few design decisions worth knowing about for someone evaluating Ignis against
|
|||||||
- A pre-compressed bootstrap response delivers vault info, vault list, metadata tree, and plugin list in a single call.
|
- A pre-compressed bootstrap response delivers vault info, vault list, metadata tree, and plugin list in a single call.
|
||||||
- Indexer pre-fetch warms the content cache so Obsidian's startup index hits cache instead of the network.
|
- Indexer pre-fetch warms the content cache so Obsidian's startup index hits cache instead of the network.
|
||||||
- An LRU content cache (50 MB by default) keeps memory use bounded regardless of vault size, so Ignis doesn't hold the whole vault in memory.
|
- An LRU content cache (50 MB by default) keeps memory use bounded regardless of vault size, so Ignis doesn't hold the whole vault in memory.
|
||||||
- Write coalescing debounces rapid writes for slow filesystems (rclone, FUSE, NFS, SMB).
|
- Optional write coalescing debounces rapid writes for slow filesystems (rclone, FUSE, NFS, SMB); off unless `WRITE_COALESCE_MS` is set.
|
||||||
|
|
||||||
## Browser compatibility
|
## Browser compatibility
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ Example configurations for Basic Auth and Authelia are in [`examples/`](examples
|
|||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> Do not run Ignis on a public network without auth. Anyone with the URL can read and write your vault files.
|
> Do not run Ignis on a public network without auth. Anyone with the URL can read and write your vault files.
|
||||||
|
|
||||||
|
Ignis also runs a cross-origin proxy (`/api/proxy`) that reaches any public host by default. It rejects private, loopback, and link-local addresses, and you can narrow it to an allowlist or disable it entirely from the proxy settings in the Ignis settings panel.
|
||||||
|
|
||||||
## Setup with Docker Compose
|
## Setup with Docker Compose
|
||||||
|
|
||||||
Example `docker-compose.yml`:
|
Example `docker-compose.yml`:
|
||||||
@@ -73,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. Useful for slow filesystems (rclone, NFS, SMB). Set to `0` to disable. | `5000` |
|
| `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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Replace with your domain, or use :443 for local access with a self-signed cert.
|
# Replace with your domain, or use :443 for local access with a self-signed cert.
|
||||||
ignis.example.com {
|
ignis.example.com {
|
||||||
basicauth {
|
basic_auth {
|
||||||
# Username: admin
|
# Username: admin
|
||||||
# Replace the hash below with your own. Generate one with:
|
# Replace the hash below with your own. Generate one with:
|
||||||
# docker run --rm caddy:2 caddy hash-password --plaintext YOUR_PASSWORD
|
# docker run --rm caddy:2 caddy hash-password --plaintext YOUR_PASSWORD
|
||||||
|
|||||||
111
apps/ignis-server/scripts/build-image.js
Normal file
111
apps/ignis-server/scripts/build-image.js
Normal 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);
|
||||||
@@ -23,25 +23,73 @@ else
|
|||||||
echo "[ignis] Using existing user $RUN_USER (UID $PUID)"
|
echo "[ignis] Using existing user $RUN_USER (UID $PUID)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Fix ownership of volumes
|
|
||||||
chown -R "$PUID:$PGID" /vaults /app/obsidian-app
|
mkdir -p /app/data
|
||||||
|
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
|
||||||
|
|||||||
@@ -75,16 +75,6 @@ module.exports = {
|
|||||||
vaults = discoverVaults();
|
vaults = discoverVaults();
|
||||||
return vaults;
|
return vaults;
|
||||||
},
|
},
|
||||||
writeCoalesceMs:
|
|
||||||
process.env.WRITE_COALESCE_MS !== undefined
|
|
||||||
? parseInt(process.env.WRITE_COALESCE_MS)
|
|
||||||
: 5000,
|
|
||||||
|
|
||||||
wsOrigins: process.env.WS_ORIGINS
|
|
||||||
? process.env.WS_ORIGINS.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
: null,
|
|
||||||
|
|
||||||
demoMode: process.env.DEMO_MODE === "true",
|
demoMode: process.env.DEMO_MODE === "true",
|
||||||
demoMaxSessions: parseInt(process.env.DEMO_MAX_SESSIONS) || 20,
|
demoMaxSessions: parseInt(process.env.DEMO_MAX_SESSIONS) || 20,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ 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.
|
||||||
|
app.use("/api/settings", (req, res) => {
|
||||||
|
res.status(403).json({ error: "Settings are disabled in demo mode" });
|
||||||
|
});
|
||||||
|
|
||||||
// Cleanup timer
|
// Cleanup timer
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
cleanupExpired().catch((e) =>
|
cleanupExpired().catch((e) =>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const fs = require("fs");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const compression = require("compression");
|
const compression = require("compression");
|
||||||
const config = require("./config");
|
const config = require("./config");
|
||||||
|
const settings = require("./settings");
|
||||||
const { getVersion } = require("./version");
|
const { getVersion } = require("./version");
|
||||||
const {
|
const {
|
||||||
setupWebSocket,
|
setupWebSocket,
|
||||||
@@ -19,7 +20,7 @@ const {
|
|||||||
getBundledPluginDirs,
|
getBundledPluginDirs,
|
||||||
} = require("./plugin-system/manager");
|
} = require("./plugin-system/manager");
|
||||||
const pluginRoutes = require("./routes/plugins");
|
const pluginRoutes = require("./routes/plugins");
|
||||||
writeCoalescer.configure({ writeCoalesceMs: config.writeCoalesceMs });
|
writeCoalescer.configure({ writeCoalesceMs: settings.get("writeCoalesceMs") });
|
||||||
const { flushAll } = writeCoalescer;
|
const { flushAll } = writeCoalescer;
|
||||||
const { setupDemo, wireDemoWebSocket } = require("./demo");
|
const { setupDemo, wireDemoWebSocket } = require("./demo");
|
||||||
|
|
||||||
@@ -32,7 +33,18 @@ const ANSI_RESET = "\x1b[0m";
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(express.json({ limit: "50mb" }));
|
// Reject oversized requests by Content-Length before parsing.
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const declared = Number(req.headers["content-length"]);
|
||||||
|
|
||||||
|
if (Number.isFinite(declared) && declared > settings.get("maxBodyBytes")) {
|
||||||
|
return res.status(413).json({ error: "Request body too large" });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(express.json({ limit: settings.MAX_BODY_BACKSTOP }));
|
||||||
app.use(compression());
|
app.use(compression());
|
||||||
|
|
||||||
// logger middleware
|
// logger middleware
|
||||||
@@ -66,6 +78,7 @@ const fsRoutes = require("./routes/fs");
|
|||||||
const vaultRoutes = require("./routes/vault");
|
const vaultRoutes = require("./routes/vault");
|
||||||
const proxyRoutes = require("./routes/proxy");
|
const proxyRoutes = require("./routes/proxy");
|
||||||
const versionRoutes = require("./routes/version");
|
const versionRoutes = require("./routes/version");
|
||||||
|
const settingsRoutes = require("./routes/settings");
|
||||||
const bootstrapRoutes = require("./routes/bootstrap");
|
const bootstrapRoutes = require("./routes/bootstrap");
|
||||||
|
|
||||||
app.use("/assets", express.static(path.join(__dirname, "assets")));
|
app.use("/assets", express.static(path.join(__dirname, "assets")));
|
||||||
@@ -78,6 +91,7 @@ app.use("/api/fs", fsRoutes);
|
|||||||
app.use("/api/vault", vaultRoutes);
|
app.use("/api/vault", vaultRoutes);
|
||||||
app.use("/api/proxy", proxyRoutes);
|
app.use("/api/proxy", proxyRoutes);
|
||||||
app.use("/api/version", versionRoutes);
|
app.use("/api/version", versionRoutes);
|
||||||
|
app.use("/api/settings", settingsRoutes);
|
||||||
app.use("/api/plugins", pluginRoutes);
|
app.use("/api/plugins", pluginRoutes);
|
||||||
app.use("/api/bootstrap", bootstrapRoutes);
|
app.use("/api/bootstrap", bootstrapRoutes);
|
||||||
|
|
||||||
@@ -197,7 +211,7 @@ const server = app.listen(config.port, async () => {
|
|||||||
|
|
||||||
const wss = setupWebSocket(server, {
|
const wss = setupWebSocket(server, {
|
||||||
getVaultPath: config.getVaultPath,
|
getVaultPath: config.getVaultPath,
|
||||||
originAllowlist: config.wsOrigins,
|
originAllowlist: settings.get("wsOrigins"),
|
||||||
});
|
});
|
||||||
wireDemoWebSocket(server);
|
wireDemoWebSocket(server);
|
||||||
|
|
||||||
|
|||||||
@@ -83,15 +83,23 @@ function isAuthenticated(dataDir) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeSecret(file, contents) {
|
||||||
|
fs.writeFileSync(file, contents, { encoding: "utf-8", mode: 0o600 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.chmodSync(file, 0o600);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
function saveInternal(dataDir, tokenData) {
|
function saveInternal(dataDir, tokenData) {
|
||||||
const internalFile = getInternalTokenFile(dataDir);
|
const internalFile = getInternalTokenFile(dataDir);
|
||||||
const dir = path.dirname(internalFile);
|
const dir = path.dirname(internalFile);
|
||||||
|
|
||||||
if (!fs.existsSync(dir)) {
|
if (!fs.existsSync(dir)) {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(internalFile, JSON.stringify(tokenData, null, 2), "utf-8");
|
writeSecret(internalFile, JSON.stringify(tokenData, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncToObCli(dataDir, token) {
|
function syncToObCli(dataDir, token) {
|
||||||
@@ -101,10 +109,10 @@ function syncToObCli(dataDir, token) {
|
|||||||
const dir = path.dirname(obAuthFile);
|
const dir = path.dirname(obAuthFile);
|
||||||
|
|
||||||
if (!fs.existsSync(dir)) {
|
if (!fs.existsSync(dir)) {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(obAuthFile, token, "utf-8");
|
writeSecret(obAuthFile, token);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const { spawn } = require("child_process");
|
|||||||
const { spawnOb, runCommand } = require("./ob-cli");
|
const { spawnOb, runCommand } = require("./ob-cli");
|
||||||
|
|
||||||
const MAX_LOG_ENTRIES = 200;
|
const MAX_LOG_ENTRIES = 200;
|
||||||
|
const MAX_LOG_LINE = 4096;
|
||||||
|
|
||||||
function killProcess(proc) {
|
function killProcess(proc) {
|
||||||
if (!proc) {
|
if (!proc) {
|
||||||
@@ -151,10 +152,13 @@ class SyncManager {
|
|||||||
const lines = data.toString().split("\n");
|
const lines = data.toString().split("\n");
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.trim()) {
|
const trimmed = line.trim();
|
||||||
this.addLog(state, line.trim());
|
|
||||||
|
if (trimmed) {
|
||||||
|
const capped = trimmed.slice(0, MAX_LOG_LINE);
|
||||||
|
this.addLog(state, capped);
|
||||||
state.lastActivity = new Date().toISOString();
|
state.lastActivity = new Date().toISOString();
|
||||||
this.broadcaster.broadcastLog(vaultId, line.trim());
|
this.broadcaster.broadcastLog(vaultId, capped);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -302,7 +306,7 @@ class SyncManager {
|
|||||||
addLog(state, line) {
|
addLog(state, line) {
|
||||||
state.logs.push({
|
state.logs.push({
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
line,
|
line: line.slice(0, MAX_LOG_LINE),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (state.logs.length > MAX_LOG_ENTRIES) {
|
if (state.logs.length > MAX_LOG_ENTRIES) {
|
||||||
|
|||||||
11
apps/ignis-server/server/routes/bootstrap.js
vendored
11
apps/ignis-server/server/routes/bootstrap.js
vendored
@@ -14,6 +14,7 @@ const {
|
|||||||
getVirtualPluginsForVault,
|
getVirtualPluginsForVault,
|
||||||
} = require("../plugin-system/manager");
|
} = require("../plugin-system/manager");
|
||||||
const { getVersion } = require("../version");
|
const { getVersion } = require("../version");
|
||||||
|
const settings = require("../settings");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -140,6 +141,11 @@ async function buildEntry(vaultId) {
|
|||||||
// In demo mode, hide server-side plugins from the client.
|
// In demo mode, hide server-side plugins from the client.
|
||||||
plugins: config.demoMode ? [] : getDiscoveredPlugins(),
|
plugins: config.demoMode ? [] : getDiscoveredPlugins(),
|
||||||
virtualPlugins: getVirtualPluginsForVault(vaultId, getVersion()),
|
virtualPlugins: getVirtualPluginsForVault(vaultId, getVersion()),
|
||||||
|
settings: {
|
||||||
|
contentCacheBytes: settings.get("contentCacheBytes"),
|
||||||
|
inputCacheBytes: settings.get("inputCacheBytes"),
|
||||||
|
inputCacheTtlMs: settings.get("inputCacheTtlMs"),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const jsonBuf = Buffer.from(JSON.stringify(response));
|
const jsonBuf = Buffer.from(JSON.stringify(response));
|
||||||
@@ -185,6 +191,10 @@ function invalidateVault(vaultId) {
|
|||||||
cache.delete(vaultId);
|
cache.delete(vaultId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function invalidateAll() {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
async function warmUp() {
|
async function warmUp() {
|
||||||
const ids = Object.keys(config.vaults);
|
const ids = Object.keys(config.vaults);
|
||||||
|
|
||||||
@@ -251,4 +261,5 @@ router.get("/", async (req, res) => {
|
|||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
module.exports.invalidateVault = invalidateVault;
|
module.exports.invalidateVault = invalidateVault;
|
||||||
|
module.exports.invalidateAll = invalidateAll;
|
||||||
module.exports.warmUp = warmUp;
|
module.exports.warmUp = warmUp;
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,349 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
|
const dns = require("dns");
|
||||||
|
const net = require("net");
|
||||||
|
const http = require("http");
|
||||||
|
const https = require("https");
|
||||||
|
const zlib = require("zlib");
|
||||||
|
const settings = require("../settings");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// POST /api/proxy - forward a request to an external URL to bypass CORS
|
const MAX_RESPONSE_BYTES = 50 * 1024 * 1024;
|
||||||
// Used by the requestUrl shim for plugin installation, etc.
|
const MAX_REDIRECTS = 5;
|
||||||
|
const REDIRECT_CODES = new Set([301, 302, 303, 307, 308]);
|
||||||
|
|
||||||
|
function isPrivateIp(ip) {
|
||||||
|
const type = net.isIP(ip);
|
||||||
|
|
||||||
|
if (type === 4) {
|
||||||
|
const o = ip.split(".").map(Number);
|
||||||
|
|
||||||
|
return (
|
||||||
|
o[0] === 0 ||
|
||||||
|
o[0] === 10 ||
|
||||||
|
o[0] === 127 ||
|
||||||
|
(o[0] === 169 && o[1] === 254) ||
|
||||||
|
(o[0] === 172 && o[1] >= 16 && o[1] <= 31) ||
|
||||||
|
(o[0] === 192 && o[1] === 168) ||
|
||||||
|
(o[0] === 100 && o[1] >= 64 && o[1] <= 127)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 6) {
|
||||||
|
const a = ip.toLowerCase();
|
||||||
|
|
||||||
|
if (a === "::1" || a === "::") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^fe[89ab]/.test(a) || a.startsWith("fc") || a.startsWith("fd")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = a.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||||
|
|
||||||
|
if (mapped) {
|
||||||
|
return isPrivateIp(mapped[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const e = new Error(message);
|
||||||
|
e.statusCode = status;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
let parsed;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = new URL(urlStr);
|
||||||
|
} catch {
|
||||||
|
throw httpError(400, "Invalid URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
|
throw httpError(400, "Only http and https URLs are allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = parsed.hostname;
|
||||||
|
|
||||||
|
if (net.isIP(host)) {
|
||||||
|
if (!addressAllowed(host)) {
|
||||||
|
throw httpError(403, "Host not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let addrs;
|
||||||
|
|
||||||
|
try {
|
||||||
|
addrs = await dns.promises.lookup(host, { all: true });
|
||||||
|
} catch {
|
||||||
|
throw httpError(502, "DNS resolution failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const a of addrs) {
|
||||||
|
if (!addressAllowed(a.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.
|
||||||
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;
|
||||||
|
|
||||||
@@ -11,25 +351,53 @@ router.post("/", async (req, res) => {
|
|||||||
return res.status(400).json({ error: "Missing url" });
|
return res.status(400).json({ error: "Missing url" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const proxyMode = settings.get("proxyMode");
|
||||||
|
|
||||||
|
if (proxyMode === "disabled") {
|
||||||
|
return res.status(403).json({ error: "Proxy is disabled" });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fetchOpts = {
|
await assertPublicUrl(url);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(e.statusCode || 400).json({ error: e.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proxyMode === "allowlist") {
|
||||||
|
const allowlist = settings.get("proxyAllowlist");
|
||||||
|
const host = new URL(url).hostname;
|
||||||
|
|
||||||
|
if (!allowlist.includes(host)) {
|
||||||
|
return res
|
||||||
|
.status(403)
|
||||||
|
.json({ error: `Host not in proxy allowlist: ${host}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reqBody =
|
||||||
|
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");
|
if (
|
||||||
} else {
|
Number.isFinite(declaredLength) &&
|
||||||
fetchOpts.body = body;
|
declaredLength > MAX_RESPONSE_BYTES
|
||||||
}
|
) {
|
||||||
|
upstream.destroy();
|
||||||
|
return res.status(413).json({ error: "Upstream response too large" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const upstream = await fetch(url, fetchOpts);
|
const respBody = await readBody(upstream, MAX_RESPONSE_BYTES);
|
||||||
const respBody = Buffer.from(await upstream.arrayBuffer());
|
|
||||||
|
|
||||||
// Forward response headers, stripping hop-by-hop / encoding headers
|
// Strip hop-by-hop and encoding headers; the body is already decompressed.
|
||||||
// since the body is already decompressed by Node's fetch
|
|
||||||
const skipHeaders = new Set([
|
const skipHeaders = new Set([
|
||||||
"content-encoding",
|
"content-encoding",
|
||||||
"transfer-encoding",
|
"transfer-encoding",
|
||||||
@@ -37,20 +405,25 @@ router.post("/", async (req, res) => {
|
|||||||
"connection",
|
"connection",
|
||||||
]);
|
]);
|
||||||
const respHeaders = {};
|
const respHeaders = {};
|
||||||
upstream.headers.forEach((val, key) => {
|
|
||||||
if (!skipHeaders.has(key)) {
|
for (const [key, val] of Object.entries(upstream.headers)) {
|
||||||
|
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.proxyRequest = proxyRequest;
|
||||||
|
module.exports.buildAllowList = buildAllowList;
|
||||||
|
module.exports.allowsAddress = allowsAddress;
|
||||||
|
|||||||
98
apps/ignis-server/server/routes/proxy.test.mjs
Normal file
98
apps/ignis-server/server/routes/proxy.test.mjs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { createRequire } from "module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { isPrivateIp, proxyRequest, buildAllowList, allowsAddress } =
|
||||||
|
require("./proxy.js");
|
||||||
|
|
||||||
|
describe("isPrivateIp", () => {
|
||||||
|
it("flags private and link-local IPv4", () => {
|
||||||
|
for (const ip of [
|
||||||
|
"0.0.0.0",
|
||||||
|
"10.0.0.1",
|
||||||
|
"127.0.0.1",
|
||||||
|
"169.254.1.1",
|
||||||
|
"172.16.0.1",
|
||||||
|
"172.31.255.255",
|
||||||
|
"192.168.1.1",
|
||||||
|
"100.64.0.1",
|
||||||
|
"100.127.255.255",
|
||||||
|
]) {
|
||||||
|
expect(isPrivateIp(ip), ip).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows public IPv4, including range boundaries", () => {
|
||||||
|
for (const ip of [
|
||||||
|
"8.8.8.8",
|
||||||
|
"1.1.1.1",
|
||||||
|
"172.15.255.255",
|
||||||
|
"172.32.0.0",
|
||||||
|
"100.63.255.255",
|
||||||
|
"100.128.0.0",
|
||||||
|
"169.253.0.0",
|
||||||
|
"169.255.0.0",
|
||||||
|
"11.0.0.1",
|
||||||
|
"192.169.0.1",
|
||||||
|
]) {
|
||||||
|
expect(isPrivateIp(ip), ip).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags private and link-local IPv6", () => {
|
||||||
|
for (const ip of ["::1", "::", "fc00::1", "fd12::1", "fe80::1", "feaf::1"]) {
|
||||||
|
expect(isPrivateIp(ip), ip).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows public IPv6", () => {
|
||||||
|
for (const ip of ["2606:4700:4700::1111", "2001:4860:4860::8888"]) {
|
||||||
|
expect(isPrivateIp(ip), ip).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies IPv4-mapped IPv6 by the embedded address", () => {
|
||||||
|
expect(isPrivateIp("::ffff:127.0.0.1")).toBe(true);
|
||||||
|
expect(isPrivateIp("::ffff:8.8.8.8")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for non-IP input", () => {
|
||||||
|
expect(isPrivateIp("not-an-ip")).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
97
apps/ignis-server/server/routes/settings.js
Normal file
97
apps/ignis-server/server/routes/settings.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const { writeCoalescer } = require("@ignis/server-core");
|
||||||
|
const settings = require("../settings");
|
||||||
|
const bootstrapRoutes = require("./bootstrap");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const NUMBER_KEYS = [
|
||||||
|
"contentCacheBytes",
|
||||||
|
"inputCacheBytes",
|
||||||
|
"inputCacheTtlMs",
|
||||||
|
"writeCoalesceMs",
|
||||||
|
"maxBodyBytes",
|
||||||
|
];
|
||||||
|
const LIST_KEYS = ["proxyAllowlist"];
|
||||||
|
|
||||||
|
function validate(body) {
|
||||||
|
const clean = {};
|
||||||
|
|
||||||
|
if (body.proxyMode !== undefined) {
|
||||||
|
if (!settings.PROXY_MODES.includes(body.proxyMode)) {
|
||||||
|
throw new Error(
|
||||||
|
`proxyMode must be one of: ${settings.PROXY_MODES.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clean.proxyMode = body.proxyMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of NUMBER_KEYS) {
|
||||||
|
if (body[key] === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const n = body[key];
|
||||||
|
|
||||||
|
if (!Number.isInteger(n) || n < 0) {
|
||||||
|
throw new Error(`${key} must be a non-negative integer`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "maxBodyBytes" && (n < 1 || n > settings.MAX_BODY_BACKSTOP)) {
|
||||||
|
throw new Error(
|
||||||
|
`maxBodyBytes must be between 1 and ${settings.MAX_BODY_BACKSTOP}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clean[key] = n;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of LIST_KEYS) {
|
||||||
|
if (body[key] === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = body[key];
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Array.isArray(list) ||
|
||||||
|
list.some((v) => typeof v !== "string" || !v.trim())
|
||||||
|
) {
|
||||||
|
throw new Error(`${key} must be an array of non-empty strings`);
|
||||||
|
}
|
||||||
|
|
||||||
|
clean[key] = list.map((v) => v.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySettings(effective) {
|
||||||
|
writeCoalescer.configure({ writeCoalesceMs: effective.writeCoalesceMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/", (req, res) => {
|
||||||
|
res.json(settings.getAll());
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/", (req, res) => {
|
||||||
|
let clean;
|
||||||
|
|
||||||
|
try {
|
||||||
|
clean = validate(req.body || {});
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(400).json({ error: e.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const effective = settings.update(clean);
|
||||||
|
applySettings(effective);
|
||||||
|
|
||||||
|
// Cache sizes ride in the bootstrap response; clear it so the next page load picks up new values.
|
||||||
|
bootstrapRoutes.invalidateAll();
|
||||||
|
|
||||||
|
res.json(effective);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
module.exports.validate = validate;
|
||||||
47
apps/ignis-server/server/routes/settings.test.mjs
Normal file
47
apps/ignis-server/server/routes/settings.test.mjs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { createRequire } from "module";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { validate } = require("./settings.js");
|
||||||
|
const settings = require("../settings.js");
|
||||||
|
|
||||||
|
describe("settings validate", () => {
|
||||||
|
it("rejects an unknown proxy mode", () => {
|
||||||
|
expect(() => validate({ proxyMode: "bogus" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects negative or non-integer numbers", () => {
|
||||||
|
expect(() => validate({ contentCacheBytes: -1 })).toThrow();
|
||||||
|
expect(() => validate({ contentCacheBytes: 1.5 })).toThrow();
|
||||||
|
expect(() => validate({ contentCacheBytes: "5" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces maxBodyBytes bounds", () => {
|
||||||
|
expect(() => validate({ maxBodyBytes: 0 })).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
validate({ maxBodyBytes: settings.MAX_BODY_BACKSTOP + 1 }),
|
||||||
|
).toThrow();
|
||||||
|
expect(validate({ maxBodyBytes: 1048576 })).toEqual({
|
||||||
|
maxBodyBytes: 1048576,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims a valid proxy allowlist", () => {
|
||||||
|
expect(
|
||||||
|
validate({ proxyAllowlist: [" api.example.com ", "github.com"] }),
|
||||||
|
).toEqual({ proxyAllowlist: ["api.example.com", "github.com"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-array allowlist or an empty entry", () => {
|
||||||
|
expect(() => validate({ proxyAllowlist: "x" })).toThrow();
|
||||||
|
expect(() => validate({ proxyAllowlist: ["ok", " "] })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores wsOrigins, which is env-only", () => {
|
||||||
|
expect(validate({ wsOrigins: ["https://evil.example.com"] })).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores unknown keys", () => {
|
||||||
|
expect(validate({ bogusKey: 1 })).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
120
apps/ignis-server/server/settings.js
Normal file
120
apps/ignis-server/server/settings.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const config = require("./config");
|
||||||
|
|
||||||
|
// Runtime server settings set through UI.
|
||||||
|
|
||||||
|
const SETTINGS_FILE = path.join(config.dataRoot, "server-settings.json");
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
contentCacheBytes: 50 * 1024 * 1024,
|
||||||
|
inputCacheBytes: 200 * 1024 * 1024,
|
||||||
|
inputCacheTtlMs: 5 * 60 * 1000,
|
||||||
|
writeCoalesceMs: 0,
|
||||||
|
maxBodyBytes: 50 * 1024 * 1024,
|
||||||
|
// "any" reaches any public host, "allowlist" restricts to proxyAllowlist, "disabled" blocks all proxying.
|
||||||
|
proxyMode: "any",
|
||||||
|
// Empty allows any public host.
|
||||||
|
proxyAllowlist: [],
|
||||||
|
wsOrigins: [],
|
||||||
|
// Private IPs/CIDRs the proxy may reach despite the SSRF guard.
|
||||||
|
proxyAllowPrivate: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROXY_MODES = ["any", "allowlist", "disabled"];
|
||||||
|
|
||||||
|
const KEYS = Object.keys(DEFAULTS);
|
||||||
|
|
||||||
|
// Env vars only; never persisted to the settings file.
|
||||||
|
const ENV_ONLY_KEYS = ["wsOrigins", "proxyAllowPrivate"];
|
||||||
|
|
||||||
|
// Hard ceiling for request bodies.
|
||||||
|
const MAX_BODY_BACKSTOP = 500 * 1024 * 1024;
|
||||||
|
|
||||||
|
function parseList(raw) {
|
||||||
|
return raw
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromEnv() {
|
||||||
|
const env = {};
|
||||||
|
|
||||||
|
if (process.env.WRITE_COALESCE_MS !== undefined) {
|
||||||
|
const n = parseInt(process.env.WRITE_COALESCE_MS, 10);
|
||||||
|
|
||||||
|
if (Number.isFinite(n)) {
|
||||||
|
env.writeCoalesceMs = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envOverrides = fromEnv();
|
||||||
|
|
||||||
|
function loadFile() {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8"));
|
||||||
|
// Keep only known keys so a stale or hand-edited file can't inject junk.
|
||||||
|
const clean = {};
|
||||||
|
|
||||||
|
for (const key of KEYS) {
|
||||||
|
if (ENV_ONLY_KEYS.includes(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed[key] !== undefined) {
|
||||||
|
clean[key] = parsed[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return clean;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileOverrides = loadFile();
|
||||||
|
|
||||||
|
function getAll() {
|
||||||
|
return { ...DEFAULTS, ...envOverrides, ...fileOverrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
function get(key) {
|
||||||
|
return getAll()[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge validated changes into the persisted file and return the new effective settings.
|
||||||
|
function update(partial) {
|
||||||
|
for (const [key, value] of Object.entries(partial)) {
|
||||||
|
if (KEYS.includes(key) && !ENV_ONLY_KEYS.includes(key)) {
|
||||||
|
fileOverrides[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
|
||||||
|
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(fileOverrides, null, 2));
|
||||||
|
|
||||||
|
return getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULTS,
|
||||||
|
KEYS,
|
||||||
|
ENV_ONLY_KEYS,
|
||||||
|
PROXY_MODES,
|
||||||
|
MAX_BODY_BACKSTOP,
|
||||||
|
getAll,
|
||||||
|
get,
|
||||||
|
update,
|
||||||
|
};
|
||||||
@@ -69,6 +69,9 @@ Immediately after the bootstrap response is applied, the client kicks off a batc
|
|||||||
| `net` | All classes/functions throw. |
|
| `net` | All classes/functions throw. |
|
||||||
| `http` / `https` | Module is importable but `request()`/`get()` emit an `error` event; `createServer` throws. Plugins should use `requestUrl` or `fetch` (the shim routes cross-origin `fetch` through the server proxy). |
|
| `http` / `https` | Module is importable but `request()`/`get()` emit an `error` event; `createServer` throws. Plugins should use `requestUrl` or `fetch` (the shim routes cross-origin `fetch` through the server proxy). |
|
||||||
| `buffer` | Aliased to the browser `Buffer` polyfill set up by the loader. |
|
| `buffer` | Aliased to the browser `Buffer` polyfill set up by the loader. |
|
||||||
|
| `assert` | Standard assertions: `assert`, `equal`, `strictEqual`, `deepEqual`, `throws`. |
|
||||||
|
| `constants` | File access and mode constants (`F_OK`, `O_RDONLY`, `S_IFMT`, etc.) for the reported Linux platform. |
|
||||||
|
| `stream` | Base classes (`Stream`, `Readable`, `Writable`, `Duplex`, `Transform`, `PassThrough`) extending EventEmitter. Data-flow methods warn and do nothing. |
|
||||||
|
|
||||||
Unknown modules return an empty proxy and log a warning. The `node:` prefix is stripped. The shim exposes two console helpers, `window.__shimLog()` (everything that has been accessed) and `window.__shimMisses()` (accessed-but-missing properties).
|
Unknown modules return an empty proxy and log a warning. The `node:` prefix is stripped. The shim exposes two console helpers, `window.__shimLog()` (everything that has been accessed) and `window.__shimMisses()` (accessed-but-missing properties).
|
||||||
|
|
||||||
@@ -78,7 +81,7 @@ Two caches on the client side. The **MetadataCache** holds `{ type, size, mtime,
|
|||||||
|
|
||||||
Reads not satisfied by ContentCache go through the transport layer to `/api/fs/readFile`. Sync calls use synchronous XHR to keep Obsidian's pre-boot module code working. Async calls use fetch. The transport handles vault id injection, base64 encoding for binary files, and mapping HTTP error codes back to Node errno values (`ENOENT`, `EEXIST`, `ENOTDIR`).
|
Reads not satisfied by ContentCache go through the transport layer to `/api/fs/readFile`. Sync calls use synchronous XHR to keep Obsidian's pre-boot module code working. Async calls use fetch. The transport handles vault id injection, base64 encoding for binary files, and mapping HTTP error codes back to Node errno values (`ENOENT`, `EEXIST`, `ENOTDIR`).
|
||||||
|
|
||||||
Writes go through a server-side write coalescer (`packages/server-core/src/write-coalescer.js`) designed for slow filesystems like rclone FUSE mounts. The first write to a path goes to disk immediately. Subsequent writes within a configurable window (default 5 seconds, `WRITE_COALESCE_MS`) are buffered and flushed when the debounce timer fires; the timer resets on each write. Buffered writes return to the HTTP client immediately with synthetic metadata so connection-pool starvation on rapid-fire writes (e.g. `workspace.json` autosaves) doesn't stall unrelated reads. Reads for pending paths serve the buffered content so clients never see stale data. All pending writes are flushed on graceful shutdown.
|
Writes go through a server-side write coalescer (`packages/server-core/src/write-coalescer.js`) designed for slow filesystems like rclone FUSE mounts. The first write to a path goes to disk immediately. Subsequent writes within a configurable window (`WRITE_COALESCE_MS`, default `0` which disables coalescing) are buffered and flushed when the debounce timer fires; the timer resets on each write. Buffered writes return to the HTTP client immediately with synthetic metadata so connection-pool starvation on rapid-fire writes (e.g. `workspace.json` autosaves) doesn't stall unrelated reads. Reads for pending paths serve the buffered content so clients never see stale data. All pending writes are flushed on graceful shutdown.
|
||||||
|
|
||||||
### Transforms
|
### Transforms
|
||||||
|
|
||||||
@@ -102,7 +105,7 @@ Obsidian on the desktop can make arbitrary cross-origin HTTP requests because it
|
|||||||
|
|
||||||
The shim handles this transparently. `window.fetch` and `window.requestUrl` are intercepted. Same-origin requests pass through unchanged. Cross-origin requests are POSTed to `/api/proxy`, which performs the outbound call from the server with headers that mimic Obsidian's desktop runtime: `Origin: app://obsidian.md` and the browser's own User-Agent. The response body is returned base64-encoded so binary content survives the JSON round-trip; the shim decodes it and hands the caller a normal `Response` or `requestUrl` result.
|
The shim handles this transparently. `window.fetch` and `window.requestUrl` are intercepted. Same-origin requests pass through unchanged. Cross-origin requests are POSTed to `/api/proxy`, which performs the outbound call from the server with headers that mimic Obsidian's desktop runtime: `Origin: app://obsidian.md` and the browser's own User-Agent. The response body is returned base64-encoded so binary content survives the JSON round-trip; the shim decodes it and hands the caller a normal `Response` or `requestUrl` result.
|
||||||
|
|
||||||
The proxy itself is intentionally generic. It forwards method, headers, and body verbatim and returns whatever the upstream sent. In demo mode, an allowlist restricts the hostname to a known-safe set; in normal self-hosted mode there's no restriction, which is one of the reasons the server needs to be behind authentication when exposed to the internet.
|
The proxy itself is intentionally generic. It forwards method, headers, and body verbatim and returns whatever the upstream sent. It always rejects requests whose hostname resolves to a private, loopback, or link-local address (SSRF guard). Outbound access is governed by `proxyMode`: `any` (the default) reaches any public host, `allowlist` restricts to a configured host list, and `disabled` blocks all proxying; demo mode pins it to `allowlist`. Under the default `any`, the proxy is an open relay to public hosts, which is one of the reasons the server needs to be behind authentication when exposed to the internet.
|
||||||
|
|
||||||
### Workspaces in browser tabs
|
### Workspaces in browser tabs
|
||||||
|
|
||||||
@@ -138,6 +141,7 @@ An Express server that handles filesystem operations, vault management, static f
|
|||||||
- `/api/bootstrap` - one-shot cold-start endpoint; returns vault info + list + metadata tree + plugin list as a single pre-compressed response, cached per vault with mtime-based invalidation.
|
- `/api/bootstrap` - one-shot cold-start endpoint; returns vault info + list + metadata tree + plugin list as a single pre-compressed response, cached per vault with mtime-based invalidation.
|
||||||
- `/api/proxy` - cross-origin HTTP proxy used by the fetch and requestUrl shims.
|
- `/api/proxy` - cross-origin HTTP proxy used by the fetch and requestUrl shims.
|
||||||
- `/api/version` - Ignis version (SemVer), per-build identifier, and pinned Obsidian version.
|
- `/api/version` - Ignis version (SemVer), per-build identifier, and pinned Obsidian version.
|
||||||
|
- `/api/settings/*` - read and update runtime server settings (cache sizes, request body limit, write-coalesce window, proxy mode and allowlist).
|
||||||
- `/api/plugins/*` - Ignis plugin management (list, enable, disable). __WIP__
|
- `/api/plugins/*` - Ignis plugin management (list, enable, disable). __WIP__
|
||||||
- `/api/ext/:pluginId/*` - routes registered by individual Ignis plugins.
|
- `/api/ext/:pluginId/*` - routes registered by individual Ignis plugins.
|
||||||
- `/vault-files/<vaultId>/<path>` - static file serving rooted at a vault, used by Obsidian for image/attachment resource URLs.
|
- `/vault-files/<vaultId>/<path>` - static file serving rooted at a vault, used by Obsidian for image/attachment resource URLs.
|
||||||
|
|||||||
171
package-lock.json
generated
171
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ignis-monorepo",
|
"name": "ignis-monorepo",
|
||||||
"version": "0.8.3",
|
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
"name": "@ignis/bridge",
|
"name": "@ignis/bridge",
|
||||||
"version": "0.0.0-internal",
|
"version": "0.0.0-internal",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"main": "src/main.js"
|
"main": "src/main.js"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,4 +51,4 @@ function stopDemoGuards() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { startDemoGuards, stopDemoGuards };
|
export { startDemoGuards, stopDemoGuards, isDemoMode };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { Notice, TFile, TFolder } = require("obsidian");
|
import { Notice, TFile, TFolder } from "obsidian";
|
||||||
|
|
||||||
function getVaultId() {
|
function getVaultId() {
|
||||||
return window.__currentVaultId || "";
|
return window.__currentVaultId || "";
|
||||||
@@ -92,4 +92,4 @@ function addFolderMenuItems(menu, folder, app) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { showFilePicker, addFileMenuItems, addFolderMenuItems };
|
export { showFilePicker, addFileMenuItems, addFolderMenuItems };
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
const { Plugin, TFile, TFolder } = require("obsidian");
|
import { Plugin, TFile, TFolder } from "obsidian";
|
||||||
const {
|
import {
|
||||||
showFilePicker,
|
showFilePicker,
|
||||||
addFileMenuItems,
|
addFileMenuItems,
|
||||||
addFolderMenuItems,
|
addFolderMenuItems,
|
||||||
} = require("./file-actions");
|
} from "./file-actions.js";
|
||||||
const {
|
import {
|
||||||
patchSettingsModal,
|
patchSettingsModal,
|
||||||
unpatchSettingsModal,
|
unpatchSettingsModal,
|
||||||
} = require("./settings/inject");
|
} from "./settings/inject.js";
|
||||||
const pluginRegistry = require("./plugin-registry");
|
import * as pluginRegistry from "./plugin-registry.js";
|
||||||
const { initStatusBar } = require("./status-bar");
|
import { initStatusBar } from "./status-bar.js";
|
||||||
const { WorkspacePickerModal } = require("./workspace-picker");
|
import { WorkspacePickerModal } from "./workspace-picker.js";
|
||||||
const { startDemoGuards, stopDemoGuards } = require("./demo-guards");
|
import { startDemoGuards, stopDemoGuards } from "./demo-guards.js";
|
||||||
|
|
||||||
class IgnisBridgePlugin extends Plugin {
|
class IgnisBridgePlugin extends Plugin {
|
||||||
async onload() {
|
async onload() {
|
||||||
@@ -65,4 +65,4 @@ class IgnisBridgePlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = IgnisBridgePlugin;
|
export default IgnisBridgePlugin;
|
||||||
|
|||||||
@@ -34,4 +34,4 @@ function getKnownIds() {
|
|||||||
return knownIds;
|
return knownIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { refresh, isIgnisPlugin, addId, getKnownIds };
|
export { refresh, isIgnisPlugin, addId, getKnownIds };
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
const { Setting } = require("obsidian");
|
import { Setting, Notice } from "obsidian";
|
||||||
|
import { isDemoMode } from "../demo-guards.js";
|
||||||
|
import { stripBuildMetadata, isNewer } from "../util/version.js";
|
||||||
|
import { ListEditorModal } from "./list-editor-modal.js";
|
||||||
|
|
||||||
const GITHUB_URL = "https://github.com/Nystik-gh/ignis";
|
const GITHUB_URL = "https://github.com/Nystik-gh/ignis";
|
||||||
const GITHUB_API_LATEST =
|
const GITHUB_API_LATEST =
|
||||||
@@ -8,11 +11,6 @@ function getVersion() {
|
|||||||
return window.__ignis?.version || "unknown";
|
return window.__ignis?.version || "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
// SemVer build metadata (`+xyz`) is informational and ignored for precedence.
|
|
||||||
function stripBuildMetadata(version) {
|
|
||||||
return (version || "").split("+")[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkForUpdate(currentVersion) {
|
async function checkForUpdate(currentVersion) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(GITHUB_API_LATEST);
|
const res = await fetch(GITHUB_API_LATEST);
|
||||||
@@ -25,7 +23,7 @@ async function checkForUpdate(currentVersion) {
|
|||||||
const latest = stripBuildMetadata(data.tag_name?.replace(/^v/, ""));
|
const latest = stripBuildMetadata(data.tag_name?.replace(/^v/, ""));
|
||||||
const current = stripBuildMetadata(currentVersion);
|
const current = stripBuildMetadata(currentVersion);
|
||||||
|
|
||||||
if (latest && latest !== current) {
|
if (isNewer(latest, current)) {
|
||||||
return { version: latest, url: data.html_url };
|
return { version: latest, url: data.html_url };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +86,7 @@ function display(containerEl, app) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
addServerStatus(containerEl);
|
addServerStatus(containerEl);
|
||||||
|
addServerSettings(containerEl, app);
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
const STATUS_LABELS = {
|
||||||
@@ -102,10 +101,22 @@ const STATUS_DOT_CLASSES = {
|
|||||||
closed: "ignis-status-disconnected",
|
closed: "ignis-status-disconnected",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function createSettingGroup(containerEl, heading) {
|
||||||
|
const group = containerEl.createDiv("setting-group");
|
||||||
|
|
||||||
|
if (heading) {
|
||||||
|
new Setting(group).setName(heading).setHeading();
|
||||||
|
}
|
||||||
|
|
||||||
|
return group.createDiv("setting-items");
|
||||||
|
}
|
||||||
|
|
||||||
function addServerStatus(containerEl) {
|
function addServerStatus(containerEl) {
|
||||||
const ws = window.__ignis.ws;
|
const ws = window.__ignis.ws;
|
||||||
|
|
||||||
const setting = new Setting(containerEl).setName("Server status");
|
const items = createSettingGroup(containerEl);
|
||||||
|
|
||||||
|
const setting = new Setting(items).setName("Server status");
|
||||||
|
|
||||||
const dotEl = setting.controlEl.createEl("span", {
|
const dotEl = setting.controlEl.createEl("span", {
|
||||||
cls: "ignis-status-dot",
|
cls: "ignis-status-dot",
|
||||||
@@ -138,4 +149,214 @@ function addServerStatus(containerEl) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { display };
|
const MB = 1024 * 1024;
|
||||||
|
const MINUTE = 60 * 1000;
|
||||||
|
|
||||||
|
function addServerSettings(containerEl, app) {
|
||||||
|
if (isDemoMode()) {
|
||||||
|
const items = createSettingGroup(containerEl);
|
||||||
|
|
||||||
|
new Setting(items)
|
||||||
|
.setName("Server settings")
|
||||||
|
.setDesc("Server settings are disabled in demo mode.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = containerEl.createEl("p", {
|
||||||
|
text: "Loading server settings...",
|
||||||
|
cls: "setting-item-description",
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch("/api/settings")
|
||||||
|
.then((res) => (res.ok ? res.json() : Promise.reject(res)))
|
||||||
|
.then((current) => {
|
||||||
|
loading.remove();
|
||||||
|
renderServerSettings(containerEl, current, app);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
loading.setText("Failed to load server settings.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderServerSettings(containerEl, current, app) {
|
||||||
|
const caching = createSettingGroup(containerEl, "Caching");
|
||||||
|
|
||||||
|
numberField(caching, {
|
||||||
|
name: "Content cache (MB)",
|
||||||
|
desc: "Browser cache of file content. Applies after reload.",
|
||||||
|
value: Math.round(current.contentCacheBytes / MB),
|
||||||
|
key: "contentCacheBytes",
|
||||||
|
toStored: (n) => n * MB,
|
||||||
|
});
|
||||||
|
|
||||||
|
numberField(caching, {
|
||||||
|
name: "Input cache (MB)",
|
||||||
|
desc: "Cache for files picked for import. Applies after reload.",
|
||||||
|
value: Math.round(current.inputCacheBytes / MB),
|
||||||
|
key: "inputCacheBytes",
|
||||||
|
toStored: (n) => n * MB,
|
||||||
|
});
|
||||||
|
|
||||||
|
numberField(caching, {
|
||||||
|
name: "Input cache TTL (minutes)",
|
||||||
|
desc: "How long picked files stay cached. Applies after reload.",
|
||||||
|
value: Math.round(current.inputCacheTtlMs / MINUTE),
|
||||||
|
key: "inputCacheTtlMs",
|
||||||
|
toStored: (n) => n * MINUTE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const security = createSettingGroup(containerEl, "Security");
|
||||||
|
|
||||||
|
numberField(security, {
|
||||||
|
name: "Max request body (MB)",
|
||||||
|
desc: "Largest request the server accepts.",
|
||||||
|
value: Math.round(current.maxBodyBytes / MB),
|
||||||
|
key: "maxBodyBytes",
|
||||||
|
toStored: (n) => n * MB,
|
||||||
|
});
|
||||||
|
|
||||||
|
proxyAccessField(security, current, app);
|
||||||
|
|
||||||
|
const advanced = createSettingGroup(containerEl, "Advanced");
|
||||||
|
|
||||||
|
numberField(advanced, {
|
||||||
|
name: "Write coalesce window (ms)",
|
||||||
|
desc: "Debounce window for rapid writes on slow filesystems. 0 disables.",
|
||||||
|
value: current.writeCoalesceMs,
|
||||||
|
key: "writeCoalesceMs",
|
||||||
|
toStored: (n) => n,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist a single setting. The server validates, applies the live ones, and saves.
|
||||||
|
async function saveSetting(partial) {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(partial),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || "Save failed");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
new Notice(`Failed to save setting: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberField(containerEl, { name, desc, value, key, toStored }) {
|
||||||
|
let committed = value;
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName(name)
|
||||||
|
.setDesc(desc)
|
||||||
|
.addText((text) => {
|
||||||
|
text.setValue(String(value));
|
||||||
|
|
||||||
|
// Commit only on change.
|
||||||
|
const commit = () => {
|
||||||
|
const n = parseInt(text.getValue(), 10);
|
||||||
|
|
||||||
|
if (!Number.isInteger(n) || n < 0 || n === committed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
committed = n;
|
||||||
|
saveSetting({ [key]: toStored(n) });
|
||||||
|
};
|
||||||
|
|
||||||
|
text.inputEl.addEventListener("blur", commit);
|
||||||
|
text.inputEl.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
commit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy access mode plus the allowlist row, which only shows in "allowlist" mode.
|
||||||
|
function proxyAccessField(parent, current, app) {
|
||||||
|
let mode = current.proxyMode || "any";
|
||||||
|
|
||||||
|
const setting = new Setting(parent)
|
||||||
|
.setName("Proxy access")
|
||||||
|
.setDesc(
|
||||||
|
"Which external hosts Obsidian may reach through the server's CORS proxy.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const allowlistSetting = listField(parent, {
|
||||||
|
name: "Proxy host allowlist",
|
||||||
|
desc: "Hostnames the proxy may reach, matched exactly.",
|
||||||
|
value: current.proxyAllowlist,
|
||||||
|
key: "proxyAllowlist",
|
||||||
|
app,
|
||||||
|
modal: {
|
||||||
|
placeholder: "api.example.com",
|
||||||
|
emptyNote: "No hosts yet.",
|
||||||
|
recommended: {
|
||||||
|
note: "Restricting the proxy stops Obsidian's plugin and theme browser and updates from working unless their hosts are allowed.",
|
||||||
|
hosts: [
|
||||||
|
"releases.obsidian.md",
|
||||||
|
"github.com",
|
||||||
|
"api.github.com",
|
||||||
|
"raw.githubusercontent.com",
|
||||||
|
],
|
||||||
|
buttonText: "Add recommended hosts",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyVisibility = () => {
|
||||||
|
allowlistSetting.settingEl.style.display =
|
||||||
|
mode === "allowlist" ? "" : "none";
|
||||||
|
};
|
||||||
|
|
||||||
|
setting.addDropdown((dd) => {
|
||||||
|
dd.addOption("any", "Any public host");
|
||||||
|
dd.addOption("allowlist", "Allowlist only");
|
||||||
|
dd.addOption("disabled", "Disabled");
|
||||||
|
dd.setValue(mode);
|
||||||
|
|
||||||
|
dd.onChange((value) => {
|
||||||
|
mode = value;
|
||||||
|
saveSetting({ proxyMode: value });
|
||||||
|
applyVisibility();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
applyVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function listField(containerEl, { name, desc, value, key, app, modal }) {
|
||||||
|
let current = [...(value || [])];
|
||||||
|
|
||||||
|
const setting = new Setting(containerEl).setName(name).setDesc(desc);
|
||||||
|
|
||||||
|
const setLabel = (btn) =>
|
||||||
|
btn.setButtonText(current.length ? `Edit (${current.length})` : "Edit");
|
||||||
|
|
||||||
|
setting.addButton((btn) => {
|
||||||
|
setLabel(btn);
|
||||||
|
|
||||||
|
btn.onClick(() => {
|
||||||
|
new ListEditorModal(app, {
|
||||||
|
title: name,
|
||||||
|
placeholder: modal.placeholder,
|
||||||
|
emptyNote: modal.emptyNote,
|
||||||
|
recommended: modal.recommended,
|
||||||
|
values: current,
|
||||||
|
onChange: (next) => {
|
||||||
|
current = next;
|
||||||
|
saveSetting({ [key]: current });
|
||||||
|
setLabel(btn);
|
||||||
|
},
|
||||||
|
}).open();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return setting;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { display };
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
const generalTab = require("./general-tab");
|
import * as generalTab from "./general-tab.js";
|
||||||
const serverPluginsTab = require("./server-plugins-tab");
|
import * as serverPluginsTab from "./server-plugins-tab.js";
|
||||||
const { createNavEl, createTab, createGroup } = require("./settings-ui");
|
import { createNavEl, createTab, createGroup } from "./settings-ui.js";
|
||||||
const {
|
import {
|
||||||
allIgnisNavEls,
|
allIgnisNavEls,
|
||||||
setupPluginTabs,
|
setupPluginTabs,
|
||||||
reconcilePluginTabs,
|
reconcilePluginTabs,
|
||||||
hideIgnisFromCommunityPlugins,
|
hideIgnisFromCommunityPlugins,
|
||||||
restoreCommunityPlugins,
|
restoreCommunityPlugins,
|
||||||
clearOwnedPluginIds,
|
clearOwnedPluginIds,
|
||||||
} = require("./plugin-tabs");
|
} from "./plugin-tabs.js";
|
||||||
|
|
||||||
function removeExistingIgnisGroups(tabHeadersEl) {
|
function removeExistingIgnisGroups(tabHeadersEl) {
|
||||||
const groups = tabHeadersEl.querySelectorAll(".vertical-tab-header-group");
|
const groups = tabHeadersEl.querySelectorAll(".vertical-tab-header-group");
|
||||||
@@ -139,4 +139,4 @@ function unpatchSettingsModal(plugin) {
|
|||||||
clearOwnedPluginIds();
|
clearOwnedPluginIds();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs };
|
export { patchSettingsModal, unpatchSettingsModal, reconcilePluginTabs };
|
||||||
|
|||||||
134
packages/bridge/src/settings/list-editor-modal.js
Normal file
134
packages/bridge/src/settings/list-editor-modal.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { Modal, Setting, Notice } from "obsidian";
|
||||||
|
|
||||||
|
// Modal editor for a list of string entries (the proxy host allowlist).
|
||||||
|
class ListEditorModal extends Modal {
|
||||||
|
constructor(app, opts) {
|
||||||
|
super(app);
|
||||||
|
this.opts = opts;
|
||||||
|
this.values = [...(opts.values || [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen() {
|
||||||
|
this.titleEl.setText(this.opts.title);
|
||||||
|
|
||||||
|
if (this.opts.recommended) {
|
||||||
|
new Setting(this.contentEl)
|
||||||
|
.setDesc(this.opts.recommended.note)
|
||||||
|
.addButton((btn) =>
|
||||||
|
btn
|
||||||
|
.setButtonText(
|
||||||
|
this.opts.recommended.buttonText || "Add recommended",
|
||||||
|
)
|
||||||
|
.onClick(() => this.addRecommended()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.listEl = this.contentEl.createDiv("ignis-list-editor");
|
||||||
|
this.renderList();
|
||||||
|
|
||||||
|
new Setting(this.contentEl)
|
||||||
|
.setName("Add entry")
|
||||||
|
.addText((text) => {
|
||||||
|
this.input = text;
|
||||||
|
text.setPlaceholder(this.opts.placeholder || "");
|
||||||
|
|
||||||
|
text.inputEl.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
this.addCurrent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.addButton((btn) =>
|
||||||
|
btn
|
||||||
|
.setButtonText("Add")
|
||||||
|
.setCta()
|
||||||
|
.onClick(() => this.addCurrent()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addEntry(entry) {
|
||||||
|
if (this.values.includes(entry)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.values.push(entry);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
addCurrent() {
|
||||||
|
const entry = this.input.getValue().trim();
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.addEntry(entry)) {
|
||||||
|
new Notice("That entry is already in the list.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.input.setValue("");
|
||||||
|
this.input.inputEl.focus();
|
||||||
|
this.commit();
|
||||||
|
this.renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
addRecommended() {
|
||||||
|
let added = 0;
|
||||||
|
|
||||||
|
for (const host of this.opts.recommended.hosts) {
|
||||||
|
if (this.addEntry(host)) {
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (added > 0) {
|
||||||
|
this.commit();
|
||||||
|
this.renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
new Notice(
|
||||||
|
added > 0
|
||||||
|
? `Added ${added} host${added === 1 ? "" : "s"}.`
|
||||||
|
: "All recommended hosts are already in the list.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(entry) {
|
||||||
|
this.values = this.values.filter((v) => v !== entry);
|
||||||
|
this.commit();
|
||||||
|
this.renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderList() {
|
||||||
|
this.listEl.empty();
|
||||||
|
|
||||||
|
if (this.values.length === 0) {
|
||||||
|
this.listEl.createDiv({
|
||||||
|
text: this.opts.emptyNote,
|
||||||
|
cls: "ignis-list-empty",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of this.values) {
|
||||||
|
new Setting(this.listEl).setName(entry).addExtraButton((btn) =>
|
||||||
|
btn
|
||||||
|
.setIcon("trash-2")
|
||||||
|
.setTooltip("Remove")
|
||||||
|
.onClick(() => this.remove(entry)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
commit() {
|
||||||
|
this.opts.onChange([...this.values]);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
this.contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ListEditorModal };
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
const { setIcon } = require("obsidian");
|
import { setIcon } from "obsidian";
|
||||||
const { findGroupByTitle } = require("./settings-ui");
|
import { findGroupByTitle } from "./settings-ui.js";
|
||||||
const { isIgnisPlugin } = require("../plugin-registry");
|
import { isIgnisPlugin } from "../plugin-registry.js";
|
||||||
|
|
||||||
// All ignis-managed nav elements (both Ignis group and Ignis Core Plugins group).
|
// All ignis-managed nav elements (both Ignis group and Ignis Core Plugins group).
|
||||||
// Shared with inject.js so the openTab patch can manage is-active across all of them.
|
// Shared with inject.js so the openTab patch can manage is-active across all of them.
|
||||||
@@ -232,7 +232,7 @@ function clearOwnedPluginIds() {
|
|||||||
ownedPluginIds.clear();
|
ownedPluginIds.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
export {
|
||||||
allIgnisNavEls,
|
allIgnisNavEls,
|
||||||
setupPluginTabs,
|
setupPluginTabs,
|
||||||
reconcilePluginTabs,
|
reconcilePluginTabs,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const { Setting, Notice } = require("obsidian");
|
import { Setting, Notice } from "obsidian";
|
||||||
const { reconcilePluginTabs } = require("./plugin-tabs");
|
import { reconcilePluginTabs } from "./plugin-tabs.js";
|
||||||
|
|
||||||
function getVaultId() {
|
function getVaultId() {
|
||||||
return window.__currentVaultId || "";
|
return window.__currentVaultId || "";
|
||||||
@@ -94,4 +94,4 @@ function display(containerEl, app) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { display };
|
export { display };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { setIcon } = require("obsidian");
|
import { setIcon } from "obsidian";
|
||||||
|
|
||||||
function createNavEl(tab, setting) {
|
function createNavEl(tab, setting) {
|
||||||
const nav = document.createElement("div");
|
const nav = document.createElement("div");
|
||||||
@@ -86,4 +86,4 @@ function findGroupByTitle(tabHeadersEl, title) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createNavEl, createTab, createGroup, findGroupByTitle };
|
export { createNavEl, createTab, createGroup, findGroupByTitle };
|
||||||
|
|||||||
@@ -32,4 +32,4 @@ function initStatusBar(plugin) {
|
|||||||
return ws.onStateChange(render);
|
return ws.onStateChange(render);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { initStatusBar };
|
export { initStatusBar };
|
||||||
|
|||||||
39
packages/bridge/src/util/version.js
Normal file
39
packages/bridge/src/util/version.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Version comparison helpers for the update check.
|
||||||
|
|
||||||
|
// SemVer build metadata (`+xyz`) is informational and ignored for precedence.
|
||||||
|
function stripBuildMetadata(version) {
|
||||||
|
return (version || "").split("+")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse X.Y.Z to [major, minor, patch], or null when it isn't three integers.
|
||||||
|
function parseSemver(version) {
|
||||||
|
const parts = (version || "").split(".");
|
||||||
|
|
||||||
|
if (parts.length < 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nums = parts.slice(0, 3).map((p) => parseInt(p, 10));
|
||||||
|
|
||||||
|
return nums.some((n) => !Number.isInteger(n)) ? null : nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True only when latest is strictly newer than current.
|
||||||
|
function isNewer(latest, current) {
|
||||||
|
const a = parseSemver(latest);
|
||||||
|
const b = parseSemver(current);
|
||||||
|
|
||||||
|
if (!a || !b) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (a[i] !== b[i]) {
|
||||||
|
return a[i] > b[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { stripBuildMetadata, parseSemver, isNewer };
|
||||||
28
packages/bridge/src/util/version.test.js
Normal file
28
packages/bridge/src/util/version.test.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { stripBuildMetadata, isNewer } from "./version.js";
|
||||||
|
|
||||||
|
describe("isNewer", () => {
|
||||||
|
it("is true when latest is strictly newer", () => {
|
||||||
|
expect(isNewer("0.8.4", "0.8.3")).toBe(true);
|
||||||
|
expect(isNewer("1.0.0", "0.9.9")).toBe(true);
|
||||||
|
expect(isNewer("0.9.0", "0.8.9")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for older or equal, so no downgrade is prompted", () => {
|
||||||
|
expect(isNewer("0.8.3", "0.8.4")).toBe(false);
|
||||||
|
expect(isNewer("0.8.4", "0.8.4")).toBe(false);
|
||||||
|
expect(isNewer("0.9.9", "1.0.0")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for malformed versions", () => {
|
||||||
|
expect(isNewer("x", "0.8.4")).toBe(false);
|
||||||
|
expect(isNewer("0.8", "0.8.4")).toBe(false);
|
||||||
|
expect(isNewer("1.x.0", "0.8.4")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores build metadata, so an equal version with a build tag is not newer", () => {
|
||||||
|
expect(
|
||||||
|
isNewer(stripBuildMetadata("0.8.4"), stripBuildMetadata("0.8.4+q2fmfox")),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const { FuzzySuggestModal } = require("obsidian");
|
import { FuzzySuggestModal } from "obsidian";
|
||||||
|
|
||||||
class WorkspacePickerModal extends FuzzySuggestModal {
|
class WorkspacePickerModal extends FuzzySuggestModal {
|
||||||
constructor(app) {
|
constructor(app) {
|
||||||
@@ -29,4 +29,4 @@ class WorkspacePickerModal extends FuzzySuggestModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { WorkspacePickerModal };
|
export { WorkspacePickerModal };
|
||||||
|
|||||||
@@ -141,3 +141,18 @@
|
|||||||
font-size: var(--font-ui-small);
|
font-size: var(--font-ui-small);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ignis-list-editor {
|
||||||
|
border: 1px solid var(--background-modifier-border);
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 var(--size-4-3);
|
||||||
|
margin-bottom: var(--size-4-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ignis-list-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-ui-smaller);
|
||||||
|
padding: var(--size-4-3) 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ const { WebSocketServer } = require("ws");
|
|||||||
const url = require("url");
|
const url = require("url");
|
||||||
const watcher = require("./watcher");
|
const watcher = require("./watcher");
|
||||||
|
|
||||||
|
// Null / undefined / empty array means no Origin check.
|
||||||
|
function toOriginSet(list) {
|
||||||
|
return Array.isArray(list) && list.length > 0 ? new Set(list) : null;
|
||||||
|
}
|
||||||
|
|
||||||
function setupWebSocket(server, opts = {}) {
|
function setupWebSocket(server, opts = {}) {
|
||||||
const { getVaultPath, originAllowlist } = opts;
|
const { getVaultPath, originAllowlist } = opts;
|
||||||
|
|
||||||
@@ -9,11 +14,7 @@ function setupWebSocket(server, opts = {}) {
|
|||||||
throw new Error("setupWebSocket: opts.getVaultPath is required");
|
throw new Error("setupWebSocket: opts.getVaultPath is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Null / undefined / empty array = no Origin check.
|
const originSet = toOriginSet(originAllowlist);
|
||||||
const originSet =
|
|
||||||
Array.isArray(originAllowlist) && originAllowlist.length > 0
|
|
||||||
? new Set(originAllowlist)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const wss = new WebSocketServer({ server, path: "/ws" });
|
const wss = new WebSocketServer({ server, path: "/ws" });
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { showVaultManager } from "../ui-registry.js";
|
import { showVaultManager } from "../ui-registry.js";
|
||||||
import { vaultService } from "@ignis/services";
|
import { vaultService } from "@ignis/services";
|
||||||
|
import { proxyFetch } from "../util/proxy.js";
|
||||||
|
|
||||||
const listeners = new Map();
|
const listeners = new Map();
|
||||||
|
|
||||||
@@ -85,66 +86,21 @@ const syncHandlers = {
|
|||||||
resources: () => "",
|
resources: () => "",
|
||||||
};
|
};
|
||||||
|
|
||||||
function arrayBufferToBase64(buf) {
|
|
||||||
const bytes = new Uint8Array(buf);
|
|
||||||
let binary = "";
|
|
||||||
const chunk = 8192;
|
|
||||||
|
|
||||||
for (let i = 0; i < bytes.length; i += chunk) {
|
|
||||||
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
|
||||||
}
|
|
||||||
|
|
||||||
return btoa(binary);
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64ToArrayBuffer(base64) {
|
|
||||||
const binary = atob(base64);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
|
|
||||||
for (let i = 0; i < binary.length; i++) {
|
|
||||||
bytes[i] = binary.charCodeAt(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
return bytes.buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRequestUrl(requestId, request) {
|
async function handleRequestUrl(requestId, request) {
|
||||||
try {
|
try {
|
||||||
let body = request.body;
|
const result = await proxyFetch({
|
||||||
let binary = false;
|
url: request.url,
|
||||||
|
method: request.method,
|
||||||
if (body instanceof ArrayBuffer) {
|
headers: request.headers,
|
||||||
body = arrayBufferToBase64(body);
|
body: request.body,
|
||||||
binary = true;
|
contentType: request.contentType,
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch("/api/proxy", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
url: request.url,
|
|
||||||
method: request.method || "GET",
|
|
||||||
headers: request.headers || {},
|
|
||||||
contentType: request.contentType,
|
|
||||||
body,
|
|
||||||
binary,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const proxyResult = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
ipcRenderer._emit(requestId, {
|
|
||||||
error: proxyResult.error || "Proxy request failed",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Electron's e.reply(requestId, data) sends on the requestId channel
|
// Electron's e.reply(requestId, data) sends on the requestId channel
|
||||||
ipcRenderer._emit(requestId, {
|
ipcRenderer._emit(requestId, {
|
||||||
status: proxyResult.status,
|
status: result.status,
|
||||||
headers: proxyResult.headers,
|
headers: result.headers,
|
||||||
body: base64ToArrayBuffer(proxyResult.body),
|
body: result.body,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ipcRenderer._emit(requestId, {
|
ipcRenderer._emit(requestId, {
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
|
import { getClipboard } from "./native-clipboard.js";
|
||||||
|
|
||||||
export const clipboardShim = {
|
export const clipboardShim = {
|
||||||
readText() {
|
readText() {
|
||||||
return "";
|
return "";
|
||||||
},
|
},
|
||||||
|
|
||||||
writeText(text) {
|
writeText(text) {
|
||||||
navigator.clipboard.writeText(text).catch((e) => {
|
const clip = getClipboard();
|
||||||
|
|
||||||
|
if (!clip) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clip.writeText(text).catch((e) => {
|
||||||
console.warn("[shim:clipboard] writeText failed:", e);
|
console.warn("[shim:clipboard] writeText failed:", e);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -14,7 +22,13 @@ export const clipboardShim = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
writeHTML(html) {
|
writeHTML(html) {
|
||||||
navigator.clipboard
|
const clip = getClipboard();
|
||||||
|
|
||||||
|
if (!clip) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clip
|
||||||
.write([
|
.write([
|
||||||
new ClipboardItem({
|
new ClipboardItem({
|
||||||
"text/html": new Blob([html], { type: "text/html" }),
|
"text/html": new Blob([html], { type: "text/html" }),
|
||||||
@@ -35,6 +49,12 @@ export const clipboardShim = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clip = getClipboard();
|
||||||
|
|
||||||
|
if (!clip) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const pngData = image.toPNG();
|
const pngData = image.toPNG();
|
||||||
|
|
||||||
if (!pngData || pngData.length === 0) {
|
if (!pngData || pngData.length === 0) {
|
||||||
@@ -43,11 +63,9 @@ export const clipboardShim = {
|
|||||||
|
|
||||||
const blob = new Blob([pngData], { type: "image/png" });
|
const blob = new Blob([pngData], { type: "image/png" });
|
||||||
|
|
||||||
navigator.clipboard
|
clip.write([new ClipboardItem({ "image/png": blob })]).catch((e) => {
|
||||||
.write([new ClipboardItem({ "image/png": blob })])
|
console.warn("[shim:clipboard] writeImage failed:", e);
|
||||||
.catch((e) => {
|
});
|
||||||
console.warn("[shim:clipboard] writeImage failed:", e);
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
has(format) {
|
has(format) {
|
||||||
@@ -59,6 +77,12 @@ export const clipboardShim = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
navigator.clipboard.writeText("").catch(() => {});
|
const clip = getClipboard();
|
||||||
|
|
||||||
|
if (!clip) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clip.writeText("").catch(() => {});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
22
packages/shim/src/electron/remote/native-clipboard.js
Normal file
22
packages/shim/src/electron/remote/native-clipboard.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Obsidian points navigator.clipboard.writeText at electron.clipboard, which already points at this shim.
|
||||||
|
// To avoid recursion, use the untouched native prototype methods.
|
||||||
|
const proto = typeof Clipboard !== "undefined" ? Clipboard.prototype : null;
|
||||||
|
|
||||||
|
// Returns a native-backed clipboard facade, or null in insecure (non-localhost http) contexts.
|
||||||
|
export function getClipboard() {
|
||||||
|
const clip =
|
||||||
|
typeof navigator !== "undefined" ? navigator.clipboard : undefined;
|
||||||
|
|
||||||
|
if (!proto || !clip) {
|
||||||
|
console.warn(
|
||||||
|
"[shim:clipboard] clipboard API unavailable (insecure context?)",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
writeText: (text) => proto.writeText.call(clip, text),
|
||||||
|
write: (items) => proto.write.call(clip, items),
|
||||||
|
read: () => proto.read.call(clip),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getClipboard } from "./native-clipboard.js";
|
||||||
|
|
||||||
const currentWindowState = {
|
const currentWindowState = {
|
||||||
title: "Obsidian",
|
title: "Obsidian",
|
||||||
isMaximized: false,
|
isMaximized: false,
|
||||||
@@ -196,7 +198,13 @@ const currentWebContents = {
|
|||||||
document.execCommand("copy");
|
document.execCommand("copy");
|
||||||
},
|
},
|
||||||
paste() {
|
paste() {
|
||||||
navigator.clipboard
|
const clip = getClipboard();
|
||||||
|
|
||||||
|
if (!clip) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clip
|
||||||
.read()
|
.read()
|
||||||
.then(async (items) => {
|
.then(async (items) => {
|
||||||
const dt = new DataTransfer();
|
const dt = new DataTransfer();
|
||||||
@@ -233,7 +241,13 @@ const currentWebContents = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
pasteAndMatchStyle() {
|
pasteAndMatchStyle() {
|
||||||
navigator.clipboard
|
const clip = getClipboard();
|
||||||
|
|
||||||
|
if (!clip) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clip
|
||||||
.read()
|
.read()
|
||||||
.then(async (items) => {
|
.then(async (items) => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
|||||||
34
packages/shim/src/fs/callback.js
Normal file
34
packages/shim/src/fs/callback.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const CALLBACK_METHODS = [
|
||||||
|
"stat",
|
||||||
|
"lstat",
|
||||||
|
"readdir",
|
||||||
|
"readFile",
|
||||||
|
"writeFile",
|
||||||
|
"appendFile",
|
||||||
|
"unlink",
|
||||||
|
"rename",
|
||||||
|
"mkdir",
|
||||||
|
"rmdir",
|
||||||
|
"rm",
|
||||||
|
"copyFile",
|
||||||
|
"access",
|
||||||
|
"utimes",
|
||||||
|
"chmod",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createFsCallbacks(fsPromises) {
|
||||||
|
const callbacks = {};
|
||||||
|
|
||||||
|
for (const name of CALLBACK_METHODS) {
|
||||||
|
callbacks[name] = function (...args) {
|
||||||
|
const callback = args.pop();
|
||||||
|
|
||||||
|
fsPromises[name](...args).then(
|
||||||
|
(result) => callback(null, result),
|
||||||
|
(err) => callback(err),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return callbacks;
|
||||||
|
}
|
||||||
47
packages/shim/src/fs/callback.test.js
Normal file
47
packages/shim/src/fs/callback.test.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { createFsCallbacks } from "./callback.js";
|
||||||
|
|
||||||
|
describe("fs callbacks", () => {
|
||||||
|
it("resolves the promise result through the callback", async () => {
|
||||||
|
const fakePromises = { readFile: async (p) => `data:${p}` };
|
||||||
|
const cb = createFsCallbacks(fakePromises);
|
||||||
|
|
||||||
|
const result = await new Promise((resolve) =>
|
||||||
|
cb.readFile("/x", (err, data) => resolve([err, data])),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([null, "data:/x"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a rejection to the callback as the error argument", async () => {
|
||||||
|
const boom = new Error("nope");
|
||||||
|
const fakePromises = {
|
||||||
|
stat: async () => {
|
||||||
|
throw boom;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const cb = createFsCallbacks(fakePromises);
|
||||||
|
|
||||||
|
const result = await new Promise((resolve) =>
|
||||||
|
cb.stat("/x", (err) => resolve(err)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe(boom);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards the arguments that precede the callback", async () => {
|
||||||
|
let received = null;
|
||||||
|
const fakePromises = {
|
||||||
|
mkdir: async (p, opts) => {
|
||||||
|
received = [p, opts];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const cb = createFsCallbacks(fakePromises);
|
||||||
|
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
cb.mkdir("/d", { recursive: true }, () => resolve()),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(received).toEqual(["/d", { recursive: true }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,14 @@ export class ContentCache {
|
|||||||
this._maxSize = maxSize;
|
this._maxSize = maxSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMaxSize(maxSize) {
|
||||||
|
this._maxSize = maxSize;
|
||||||
|
|
||||||
|
while (this._currentSize > this._maxSize && this._cache.size > 0) {
|
||||||
|
this._evictOne();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
has(path) {
|
has(path) {
|
||||||
return this._cache.has(this._normalize(path));
|
return this._cache.has(this._normalize(path));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
// Shared echo suppression for file watcher.
|
// Shared echo suppression for file watcher.
|
||||||
// fs operations mark paths as "locally modified" so the watcher client
|
// fs operations mark paths as "locally modified" so the watcher client can skip events that originated from this client.
|
||||||
// can skip events that originated from this client.
|
import { normalize } from "../util/path.js";
|
||||||
|
|
||||||
const ECHO_SUPPRESS_MS = 1500;
|
const ECHO_SUPPRESS_MS = 1500;
|
||||||
const recentOps = new Map(); // normalized path -> timestamp
|
const recentOps = new Map(); // normalized path -> timestamp
|
||||||
|
|
||||||
function normalize(p) {
|
|
||||||
return (p || "")
|
|
||||||
.replace(/\\/g, "/")
|
|
||||||
.replace(/^\/+/, "")
|
|
||||||
.replace(/\/+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function markLocalOp(path) {
|
export function markLocalOp(path) {
|
||||||
recentOps.set(normalize(path), Date.now());
|
recentOps.set(normalize(path), Date.now());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}'`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { createFsSync } from "./sync.js";
|
|||||||
import { createFsWatch } from "./watch.js";
|
import { createFsWatch } from "./watch.js";
|
||||||
import { createWatcherClient } from "./watcher-client.js";
|
import { createWatcherClient } from "./watcher-client.js";
|
||||||
import { createFdOps } from "./fd.js";
|
import { createFdOps } from "./fd.js";
|
||||||
|
import { createFsCallbacks } from "./callback.js";
|
||||||
|
import { realpath, realpathSync } from "./realpath.js";
|
||||||
import { constants } from "./constants.js";
|
import { constants } from "./constants.js";
|
||||||
import { registerReadTransform, removeReadTransform, resolvePath } from "./transforms.js";
|
import { registerReadTransform, removeReadTransform, resolvePath } from "./transforms.js";
|
||||||
import { wsClient } from "../ws-client.js";
|
import { wsClient } from "../ws-client.js";
|
||||||
@@ -18,10 +20,13 @@ const fsSync = createFsSync(metadataCache, contentCache, transport);
|
|||||||
const fsWatch = createFsWatch(transport);
|
const fsWatch = createFsWatch(transport);
|
||||||
const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch, wsClient);
|
const watcherClient = createWatcherClient(metadataCache, contentCache, fsWatch, wsClient);
|
||||||
const fdOps = createFdOps(metadataCache, contentCache, transport);
|
const fdOps = createFdOps(metadataCache, contentCache, transport);
|
||||||
|
const fsCallbacks = createFsCallbacks(fsPromises);
|
||||||
|
|
||||||
export const fsShim = {
|
export const fsShim = {
|
||||||
promises: fsPromises,
|
promises: fsPromises,
|
||||||
|
|
||||||
|
...fsCallbacks,
|
||||||
|
|
||||||
existsSync: fsSync.existsSync,
|
existsSync: fsSync.existsSync,
|
||||||
readFileSync: fsSync.readFileSync,
|
readFileSync: fsSync.readFileSync,
|
||||||
writeFileSync: fsSync.writeFileSync,
|
writeFileSync: fsSync.writeFileSync,
|
||||||
@@ -29,6 +34,18 @@ export const fsShim = {
|
|||||||
accessSync: fsSync.accessSync,
|
accessSync: fsSync.accessSync,
|
||||||
statSync: fsSync.statSync,
|
statSync: fsSync.statSync,
|
||||||
readdirSync: fsSync.readdirSync,
|
readdirSync: fsSync.readdirSync,
|
||||||
|
lstatSync: fsSync.lstatSync,
|
||||||
|
mkdirSync: fsSync.mkdirSync,
|
||||||
|
rmdirSync: fsSync.rmdirSync,
|
||||||
|
rmSync: fsSync.rmSync,
|
||||||
|
renameSync: fsSync.renameSync,
|
||||||
|
copyFileSync: fsSync.copyFileSync,
|
||||||
|
appendFileSync: fsSync.appendFileSync,
|
||||||
|
utimesSync: fsSync.utimesSync,
|
||||||
|
chmodSync: fsSync.chmodSync,
|
||||||
|
|
||||||
|
realpath,
|
||||||
|
realpathSync,
|
||||||
|
|
||||||
open: fdOps.open,
|
open: fdOps.open,
|
||||||
openSync: fdOps.openSync,
|
openSync: fdOps.openSync,
|
||||||
|
|||||||
@@ -5,19 +5,14 @@
|
|||||||
// - 5-minute TTL per entry
|
// - 5-minute TTL per entry
|
||||||
// - Entries kept until TTL expires (plugins may read the same file multiple times)
|
// - Entries kept until TTL expires (plugins may read the same file multiple times)
|
||||||
|
|
||||||
const MAX_SIZE = 200 * 1024 * 1024;
|
import { normalize } from "../util/path.js";
|
||||||
const TTL_MS = 5 * 60 * 1000;
|
|
||||||
|
let MAX_SIZE = 200 * 1024 * 1024;
|
||||||
|
let TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
const cache = new Map(); // path -> { data, size, createdAt }
|
const cache = new Map(); // path -> { data, size, createdAt }
|
||||||
let currentSize = 0;
|
let currentSize = 0;
|
||||||
|
|
||||||
function normalize(p) {
|
|
||||||
return (p || "")
|
|
||||||
.replace(/\\/g, "/")
|
|
||||||
.replace(/^\/+/, "")
|
|
||||||
.replace(/\/+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function evictExpired() {
|
function evictExpired() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@@ -117,6 +112,20 @@ export function inputCacheClear() {
|
|||||||
currentSize = 0;
|
currentSize = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setInputCacheLimits({ maxSize, ttlMs }) {
|
||||||
|
if (Number.isFinite(maxSize)) {
|
||||||
|
MAX_SIZE = maxSize;
|
||||||
|
|
||||||
|
while (currentSize > MAX_SIZE && cache.size > 0) {
|
||||||
|
evictOldest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isFinite(ttlMs)) {
|
||||||
|
TTL_MS = ttlMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function isInputCachePath(path) {
|
export function isInputCachePath(path) {
|
||||||
const norm = normalize(path);
|
const norm = normalize(path);
|
||||||
return norm.startsWith(".obsidian/imports/");
|
return norm.startsWith(".obsidian/imports/");
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { markLocalOp } from "./echo-guard.js";
|
import { markLocalOp } from "./echo-guard.js";
|
||||||
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
|
import { isInputCachePath, inputCacheGet } from "./input-cache.js";
|
||||||
import { applyReadTransform, applyWriteTransform, resolvePath } from "./transforms.js";
|
import {
|
||||||
|
applyReadTransform,
|
||||||
|
applyWriteTransform,
|
||||||
|
resolvePath,
|
||||||
|
} 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 {
|
||||||
@@ -256,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) {
|
||||||
@@ -270,6 +276,10 @@ export function createFsPromises(metadataCache, contentCache, transport) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async chmod() {
|
||||||
|
// No permission bits in the vault FS. No-op.
|
||||||
|
},
|
||||||
|
|
||||||
async open(path, flags) {
|
async open(path, flags) {
|
||||||
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null;
|
const hasInCache = isInputCachePath(path) && inputCacheGet(path) !== null;
|
||||||
const resolved = resolvePath(path);
|
const resolved = resolvePath(path);
|
||||||
|
|||||||
12
packages/shim/src/fs/realpath.js
Normal file
12
packages/shim/src/fs/realpath.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export function realpathSync(path) {
|
||||||
|
return typeof path === "string" ? path : String(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function realpath(path, options, callback) {
|
||||||
|
const cb = typeof options === "function" ? options : callback;
|
||||||
|
|
||||||
|
queueMicrotask(() => cb(null, realpathSync(path)));
|
||||||
|
}
|
||||||
|
|
||||||
|
realpath.native = realpath;
|
||||||
|
realpathSync.native = realpathSync;
|
||||||
36
packages/shim/src/fs/realpath.test.js
Normal file
36
packages/shim/src/fs/realpath.test.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { realpath } from "./realpath.js";
|
||||||
|
import { createFsPromises } from "./promises.js";
|
||||||
|
|
||||||
|
describe("fs realpath shim", () => {
|
||||||
|
it("realpath invokes the callback with the path", async () => {
|
||||||
|
const result = await new Promise((resolve) =>
|
||||||
|
realpath("/a/b.md", (err, p) => resolve(p)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("/a/b.md");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("realpath accepts an options argument before the callback", async () => {
|
||||||
|
const result = await new Promise((resolve) =>
|
||||||
|
realpath("/a/b.md", "utf8", (err, p) => resolve(p)),
|
||||||
|
);
|
||||||
|
|
||||||
|
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("/");
|
||||||
|
});
|
||||||
|
});
|
||||||
128
packages/shim/src/fs/sync-mutations.test.js
Normal file
128
packages/shim/src/fs/sync-mutations.test.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { createFsSync } from "./sync.js";
|
||||||
|
import { resolvePath } from "./transforms.js";
|
||||||
|
|
||||||
|
function makeDeps() {
|
||||||
|
const store = new Map();
|
||||||
|
|
||||||
|
const metadataCache = {
|
||||||
|
has: (p) => store.has(p),
|
||||||
|
get: (p) => (store.has(p) ? store.get(p) : null),
|
||||||
|
set: (p, m) => store.set(p, m),
|
||||||
|
delete: (p) => store.delete(p),
|
||||||
|
rename: (a, b) => {
|
||||||
|
if (store.has(a)) {
|
||||||
|
store.set(b, store.get(a));
|
||||||
|
store.delete(a);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toStat: (p) =>
|
||||||
|
store.has(p)
|
||||||
|
? {
|
||||||
|
type: store.get(p).type,
|
||||||
|
isDirectory: () => store.get(p).type === "directory",
|
||||||
|
isFile: () => store.get(p).type === "file",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
readdir: () => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentCache = {
|
||||||
|
get: () => null,
|
||||||
|
set: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
invalidate: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const transport = {
|
||||||
|
mkdir: vi.fn(async () => {}),
|
||||||
|
rmdir: vi.fn(async () => {}),
|
||||||
|
rm: vi.fn(async () => {}),
|
||||||
|
rename: vi.fn(async () => {}),
|
||||||
|
copyFile: vi.fn(async () => {}),
|
||||||
|
appendFile: vi.fn(async () => {}),
|
||||||
|
utimes: vi.fn(async () => {}),
|
||||||
|
stat: vi.fn(async () => ({ type: "file", size: 1 })),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { metadataCache, contentCache, transport, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sync fs mutations", () => {
|
||||||
|
it("lstatSync mirrors statSync", () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
||||||
|
deps.store.set(resolvePath("dir"), { type: "directory" });
|
||||||
|
|
||||||
|
expect(fs.lstatSync("dir").isDirectory()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mkdirSync updates the cache and fires the transport", () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
||||||
|
|
||||||
|
fs.mkdirSync("newdir", { recursive: true });
|
||||||
|
|
||||||
|
expect(deps.store.get("newdir")).toEqual({ type: "directory" });
|
||||||
|
expect(deps.transport.mkdir).toHaveBeenCalledWith("newdir", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rmSync deletes from the cache and fires the transport", () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
||||||
|
const key = resolvePath("gone.md");
|
||||||
|
deps.store.set(key, { type: "file" });
|
||||||
|
|
||||||
|
fs.rmSync("gone.md", { recursive: true });
|
||||||
|
|
||||||
|
expect(deps.store.has(key)).toBe(false);
|
||||||
|
expect(deps.transport.rm).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renameSync moves cache metadata and fires the transport", () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
||||||
|
const from = resolvePath("a.md");
|
||||||
|
const to = resolvePath("b.md");
|
||||||
|
deps.store.set(from, { type: "file", size: 2 });
|
||||||
|
|
||||||
|
fs.renameSync("a.md", "b.md");
|
||||||
|
|
||||||
|
expect(deps.store.has(from)).toBe(false);
|
||||||
|
expect(deps.store.get(to)).toEqual({ type: "file", size: 2 });
|
||||||
|
expect(deps.transport.rename).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copyFileSync optimistically mirrors source metadata and fires the transport", () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
||||||
|
const srcKey = resolvePath("src.md");
|
||||||
|
const destKey = resolvePath("dest.md");
|
||||||
|
deps.store.set(srcKey, { type: "file", size: 9 });
|
||||||
|
|
||||||
|
fs.copyFileSync("src.md", "dest.md");
|
||||||
|
|
||||||
|
expect(deps.store.get(destKey)).toEqual({ type: "file", size: 9 });
|
||||||
|
expect(deps.transport.copyFile).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("utimesSync sets mtime and fires the transport", () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
||||||
|
const key = resolvePath("note.md");
|
||||||
|
deps.store.set(key, { type: "file", mtime: 0 });
|
||||||
|
|
||||||
|
fs.utimesSync("note.md", 111, 222);
|
||||||
|
|
||||||
|
expect(deps.store.get(key).mtime).toBe(222);
|
||||||
|
expect(deps.transport.utimes).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("chmodSync is a no-op that does not throw", () => {
|
||||||
|
const deps = makeDeps();
|
||||||
|
const fs = createFsSync(deps.metadataCache, deps.contentCache, deps.transport);
|
||||||
|
|
||||||
|
expect(() => fs.chmodSync("note.md", 0o644)).not.toThrow();
|
||||||
|
expect(fs.chmodSync("note.md", 0o644)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
@@ -180,5 +196,147 @@ export function createFsSync(metadataCache, contentCache, transport) {
|
|||||||
const entries = metadataCache.readdir(path);
|
const entries = metadataCache.readdir(path);
|
||||||
return entries.map((e) => e.name);
|
return entries.map((e) => e.name);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
lstatSync(path) {
|
||||||
|
// No symlinks in our context.
|
||||||
|
return this.statSync(path);
|
||||||
|
},
|
||||||
|
|
||||||
|
mkdirSync(path, options) {
|
||||||
|
const recursive =
|
||||||
|
typeof options === "object" ? !!options.recursive : !!options;
|
||||||
|
|
||||||
|
markLocalOp(path);
|
||||||
|
metadataCache.set(path, { type: "directory" });
|
||||||
|
|
||||||
|
transport.mkdir(path, recursive).catch((e) => {
|
||||||
|
console.error("[shim:fs] mkdirSync background create failed:", path, e);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
rmdirSync(path) {
|
||||||
|
markLocalOp(path);
|
||||||
|
metadataCache.delete(path);
|
||||||
|
|
||||||
|
transport.rmdir(path).catch((e) => {
|
||||||
|
console.error("[shim:fs] rmdirSync background remove failed:", path, e);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
rmSync(path, options) {
|
||||||
|
const recursive =
|
||||||
|
typeof options === "object" ? !!options.recursive : false;
|
||||||
|
|
||||||
|
const resolved = resolvePath(path);
|
||||||
|
|
||||||
|
markLocalOp(resolved);
|
||||||
|
metadataCache.delete(resolved);
|
||||||
|
contentCache.delete(resolved);
|
||||||
|
|
||||||
|
transport.rm(resolved, recursive).catch((e) => {
|
||||||
|
console.error(
|
||||||
|
"[shim:fs] rmSync background remove failed:",
|
||||||
|
resolved,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
renameSync(oldPath, newPath) {
|
||||||
|
const resolvedOld = resolvePath(oldPath);
|
||||||
|
const resolvedNew = resolvePath(newPath);
|
||||||
|
|
||||||
|
markLocalOp(resolvedOld);
|
||||||
|
markLocalOp(resolvedNew);
|
||||||
|
const content = contentCache.get(resolvedOld);
|
||||||
|
|
||||||
|
if (content !== null) {
|
||||||
|
contentCache.set(resolvedNew, content);
|
||||||
|
contentCache.delete(resolvedOld);
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataCache.rename(resolvedOld, resolvedNew);
|
||||||
|
|
||||||
|
transport.rename(resolvedOld, resolvedNew).catch((e) => {
|
||||||
|
console.error(
|
||||||
|
"[shim:fs] renameSync background rename failed:",
|
||||||
|
resolvedOld,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
copyFileSync(src, dest) {
|
||||||
|
const resolvedSrc = resolvePath(src);
|
||||||
|
const resolvedDest = resolvePath(dest);
|
||||||
|
|
||||||
|
markLocalOp(resolvedDest);
|
||||||
|
|
||||||
|
// Optimistically mirror the source so a sync read right after sees it.
|
||||||
|
const content = contentCache.get(resolvedSrc);
|
||||||
|
|
||||||
|
if (content !== null) {
|
||||||
|
contentCache.set(resolvedDest, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcMeta = metadataCache.get(resolvedSrc);
|
||||||
|
|
||||||
|
if (srcMeta) {
|
||||||
|
metadataCache.set(resolvedDest, { ...srcMeta });
|
||||||
|
}
|
||||||
|
|
||||||
|
transport
|
||||||
|
.copyFile(src, resolvedDest)
|
||||||
|
.then(() => transport.stat(resolvedDest))
|
||||||
|
.then((meta) => metadataCache.set(resolvedDest, meta))
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(
|
||||||
|
"[shim:fs] copyFileSync background copy failed:",
|
||||||
|
resolvedDest,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
appendFileSync(path, data) {
|
||||||
|
const resolved = resolvePath(path);
|
||||||
|
|
||||||
|
markLocalOp(resolved);
|
||||||
|
contentCache.invalidate(resolved);
|
||||||
|
|
||||||
|
transport
|
||||||
|
.appendFile(resolved, data)
|
||||||
|
.then(() => transport.stat(resolved))
|
||||||
|
.then((meta) => metadataCache.set(resolved, meta))
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(
|
||||||
|
"[shim:fs] appendFileSync background append failed:",
|
||||||
|
resolved,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
utimesSync(path, atime, mtime) {
|
||||||
|
const resolved = resolvePath(path);
|
||||||
|
const meta = metadataCache.get(resolved);
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
meta.mtime = typeof mtime === "number" ? mtime : mtime.getTime();
|
||||||
|
metadataCache.set(resolved, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.utimes(resolved, atime, mtime).catch((e) => {
|
||||||
|
console.error(
|
||||||
|
"[shim:fs] utimesSync background utimes failed:",
|
||||||
|
resolved,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
chmodSync() {
|
||||||
|
// The vault FS does not model permission bits. No-op.
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
// Path resolvers map logical paths to physical paths; read transforms post-process bytes after a read; write transforms pre-process bytes before a write.
|
// Path resolvers map logical paths to physical paths; read transforms post-process bytes after a read; write transforms pre-process bytes before a write.
|
||||||
// All hooks run at the shim's public surface, so caches and transport see only physical paths and as-stored bytes.
|
// All hooks run at the shim's public surface, so caches and transport see only physical paths and as-stored bytes.
|
||||||
|
|
||||||
function normalize(p) {
|
import { normalize } from "../util/path.js";
|
||||||
return (p || "").replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Path resolvers ---
|
// --- Path resolvers ---
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
// Virtual plugin source served from memory; the fs shim's read path checks here before disk.
|
// Virtual plugin source served from memory; the fs shim's read path checks here before disk.
|
||||||
|
|
||||||
function normalize(p) {
|
import { normalize } from "../util/path.js";
|
||||||
return (p || "").replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
const virtualFiles = new Map();
|
const virtualFiles = new Map();
|
||||||
|
|
||||||
export function setVirtualFile(path, content) {
|
export function setVirtualFile(path, content) {
|
||||||
virtualFiles.set(normalize(path), content);
|
const normalized = normalize(path);
|
||||||
|
|
||||||
|
if (normalized.split("/").includes("..")) {
|
||||||
|
throw new Error(`virtual file path may not contain '..': ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
virtualFiles.set(normalized, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeVirtualFile(path) {
|
export function removeVirtualFile(path) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
unregisterPopupWindow,
|
unregisterPopupWindow,
|
||||||
} from "./electron/remote/window.js";
|
} from "./electron/remote/window.js";
|
||||||
import { showVaultManager } from "./ui-registry.js";
|
import { showVaultManager } from "./ui-registry.js";
|
||||||
|
import { isSameOrigin } from "./util/url.js";
|
||||||
|
import { proxyFetch } from "./util/proxy.js";
|
||||||
|
|
||||||
function installProcess() {
|
function installProcess() {
|
||||||
window.process = processShim;
|
window.process = processShim;
|
||||||
@@ -115,51 +117,6 @@ function installWindowOpen() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function arrayBufferToBase64(buf) {
|
|
||||||
const bytes = new Uint8Array(buf);
|
|
||||||
let binary = "";
|
|
||||||
const chunk = 8192;
|
|
||||||
|
|
||||||
for (let i = 0; i < bytes.length; i += chunk) {
|
|
||||||
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
|
||||||
}
|
|
||||||
|
|
||||||
return btoa(binary);
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64ToArrayBuffer(base64) {
|
|
||||||
const binary = atob(base64);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
|
|
||||||
for (let i = 0; i < binary.length; i++) {
|
|
||||||
bytes[i] = binary.charCodeAt(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
return bytes.buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSameOrigin(url) {
|
|
||||||
if (
|
|
||||||
!url ||
|
|
||||||
url.startsWith("/") ||
|
|
||||||
url.startsWith("./") ||
|
|
||||||
url.startsWith("../")
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.startsWith("data:") || url.startsWith("blob:")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url, window.location.origin);
|
|
||||||
return parsed.origin === window.location.origin;
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function installFetchShim() {
|
function installFetchShim() {
|
||||||
const originalFetch = window.fetch.bind(window);
|
const originalFetch = window.fetch.bind(window);
|
||||||
window.__originalFetch = originalFetch;
|
window.__originalFetch = originalFetch;
|
||||||
@@ -210,17 +167,15 @@ function installFetchShim() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let body = null;
|
let body = null;
|
||||||
let binary = false;
|
|
||||||
|
|
||||||
if (init?.body && method !== "GET" && method !== "HEAD") {
|
if (init?.body && method !== "GET" && method !== "HEAD") {
|
||||||
if (typeof init.body === "string") {
|
if (typeof init.body === "string") {
|
||||||
body = init.body;
|
body = init.body;
|
||||||
} else if (init.body instanceof ArrayBuffer) {
|
} else if (
|
||||||
body = arrayBufferToBase64(init.body);
|
init.body instanceof ArrayBuffer ||
|
||||||
binary = true;
|
init.body instanceof Uint8Array
|
||||||
} else if (init.body instanceof Uint8Array) {
|
) {
|
||||||
body = arrayBufferToBase64(init.body.buffer);
|
body = init.body;
|
||||||
binary = true;
|
|
||||||
} else if (typeof init.body === "object") {
|
} else if (typeof init.body === "object") {
|
||||||
body = JSON.stringify(init.body);
|
body = JSON.stringify(init.body);
|
||||||
} else {
|
} else {
|
||||||
@@ -230,23 +185,15 @@ function installFetchShim() {
|
|||||||
|
|
||||||
console.log("[shim:fetch] Proxying cross-origin:", method, url);
|
console.log("[shim:fetch] Proxying cross-origin:", method, url);
|
||||||
|
|
||||||
const proxyRes = await originalFetch("/api/proxy", {
|
let result;
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ url, method, headers, body, binary }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!proxyRes.ok) {
|
try {
|
||||||
const err = await proxyRes
|
result = await proxyFetch({ url, method, headers, body });
|
||||||
.json()
|
} catch (e) {
|
||||||
.catch(() => ({ error: "Proxy request failed" }));
|
throw new TypeError(e.message || "Failed to fetch");
|
||||||
throw new TypeError(err.error || "Failed to fetch");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await proxyRes.json();
|
return new Response(result.body, {
|
||||||
const respBody = base64ToArrayBuffer(result.body);
|
|
||||||
|
|
||||||
return new Response(respBody, {
|
|
||||||
status: result.status,
|
status: result.status,
|
||||||
headers: result.headers,
|
headers: result.headers,
|
||||||
});
|
});
|
||||||
@@ -281,7 +228,12 @@ function installContextMenuFix() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function installGlobalAlias() {
|
||||||
|
window.global = window;
|
||||||
|
}
|
||||||
|
|
||||||
export function installGlobals() {
|
export function installGlobals() {
|
||||||
|
installGlobalAlias();
|
||||||
installProcess();
|
installProcess();
|
||||||
installBuffer();
|
installBuffer();
|
||||||
installFetchShim();
|
installFetchShim();
|
||||||
|
|||||||
@@ -8,11 +8,26 @@ import {
|
|||||||
initWorkspacePatch,
|
initWorkspacePatch,
|
||||||
} from "./workspace.js";
|
} from "./workspace.js";
|
||||||
import { prefetchVaultContent } from "./fs/indexer-prefetch.js";
|
import { prefetchVaultContent } from "./fs/indexer-prefetch.js";
|
||||||
|
import { setInputCacheLimits } from "./fs/input-cache.js";
|
||||||
import { autoTrustDemoVaults, maybeProvisionDemoVault } from "./demo.js";
|
import { autoTrustDemoVaults, maybeProvisionDemoVault } from "./demo.js";
|
||||||
import { initNativeMenuGuard } from "./native-menu-guard.js";
|
import { initNativeMenuGuard } from "./native-menu-guard.js";
|
||||||
|
|
||||||
let bootstrapVirtualPlugins = [];
|
let bootstrapVirtualPlugins = [];
|
||||||
|
|
||||||
|
// Cache sizes come from the bootstrap response and are applied at page load.
|
||||||
|
// The server owns the rest of the settings and applies them on its side.
|
||||||
|
function applyServerSettings(s) {
|
||||||
|
if (!s) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isFinite(s.contentCacheBytes)) {
|
||||||
|
fsShim._contentCache.setMaxSize(s.contentCacheBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
setInputCacheLimits({ maxSize: s.inputCacheBytes, ttlMs: s.inputCacheTtlMs });
|
||||||
|
}
|
||||||
|
|
||||||
export function getBootstrapVirtualPlugins() {
|
export function getBootstrapVirtualPlugins() {
|
||||||
return bootstrapVirtualPlugins;
|
return bootstrapVirtualPlugins;
|
||||||
}
|
}
|
||||||
@@ -212,6 +227,7 @@ export function initialize() {
|
|||||||
applyTree(bootstrap.tree);
|
applyTree(bootstrap.tree);
|
||||||
applyCoreSyncGuard(bootstrap.plugins);
|
applyCoreSyncGuard(bootstrap.plugins);
|
||||||
bootstrapVirtualPlugins = bootstrap.virtualPlugins || [];
|
bootstrapVirtualPlugins = bootstrap.virtualPlugins || [];
|
||||||
|
applyServerSettings(bootstrap.settings);
|
||||||
|
|
||||||
// Race the indexer: batch-fetch text content into ContentCache so
|
// Race the indexer: batch-fetch text content into ContentCache so
|
||||||
// Obsidian's startup indexing reads hit the cache instead of the network.
|
// Obsidian's startup indexing reads hit the cache instead of the network.
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ if (window.__currentVaultId) {
|
|||||||
|
|
||||||
extractObsidianModule()
|
extractObsidianModule()
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
// Dynamic import so bridge's top-level require("obsidian") fires after installRequire + extractObsidianModule.
|
// Dynamic import so the bridge's top-level obsidian import resolves after installRequire + extractObsidianModule.
|
||||||
const mod = await import("@ignis/bridge");
|
const mod = await import("@ignis/bridge");
|
||||||
const IgnisBridgePlugin = mod.default || mod;
|
const IgnisBridgePlugin = mod.default || mod;
|
||||||
const bridge = new IgnisBridgePlugin(window.app, BRIDGE_MANIFEST);
|
const bridge = new IgnisBridgePlugin(window.app, BRIDGE_MANIFEST);
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ function readTransform(data) {
|
|||||||
try {
|
try {
|
||||||
const obj = JSON.parse(text);
|
const obj = JSON.parse(text);
|
||||||
|
|
||||||
if (obj.nativeMenus) {
|
// force native menus to false since its never appropriate in a browser context.
|
||||||
|
if (obj.nativeMenus !== false) {
|
||||||
obj.nativeMenus = false;
|
obj.nativeMenus = false;
|
||||||
return JSON.stringify(obj);
|
return JSON.stringify(obj);
|
||||||
}
|
}
|
||||||
@@ -100,6 +101,9 @@ function patchSetConfig() {
|
|||||||
};
|
};
|
||||||
vault.__ignisNativeMenuGuarded = true;
|
vault.__ignisNativeMenuGuarded = true;
|
||||||
|
|
||||||
|
// set to false to override any platform default (like macOS).
|
||||||
|
vault.setConfig("nativeMenus", false);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
82
packages/shim/src/node/assert.js
Normal file
82
packages/shim/src/node/assert.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
class AssertionError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message || "Assertion failed");
|
||||||
|
this.name = "AssertionError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assert(value, message) {
|
||||||
|
if (!value) {
|
||||||
|
throw new AssertionError(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.AssertionError = AssertionError;
|
||||||
|
assert.ok = assert;
|
||||||
|
assert.strict = assert;
|
||||||
|
|
||||||
|
assert.fail = function (message) {
|
||||||
|
throw new AssertionError(message || "Failed");
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal = function (actual, expected, message) {
|
||||||
|
if (actual != expected) {
|
||||||
|
throw new AssertionError(message || `${actual} == ${expected}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.notEqual = function (actual, expected, message) {
|
||||||
|
if (actual == expected) {
|
||||||
|
throw new AssertionError(message || `${actual} != ${expected}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.strictEqual = function (actual, expected, message) {
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new AssertionError(message || `${actual} === ${expected}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.notStrictEqual = function (actual, expected, message) {
|
||||||
|
if (actual === expected) {
|
||||||
|
throw new AssertionError(message || `${actual} !== ${expected}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual = function (actual, expected, message) {
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new AssertionError(message || "deepEqual");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepStrictEqual = assert.deepEqual;
|
||||||
|
|
||||||
|
assert.throws = function (fn, message) {
|
||||||
|
let threw = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} catch {
|
||||||
|
threw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!threw) {
|
||||||
|
throw new AssertionError(message || "Missing expected exception");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.doesNotThrow = function (fn, message) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} catch (e) {
|
||||||
|
throw new AssertionError(message || `Got unwanted exception: ${e.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.ifError = function (value) {
|
||||||
|
if (value) {
|
||||||
|
throw new AssertionError(`ifError got unwanted exception: ${value}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assertShim = assert;
|
||||||
30
packages/shim/src/node/assert.test.js
Normal file
30
packages/shim/src/node/assert.test.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { assertShim as assert } from "./assert.js";
|
||||||
|
|
||||||
|
describe("assert shim", () => {
|
||||||
|
it("is callable and throws on a falsy value", () => {
|
||||||
|
expect(() => assert(false)).toThrow();
|
||||||
|
expect(() => assert(true)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("equal throws on mismatch and passes on loose match", () => {
|
||||||
|
expect(() => assert.equal(1, 2)).toThrow();
|
||||||
|
expect(() => assert.equal(1, 1)).not.toThrow();
|
||||||
|
expect(() => assert.equal(1, "1")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strictEqual distinguishes type", () => {
|
||||||
|
expect(() => assert.strictEqual(1, "1")).toThrow();
|
||||||
|
expect(() => assert.strictEqual(1, 1)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws() verifies that a function threw", () => {
|
||||||
|
expect(() =>
|
||||||
|
assert.throws(() => {
|
||||||
|
throw new Error("x");
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
|
||||||
|
expect(() => assert.throws(() => {})).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
50
packages/shim/src/node/constants.js
Normal file
50
packages/shim/src/node/constants.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// Linux constant values, to match the platform the process shim reports.
|
||||||
|
// O_SYMLINK and other macOS/BSD flags are omitted so feature checks treat platform as linux
|
||||||
|
|
||||||
|
export const constantsShim = {
|
||||||
|
// File access checks (fs.access mode).
|
||||||
|
F_OK: 0,
|
||||||
|
X_OK: 1,
|
||||||
|
W_OK: 2,
|
||||||
|
R_OK: 4,
|
||||||
|
|
||||||
|
// open() flags.
|
||||||
|
O_RDONLY: 0,
|
||||||
|
O_WRONLY: 1,
|
||||||
|
O_RDWR: 2,
|
||||||
|
O_CREAT: 64,
|
||||||
|
O_EXCL: 128,
|
||||||
|
O_NOCTTY: 256,
|
||||||
|
O_TRUNC: 512,
|
||||||
|
O_APPEND: 1024,
|
||||||
|
O_DIRECTORY: 65536,
|
||||||
|
O_NOATIME: 262144,
|
||||||
|
O_NOFOLLOW: 131072,
|
||||||
|
O_SYNC: 1052672,
|
||||||
|
O_DSYNC: 4096,
|
||||||
|
O_NONBLOCK: 2048,
|
||||||
|
|
||||||
|
// File type bits (st_mode & S_IFMT).
|
||||||
|
S_IFMT: 61440,
|
||||||
|
S_IFREG: 32768,
|
||||||
|
S_IFDIR: 16384,
|
||||||
|
S_IFCHR: 8192,
|
||||||
|
S_IFBLK: 24576,
|
||||||
|
S_IFIFO: 4096,
|
||||||
|
S_IFLNK: 40960,
|
||||||
|
S_IFSOCK: 49152,
|
||||||
|
|
||||||
|
// Permission bits.
|
||||||
|
S_IRWXU: 448,
|
||||||
|
S_IRUSR: 256,
|
||||||
|
S_IWUSR: 128,
|
||||||
|
S_IXUSR: 64,
|
||||||
|
S_IRWXG: 56,
|
||||||
|
S_IRGRP: 32,
|
||||||
|
S_IWGRP: 16,
|
||||||
|
S_IXGRP: 8,
|
||||||
|
S_IRWXO: 7,
|
||||||
|
S_IROTH: 4,
|
||||||
|
S_IWOTH: 2,
|
||||||
|
S_IXOTH: 1,
|
||||||
|
};
|
||||||
85
packages/shim/src/node/stream.js
Normal file
85
packages/shim/src/node/stream.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { EventEmitter } from "./events.js";
|
||||||
|
|
||||||
|
let warned = false;
|
||||||
|
|
||||||
|
function warnNoDataFlow(method) {
|
||||||
|
if (warned) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
warned = true;
|
||||||
|
console.warn(
|
||||||
|
`[shim:stream] ${method}() called, but stream data flow is not implemented. ` +
|
||||||
|
"This plugin needs the full stream shim.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Stream extends EventEmitter {
|
||||||
|
pipe(destination) {
|
||||||
|
warnNoDataFlow("pipe");
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Readable extends Stream {
|
||||||
|
constructor(options) {
|
||||||
|
super();
|
||||||
|
this.readable = true;
|
||||||
|
this._readableState = { options: options || {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
read() {
|
||||||
|
warnNoDataFlow("read");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
push() {
|
||||||
|
warnNoDataFlow("push");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_read() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Writable extends Stream {
|
||||||
|
constructor(options) {
|
||||||
|
super();
|
||||||
|
this.writable = true;
|
||||||
|
this._writableState = { options: options || {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
write() {
|
||||||
|
warnNoDataFlow("write");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
end() {
|
||||||
|
warnNoDataFlow("end");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
_write() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Duplex extends Readable {
|
||||||
|
constructor(options) {
|
||||||
|
super(options);
|
||||||
|
this.writable = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
write() {
|
||||||
|
warnNoDataFlow("write");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
end() {
|
||||||
|
warnNoDataFlow("end");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Transform extends Duplex {
|
||||||
|
_transform() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PassThrough extends Transform {}
|
||||||
16
packages/shim/src/node/stream.test.js
Normal file
16
packages/shim/src/node/stream.test.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { Readable, Writable } from "./stream.js";
|
||||||
|
|
||||||
|
describe("stream shim", () => {
|
||||||
|
it("warns once when data-flow methods are used", () => {
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
new Readable().read();
|
||||||
|
new Writable().write("x");
|
||||||
|
|
||||||
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(warn.mock.calls[0][0]).toContain("[shim:stream]");
|
||||||
|
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export const processShim = {
|
export const processShim = {
|
||||||
platform: "linux",
|
platform: "linux",
|
||||||
|
version: "v18.18.0",
|
||||||
versions: {
|
versions: {
|
||||||
electron: "28.2.3",
|
electron: "28.2.3",
|
||||||
node: "18.18.0",
|
node: "18.18.0",
|
||||||
|
|||||||
@@ -1,40 +1,16 @@
|
|||||||
// Override window.requestUrl to proxy external requests through our server, bypassing CORS.
|
// Override window.requestUrl to proxy external requests through our server, bypassing CORS.
|
||||||
// Obsidian sets window.requestUrl in app.js, so we override it after app.js loads.
|
// Obsidian sets window.requestUrl in app.js, so we override it after app.js loads.
|
||||||
|
|
||||||
function base64ToArrayBuffer(base64) {
|
import { isSameOrigin } from "./util/url.js";
|
||||||
const binary = atob(base64);
|
import { proxyFetch } from "./util/proxy.js";
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
|
|
||||||
for (let i = 0; i < binary.length; i++) {
|
|
||||||
bytes[i] = binary.charCodeAt(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
return bytes.buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
function arrayBufferToBase64(buf) {
|
|
||||||
const bytes = new Uint8Array(buf);
|
|
||||||
let binary = "";
|
|
||||||
const chunk = 8192;
|
|
||||||
|
|
||||||
for (let i = 0; i < bytes.length; i += chunk) {
|
|
||||||
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
|
||||||
}
|
|
||||||
|
|
||||||
return btoa(binary);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function proxyRequestUrl(request) {
|
async function proxyRequestUrl(request) {
|
||||||
if (typeof request === "string") {
|
if (typeof request === "string") {
|
||||||
request = { url: request };
|
request = { url: request };
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSameOrigin =
|
// Same-origin requests don't need the proxy.
|
||||||
request.url.startsWith(window.location.origin) ||
|
if (isSameOrigin(request.url)) {
|
||||||
request.url.startsWith("/");
|
|
||||||
|
|
||||||
// Same-origin requests don't need the proxy
|
|
||||||
if (isSameOrigin) {
|
|
||||||
const res = await fetch(request.url, {
|
const res = await fetch(request.url, {
|
||||||
method: request.method || "GET",
|
method: request.method || "GET",
|
||||||
headers: request.headers || {},
|
headers: request.headers || {},
|
||||||
@@ -52,42 +28,14 @@ async function proxyRequestUrl(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cross-origin: route through server proxy
|
// Cross-origin: route through server proxy
|
||||||
let body = request.body;
|
const result = await proxyFetch({
|
||||||
let binary = false;
|
url: request.url,
|
||||||
|
method: request.method,
|
||||||
if (body instanceof ArrayBuffer) {
|
headers: request.headers,
|
||||||
body = arrayBufferToBase64(body);
|
body: request.body,
|
||||||
binary = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch("/api/proxy", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
url: request.url,
|
|
||||||
method: request.method || "GET",
|
|
||||||
headers: request.headers || {},
|
|
||||||
body,
|
|
||||||
binary,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
return makeResponse(request, result.status, result.headers, result.body);
|
||||||
const err = await res
|
|
||||||
.json()
|
|
||||||
.catch(() => ({ error: "Proxy request failed" }));
|
|
||||||
throw new Error(err.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const proxyResult = await res.json();
|
|
||||||
const arrayBuf = base64ToArrayBuffer(proxyResult.body);
|
|
||||||
|
|
||||||
return makeResponse(
|
|
||||||
request,
|
|
||||||
proxyResult.status,
|
|
||||||
proxyResult.headers,
|
|
||||||
arrayBuf,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeResponse(request, status, headers, arrayBuf) {
|
function makeResponse(request, status, headers, arrayBuf) {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import * as netShim from "./node/net.js";
|
|||||||
import * as httpShim from "./node/http.js";
|
import * as httpShim from "./node/http.js";
|
||||||
import * as zlibShim from "./node/zlib.js";
|
import * as zlibShim from "./node/zlib.js";
|
||||||
import * as utilShim from "./node/util.js";
|
import * as utilShim from "./node/util.js";
|
||||||
|
import { constantsShim } from "./node/constants.js";
|
||||||
|
import { assertShim } from "./node/assert.js";
|
||||||
|
import * as streamShim from "./node/stream.js";
|
||||||
import { wrapWithProxy, installDebugHelpers } from "./debug.js";
|
import { wrapWithProxy, installDebugHelpers } from "./debug.js";
|
||||||
|
|
||||||
const rawRegistry = {
|
const rawRegistry = {
|
||||||
@@ -29,6 +32,9 @@ const rawRegistry = {
|
|||||||
https: httpShim,
|
https: httpShim,
|
||||||
zlib: zlibShim,
|
zlib: zlibShim,
|
||||||
util: utilShim,
|
util: utilShim,
|
||||||
|
constants: constantsShim,
|
||||||
|
assert: assertShim,
|
||||||
|
stream: streamShim,
|
||||||
};
|
};
|
||||||
|
|
||||||
const shimRegistry = {};
|
const shimRegistry = {};
|
||||||
|
|||||||
26
packages/shim/src/util/base64.js
Normal file
26
packages/shim/src/util/base64.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Base64 codec for the binary bodies exchanged with the server proxy.
|
||||||
|
|
||||||
|
function arrayBufferToBase64(buf) {
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
let binary = "";
|
||||||
|
const chunk = 8192;
|
||||||
|
|
||||||
|
for (let i = 0; i < bytes.length; i += chunk) {
|
||||||
|
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
||||||
|
}
|
||||||
|
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToArrayBuffer(base64) {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { arrayBufferToBase64, base64ToArrayBuffer };
|
||||||
7
packages/shim/src/util/path.js
Normal file
7
packages/shim/src/util/path.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Canonical key form for fs paths: backslashes to forward slashes, no leading or trailing slash.
|
||||||
|
// Used by caches and registries that key on path.
|
||||||
|
function normalize(p) {
|
||||||
|
return (p || "").replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export { normalize };
|
||||||
54
packages/shim/src/util/proxy.js
Normal file
54
packages/shim/src/util/proxy.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Single round-trip through the server's /api/proxy endpoint for cross-origin requests.
|
||||||
|
// Encodes a binary request body to base64, returns the upstream response with its body as an ArrayBuffer.
|
||||||
|
// Throws an Error carrying the server's message on failure.
|
||||||
|
|
||||||
|
import { arrayBufferToBase64, base64ToArrayBuffer } from "./base64.js";
|
||||||
|
|
||||||
|
export async function proxyFetch({ url, method, headers, body, contentType }) {
|
||||||
|
let encodedBody = null;
|
||||||
|
let binary = false;
|
||||||
|
|
||||||
|
if (body instanceof ArrayBuffer) {
|
||||||
|
encodedBody = arrayBufferToBase64(body);
|
||||||
|
binary = true;
|
||||||
|
} else if (body instanceof Uint8Array) {
|
||||||
|
encodedBody = arrayBufferToBase64(body.buffer);
|
||||||
|
binary = true;
|
||||||
|
} else if (body != null) {
|
||||||
|
encodedBody = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
url,
|
||||||
|
method: method || "GET",
|
||||||
|
headers: headers || {},
|
||||||
|
body: encodedBody,
|
||||||
|
binary,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (contentType !== undefined) {
|
||||||
|
payload.contentType = contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use native fetch to avoid an unnecessary call through the shim. proxy is already same origin.
|
||||||
|
const nativeFetch = window.__originalFetch || fetch;
|
||||||
|
|
||||||
|
const res = await nativeFetch("/api/proxy", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || "Proxy request failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: result.status,
|
||||||
|
headers: result.headers,
|
||||||
|
body: base64ToArrayBuffer(result.body),
|
||||||
|
};
|
||||||
|
}
|
||||||
24
packages/shim/src/util/url.js
Normal file
24
packages/shim/src/util/url.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// True when a request URL targets the page's own origin (so it can skip the cross-origin proxy).
|
||||||
|
function isSameOrigin(url) {
|
||||||
|
if (
|
||||||
|
!url ||
|
||||||
|
(url.startsWith("/") && !url.startsWith("//")) ||
|
||||||
|
url.startsWith("./") ||
|
||||||
|
url.startsWith("../")
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith("data:") || url.startsWith("blob:")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url, window.location.origin);
|
||||||
|
return parsed.origin === window.location.origin;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isSameOrigin };
|
||||||
25
packages/shim/src/util/url.test.js
Normal file
25
packages/shim/src/util/url.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -104,6 +104,12 @@ export async function extractObsidianModule() {
|
|||||||
return captured;
|
return captured;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertSameOrigin(url) {
|
||||||
|
if (new URL(url, location.origin).origin !== location.origin) {
|
||||||
|
throw new Error(`refusing cross-origin plugin URL: ${url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Serialize per-id load/unload so rapid toggles can't race.
|
// Serialize per-id load/unload so rapid toggles can't race.
|
||||||
const inFlight = new Map();
|
const inFlight = new Map();
|
||||||
|
|
||||||
@@ -128,7 +134,11 @@ export function loadVirtualPlugin(entry) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertSameOrigin(entry.scriptUrl);
|
||||||
|
|
||||||
if (entry.cssUrl) {
|
if (entry.cssUrl) {
|
||||||
|
assertSameOrigin(entry.cssUrl);
|
||||||
|
|
||||||
const link = document.createElement("link");
|
const link = document.createElement("link");
|
||||||
link.rel = "stylesheet";
|
link.rel = "stylesheet";
|
||||||
link.href = entry.cssUrl;
|
link.href = entry.cssUrl;
|
||||||
|
|||||||
Reference in New Issue
Block a user