mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
Compare commits
68 Commits
v0.8.0+obs
...
v0.8.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aa2b2bd45 | ||
|
|
b6c538fb33 | ||
|
|
201607dbea | ||
|
|
85956dbb3f | ||
|
|
97bcf4fde5 | ||
|
|
9619703a58 | ||
|
|
c60322a287 | ||
|
|
448c6eea2c | ||
|
|
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 | ||
|
|
35118ca190 | ||
|
|
3af8687037 | ||
|
|
5bf120defa | ||
|
|
7d70872f7e | ||
|
|
d5fb9e1e1d | ||
|
|
28effab1ed | ||
|
|
9eeff3c1b3 | ||
|
|
f05ee9e856 | ||
|
|
956a11d0cd | ||
|
|
69f8320d05 | ||
|
|
ec3c38228c | ||
|
|
e6975d631c | ||
|
|
4fff803cbd | ||
|
|
10c6782652 | ||
|
|
840af89feb | ||
|
|
f7fd3d9fba | ||
|
|
85b61a09c4 | ||
|
|
8672fa11a3 | ||
|
|
a6807fe850 | ||
|
|
4a65f142bc | ||
|
|
fe11f30c01 | ||
|
|
a0b44bde58 | ||
|
|
0433f1f8ca | ||
|
|
4da91d017b | ||
|
|
64073968d4 | ||
|
|
23306ff68e | ||
|
|
43778d7bca | ||
|
|
32f21445d4 | ||
|
|
6a719aca7c | ||
|
|
56776e7f13 |
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
.vscode
|
||||
.prettierrc
|
||||
investigation
|
||||
vaults
|
||||
demo-vaults
|
||||
data
|
||||
tmp
|
||||
**/dist
|
||||
apps/ignis-server/server/build-info.json
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,6 +2,7 @@ node_modules/
|
||||
dist/
|
||||
investigation/
|
||||
vaults/
|
||||
plugin/main.js
|
||||
server/plugins/*/plugin/main.js
|
||||
packages/*/dist/
|
||||
apps/ignis-server/server/build-info.json
|
||||
demo-vaults/
|
||||
data/
|
||||
|
||||
84
CHANGELOG.md
84
CHANGELOG.md
@@ -2,6 +2,90 @@
|
||||
|
||||
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)
|
||||
|
||||
### Added
|
||||
|
||||
- `WS_ORIGINS` env var to restrict allowed `Origin` headers on WebSocket connections.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Ignis version is now rendered correctly.
|
||||
- Tables in editing mode now render correctly in Firefox.
|
||||
|
||||
## [0.8.2] - Karm (2026-05-23)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Various app menus crash when "Use native menus" enabled. Solved by forcing the setting off internally; the on-disk value is preserved across toggles.
|
||||
- `/api/fs/rename` and `/api/fs/copyFile` reject missing path fields with 400 instead of silently resolving to the vault root.
|
||||
|
||||
## [0.8.1] - Karm (2026-05-17)
|
||||
|
||||
### Added
|
||||
|
||||
- "Available version" indicator in Ignis settings now links to the release page on GitHub.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Update check no longer reports a new version available when only the SemVer build metadata differs.
|
||||
|
||||
## [0.8.0] - Karm (2026-05-16)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -46,18 +46,22 @@ This kind of report makes it straightforward to add the missing shim.
|
||||
If you want to contribute code:
|
||||
|
||||
1. Fork the repo and create a branch for your change
|
||||
2. Run `npm run build` to verify everything builds
|
||||
3. start the server with `npm run dev`.
|
||||
2. Run `npm install` once at the repo root (npm workspaces)
|
||||
3. Run `npm run dev` to build and start the server
|
||||
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
|
||||
|
||||
- `src/shims/` - Browser shims for Node.js and Electron APIs
|
||||
- `src/ui/` - Svelte UI components (vault manager, dialogs)
|
||||
- `plugin/` - The ignis-bridge Obsidian plugin (settings, file actions)
|
||||
- `server/` - Express server (fs routes, WebSocket, plugin system)
|
||||
- `server/plugins/` - Server plugin packages (e.g., headless-sync)
|
||||
- `packages/shim/` - Browser shims for Node.js and Electron APIs
|
||||
- `packages/ui/` - Svelte UI components (vault manager, dialogs)
|
||||
- `packages/bridge/` - The ignis-bridge Obsidian plugin (settings, file actions)
|
||||
- `packages/server-core/` - Shared server helpers (path guards, watcher, WebSocket)
|
||||
- `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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
53
Dockerfile
53
Dockerfile
@@ -1,53 +0,0 @@
|
||||
# Build shim-loader.js
|
||||
FROM node:22-slim AS build
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
COPY build.js ./
|
||||
COPY src/ ./src/
|
||||
COPY plugin/src/ ./plugin/src/
|
||||
COPY server/plugins/ ./server/plugins/
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Production image. No Obsidian code included.
|
||||
# On first run, the entrypoint downloads Obsidian.
|
||||
FROM node:22-slim
|
||||
|
||||
LABEL com.thiefling.ignis.obsidian-version="1.12.7"
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev --ignore-scripts
|
||||
|
||||
COPY server/ ./server/
|
||||
COPY scripts/ ./scripts/
|
||||
COPY images/ ./images/
|
||||
COPY plugin/ ./plugin/
|
||||
COPY --from=build /build/dist ./dist
|
||||
COPY --from=build /build/plugin/main.js ./plugin/main.js
|
||||
COPY --from=build /build/server/plugins/headless-sync/plugin/main.js ./server/plugins/headless-sync/plugin/main.js
|
||||
|
||||
RUN chmod +x /app/scripts/entrypoint.sh
|
||||
|
||||
ENV PORT=8080
|
||||
ENV VAULT_ROOT=/vaults
|
||||
ENV OBSIDIAN_VERSION=1.12.7
|
||||
ENV OBSIDIAN_ASSETS_PATH=/app/obsidian-app
|
||||
ENV PUID=1000
|
||||
ENV PGID=1000
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
VOLUME /vaults
|
||||
VOLUME /app/obsidian-app
|
||||
|
||||
ENTRYPOINT ["/app/scripts/entrypoint.sh"]
|
||||
105
README.md
105
README.md
@@ -28,6 +28,10 @@ What started as an experiment turned out to be more viable than expected, and th
|
||||
|
||||
Plugin compatibility depends on what APIs a plugin uses; most plugins built on Obsidian's plugin API work, anything requiring Node native modules or `child_process` doesn't. See [What doesn't work](#what-doesnt-work) for the full list of known limitations.
|
||||
|
||||
## Variants
|
||||
|
||||
Ignis ships currently ships as a self-hosted server but I have plans for a desktop plugin. The server variant code and Readme with details and setup instructions lives here: [`apps/ignis-server/`](apps/ignis-server/)
|
||||
|
||||
## What works
|
||||
|
||||
- All core editor features: markdown, canvas, bases, and the command palette.
|
||||
@@ -62,7 +66,7 @@ Compatibility for specific community plugins is tracked in [Issue #9](https://gi
|
||||
**Multi-tab and workspaces.**
|
||||
- Live file sync between browser tabs via WebSocket: open the same vault in two tabs and edits propagate within a second.
|
||||
- Saved workspaces can be opened in separate browser tabs via a `?workspace=` URL parameter, so each tab can hold a different layout of the same vault.
|
||||
- The bridge plugin adds an "Open workspace in tab" command to the command palette.
|
||||
- Ignis adds an "Open workspace in tab" command to the command palette.
|
||||
|
||||
**Server-side sync.**
|
||||
- Obsidian Headless is implemented as a server-side plugin that performs continuous sync without needing an active browser tab. Only one of Obsidian Sync or Obsidian Headless can run per vault.
|
||||
@@ -70,8 +74,19 @@ Compatibility for specific community plugins is tracked in [Issue #9](https://gi
|
||||
**Server-side integration.**
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Roadmap
|
||||
|
||||
**Planned:**
|
||||
- Continued shim work to support more community plugins.
|
||||
- Server-side plugin system improvements.
|
||||
|
||||
**Eventually:**
|
||||
- Multi-user support with OIDC for self-hosted shared deployments.
|
||||
- Built-in auth, so a reverse proxy isn't required for basic protected use.
|
||||
|
||||
## Performance
|
||||
|
||||
A few design decisions worth knowing about for someone evaluating Ignis against large vaults or slow storage:
|
||||
@@ -79,96 +94,12 @@ 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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
Tested in Chrome, Brave, and Firefox, with limited testing in Safari.
|
||||
|
||||
## Authentication
|
||||
|
||||
Ignis has **no built-in authentication** and serves plain HTTP by default. Both authentication and TLS termination are expected to be handled by whatever you put in front of it.
|
||||
|
||||
If you are exposing Ignis to the internet, **you should really** put an authentication layer in front of it. Options include:
|
||||
|
||||
- A reverse proxy with Basic Auth (nginx, Caddy, Traefik)
|
||||
- An SSO proxy like Authelia, Authentik, or OAuth2 Proxy
|
||||
- A VPN (Tailscale, WireGuard)
|
||||
- Cloudflare Application Tunnel
|
||||
|
||||
Example for Basic Auth, and Authelia can be found [here](examples).
|
||||
|
||||
> [!CAUTION]
|
||||
> Do not run Ignis on a public network without auth. Anyone with the url can read and write your vault files.
|
||||
|
||||
|
||||
|
||||
## Setup with Docker Compose
|
||||
|
||||
Example `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ignis:
|
||||
image: nobbe/ignis:latest
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- OBSIDIAN_VERSION=1.12.7
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
volumes:
|
||||
- ./vaults:/vaults
|
||||
- ./data:/app/data
|
||||
- obsidian-app:/app/obsidian-app
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
obsidian-app:
|
||||
```
|
||||
|
||||
Then `docker compose up -d`. On first start the container downloads Obsidian from the official source and installs Obsidian Headless CLI. This takes a minute or two.
|
||||
|
||||
To build from source instead of pulling the image, clone the repo and replace `image: nobbe/ignis:latest` with `build: .`.
|
||||
|
||||
### Volumes
|
||||
|
||||
| Mount | Description |
|
||||
| ----- | ----------- |
|
||||
| `/vaults` | Vault storage. Each subdirectory is a vault. |
|
||||
| `/data` | state persistence for various ignis specific functionality, plugin management, headless sync config, etc |
|
||||
| `/app/obsidian-app` | Cached Obsidian assets. Persisting this avoids re-downloading on container recreate. |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| -------- | ----------- | ------- |
|
||||
| `PORT` | Server listen port | `8080` |
|
||||
| `VAULT_ROOT` | Path to vault storage inside the container | `/vaults` |
|
||||
| `DATA_ROOT` | Path to persistent data (plugin config, sync state, auth tokens) | `/app/data` |
|
||||
| `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` |
|
||||
| `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` |
|
||||
| `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` |
|
||||
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
### Upgrading Obsidian
|
||||
|
||||
Obsidian releases can include changes that break the compatibility shim. Each Ignis release pins a known-working Obsidian version through the `OBSIDIAN_VERSION` env var, so the recommended path is to wait for an Ignis release that bumps the version, pull the new image, and restart.
|
||||
|
||||
If you want to try a newer Obsidian version before Ignis updates, set `OBSIDIAN_VERSION` in your compose file. The entrypoint will download that version on next start, but there's no guarantee it'll work cleanly with the current shim.
|
||||
|
||||
### Backups
|
||||
|
||||
Vault data lives as ordinary files in `/vaults`. Back it up however you back up other server-side data; Ignis doesn't provide a built in backup mechanism.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, especially on how to report plugin compatibility issues. Check the [open issues](https://github.com/Nystik-gh/ignis/issues) for things to work on.
|
||||
@@ -187,4 +118,4 @@ Ignis is not affiliated with, endorsed by, or associated with Dynalist Inc. or O
|
||||
|
||||
This work falls under the interoperability provisions of [Directive 2009/24/EC](https://eur-lex.europa.eu/eli/dir/2009/24/oj/eng) (the EU Software Directive), Article 6. See [LEGAL.md](LEGAL.md) for the full rationale.
|
||||
|
||||
This project exists because its author uses Obsidian daily and wants to access it from a browser. There is no intent to harm Obsidian, Dynalist Inc., or their business. If you are a representative of Dynalist Inc. and wish to discuss this project, please reach out: ignis@thiefling.com
|
||||
This project exists because its author uses Obsidian daily and wants to access it from a browser. There is no intent to harm Obsidian, Dynalist Inc., or their business. If you are a representative of Dynalist Inc. and wish to discuss this project, please reach out: ignis@thiefling.com
|
||||
|
||||
73
apps/ignis-server/Dockerfile
Normal file
73
apps/ignis-server/Dockerfile
Normal file
@@ -0,0 +1,73 @@
|
||||
# Build stage.
|
||||
# Runs from the repo root as build context so workspace symlinks resolve.
|
||||
# Copies workspace package.jsons first for cache-friendly npm ci.
|
||||
FROM node:22-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/services/package.json ./packages/services/
|
||||
COPY packages/shim/package.json ./packages/shim/
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
COPY packages/bridge/package.json ./packages/bridge/
|
||||
COPY packages/server-core/package.json ./packages/server-core/
|
||||
COPY apps/ignis-server/package.json ./apps/ignis-server/
|
||||
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
COPY build.js ./
|
||||
COPY packages/ ./packages/
|
||||
COPY apps/ignis-server/ ./apps/ignis-server/
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Production image. No Obsidian code included.
|
||||
# On first run, the entrypoint downloads Obsidian.
|
||||
FROM node:22-slim
|
||||
|
||||
LABEL com.thiefling.ignis.obsidian-version="1.12.7"
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Workspace package.jsons + lockfile so npm ci sets up the @ignis/* symlinks.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/services/package.json ./packages/services/
|
||||
COPY packages/shim/package.json ./packages/shim/
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
COPY packages/bridge/package.json ./packages/bridge/
|
||||
COPY packages/server-core/package.json ./packages/server-core/
|
||||
COPY apps/ignis-server/package.json ./apps/ignis-server/
|
||||
|
||||
RUN npm ci --omit=dev --ignore-scripts
|
||||
|
||||
# Runtime sources: the server, entrypoint script, repo-rooted images, and the server-core JS that gets required from the server at runtime.
|
||||
COPY apps/ignis-server/server/ ./apps/ignis-server/server/
|
||||
COPY apps/ignis-server/scripts/ ./apps/ignis-server/scripts/
|
||||
COPY images/ ./images/
|
||||
COPY packages/server-core/src/ ./packages/server-core/src/
|
||||
|
||||
# Built artifacts from the build stage.
|
||||
COPY --from=build /app/packages/shim/dist/shim-loader.js ./packages/shim/dist/shim-loader.js
|
||||
COPY --from=build /app/packages/ui/dist/ignis-ui.js ./packages/ui/dist/ignis-ui.js
|
||||
COPY --from=build /app/apps/ignis-server/server/build-info.json ./apps/ignis-server/server/build-info.json
|
||||
COPY --from=build /app/apps/ignis-server/server/plugins/headless-sync/obsidian/dist/ ./apps/ignis-server/server/plugins/headless-sync/obsidian/dist/
|
||||
|
||||
RUN chmod +x /app/apps/ignis-server/scripts/entrypoint.sh
|
||||
|
||||
ENV PORT=8080
|
||||
ENV VAULT_ROOT=/vaults
|
||||
ENV OBSIDIAN_VERSION=1.12.7
|
||||
ENV OBSIDIAN_ASSETS_PATH=/app/obsidian-app
|
||||
ENV PUID=1000
|
||||
ENV PGID=1000
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
VOLUME /vaults
|
||||
VOLUME /app/obsidian-app
|
||||
|
||||
ENTRYPOINT ["/app/apps/ignis-server/scripts/entrypoint.sh"]
|
||||
113
apps/ignis-server/README.md
Normal file
113
apps/ignis-server/README.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Ignis Server
|
||||
|
||||
The self-hosted Docker variant of Ignis. For the project overview, feature list, and what works / what doesn't, see the [root README](../../README.md).
|
||||
|
||||
## Contents
|
||||
|
||||
- [Authentication](#authentication)
|
||||
- [Setup with Docker Compose](#setup-with-docker-compose)
|
||||
- [Volumes](#volumes)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Migrating an existing vault](#migrating-an-existing-vault)
|
||||
- [Upgrading Obsidian](#upgrading-obsidian)
|
||||
- [Backups](#backups)
|
||||
|
||||
## Authentication
|
||||
|
||||
Ignis has **no built-in authentication** and serves plain HTTP by default. Both authentication and TLS termination are expected to be handled by whatever you put in front of it.
|
||||
|
||||
If you are exposing Ignis to the internet, **you should really** put an authentication layer in front of it. Options include:
|
||||
|
||||
- A reverse proxy with Basic Auth (nginx, Caddy, Traefik)
|
||||
- An SSO proxy like Authelia, Authentik, or OAuth2 Proxy
|
||||
- A VPN (Tailscale, WireGuard)
|
||||
- Cloudflare Application Tunnel
|
||||
|
||||
Example configurations for Basic Auth and Authelia are in [`examples/`](examples).
|
||||
|
||||
> [!CAUTION]
|
||||
> 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
|
||||
|
||||
Example `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ignis:
|
||||
image: nobbe/ignis:latest
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- OBSIDIAN_VERSION=1.12.7
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
volumes:
|
||||
- ./vaults:/vaults
|
||||
- ./data:/app/data
|
||||
- obsidian-app:/app/obsidian-app
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
obsidian-app:
|
||||
```
|
||||
|
||||
Then `docker compose up -d`. On first start the container downloads Obsidian from the official source and installs the Obsidian Headless CLI. This takes a minute or two.
|
||||
|
||||
To build from source instead of pulling the image, clone the repo and run `docker compose up` against the [`docker-compose.yml`](docker-compose.yml) in this directory.
|
||||
|
||||
## Volumes
|
||||
|
||||
| Mount | Description |
|
||||
| ----- | ----------- |
|
||||
| `/vaults` | Vault storage. Each subdirectory is a vault. |
|
||||
| `/app/data` | State persistence for various Ignis-specific functionality: plugin management, headless sync config, etc. |
|
||||
| `/app/obsidian-app` | Cached Obsidian assets. Persisting this avoids re-downloading on container recreate. |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| -------- | ----------- | ------- |
|
||||
| `PORT` | Server listen port | `8080` |
|
||||
| `VAULT_ROOT` | Path to vault storage inside the container | `/vaults` |
|
||||
| `DATA_ROOT` | Path to persistent data (plugin config, sync state, auth tokens) | `/app/data` |
|
||||
| `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_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` |
|
||||
| `PUID` | User ID for file ownership | `1000` |
|
||||
| `PGID` | Group ID for file ownership | `1000` |
|
||||
| `WRITE_COALESCE_MS` | Debounce window (ms) for rapid writes. On slow filesystems (rclone, NFS, SMB), set an appropriate duration. | `0` |
|
||||
| `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.
|
||||
|
||||
## 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
|
||||
|
||||
Each subdirectory of `/vaults` is treated as a separate vault, so dropping in an existing Obsidian vault directory will make it available in Ignis.
|
||||
|
||||
## Upgrading Obsidian
|
||||
|
||||
Obsidian releases can include changes that break the compatibility shim. Each Ignis release pins a known-working Obsidian version through the `OBSIDIAN_VERSION` env var, so the recommended path is to wait for an Ignis release that bumps the version, pull the new image, and restart.
|
||||
|
||||
If you want to try a newer Obsidian version before Ignis updates, set `OBSIDIAN_VERSION` in your compose file. The entrypoint will download that version on next start, but there is no guarantee it will work cleanly with the current shim.
|
||||
|
||||
## Backups
|
||||
|
||||
Vault data lives as ordinary files in `/vaults`. Back it up however you back up other server-side data; Ignis does not provide a built-in backup mechanism.
|
||||
@@ -1,6 +1,8 @@
|
||||
services:
|
||||
ignis:
|
||||
build: .
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: apps/ignis-server/Dockerfile
|
||||
ports:
|
||||
- "8082:8080"
|
||||
environment:
|
||||
@@ -1,6 +1,6 @@
|
||||
# Replace with your domain, or use :443 for local access with a self-signed cert.
|
||||
ignis.example.com {
|
||||
basicauth {
|
||||
basic_auth {
|
||||
# Username: admin
|
||||
# Replace the hash below with your own. Generate one with:
|
||||
# docker run --rm caddy:2 caddy hash-password --plaintext YOUR_PASSWORD
|
||||
@@ -9,7 +9,8 @@
|
||||
services:
|
||||
ignis-demo:
|
||||
build:
|
||||
context: ../..
|
||||
context: ../../../..
|
||||
dockerfile: apps/ignis-server/Dockerfile
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
5
apps/ignis-server/package.json
Normal file
5
apps/ignis-server/package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@ignis/app",
|
||||
"version": "0.0.0-internal",
|
||||
"private": true
|
||||
}
|
||||
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);
|
||||
119
apps/ignis-server/scripts/entrypoint.sh
Normal file
119
apps/ignis-server/scripts/entrypoint.sh
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Create user with specified UID/GID
|
||||
PUID=${PUID:-1000}
|
||||
PGID=${PGID:-1000}
|
||||
|
||||
# Create group if GID doesn't exist, otherwise use existing
|
||||
if ! getent group "$PGID" >/dev/null 2>&1; then
|
||||
groupadd -g "$PGID" ignis
|
||||
else
|
||||
EXISTING_GROUP=$(getent group "$PGID" | cut -d: -f1)
|
||||
echo "[ignis] Using existing group $EXISTING_GROUP (GID $PGID)"
|
||||
fi
|
||||
|
||||
# Create user if UID doesn't exist, otherwise use existing
|
||||
if ! id -u "$PUID" >/dev/null 2>&1; then
|
||||
GROUP_NAME=$(getent group "$PGID" | cut -d: -f1)
|
||||
useradd -u "$PUID" -g "$PGID" -m -s /bin/bash ignis 2>/dev/null || useradd -u "$PUID" -g "$GROUP_NAME" -M -N ignis
|
||||
RUN_USER="ignis"
|
||||
else
|
||||
RUN_USER=$(id -un "$PUID")
|
||||
echo "[ignis] Using existing user $RUN_USER (UID $PUID)"
|
||||
fi
|
||||
|
||||
|
||||
mkdir -p /app/data
|
||||
chown -R "$PUID:$PGID" /vaults /app/obsidian-app /app/data
|
||||
|
||||
OBSIDIAN_DIR="/app/obsidian-app"
|
||||
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 [ -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
|
||||
|
||||
echo "[ignis] First run. Unpacking local Obsidian package: $OBSIDIAN_PACKAGE"
|
||||
|
||||
case "$OBSIDIAN_PACKAGE" in
|
||||
*.deb)
|
||||
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}..."
|
||||
|
||||
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] 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
|
||||
echo "[ignis] Obsidian already set up."
|
||||
fi
|
||||
|
||||
|
||||
# Install obsidian-headless (ob CLI) if not already present.
|
||||
# Not included in the image for legal reasons - installed at runtime.
|
||||
if ! command -v ob &>/dev/null; then
|
||||
echo "[ignis] Installing obsidian-headless..."
|
||||
|
||||
if npm install -g --prefix /usr/local obsidian-headless --silent 2>/dev/null; then
|
||||
OB_VERSION=$(ob --version 2>/dev/null)
|
||||
|
||||
if [ -n "$OB_VERSION" ]; then
|
||||
echo "[ignis] obsidian-headless $OB_VERSION installed."
|
||||
else
|
||||
echo "[ignis] WARNING: obsidian-headless installed but 'ob' command not working."
|
||||
fi
|
||||
else
|
||||
echo "[ignis] WARNING: Failed to install obsidian-headless. Headless sync will not be available."
|
||||
fi
|
||||
else
|
||||
echo "[ignis] obsidian-headless $(ob --version 2>/dev/null) available."
|
||||
fi
|
||||
|
||||
# Run as the determined user
|
||||
exec gosu "$RUN_USER" node /app/apps/ignis-server/server/index.js
|
||||
|
Before Width: | Height: | Size: 984 B After Width: | Height: | Size: 984 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -41,8 +41,8 @@
|
||||
<div id="ignis-status-label">Loading Obsidian...</div>
|
||||
</div>
|
||||
<!-- Ignis shims: must run before any Obsidian code. -->
|
||||
<script type="text/javascript" src="__IGNIS_UI_SRC__"></script>
|
||||
<script type="text/javascript" src="__SHIM_LOADER_SRC__"></script>
|
||||
<script type="text/javascript" src="__IGNIS_UI_SRC__"></script>
|
||||
<!-- Obsidian scripts injected dynamically to avoid touching their files. -->
|
||||
<script>
|
||||
(function () {
|
||||
@@ -65,25 +65,67 @@
|
||||
}, 250);
|
||||
}
|
||||
|
||||
update();
|
||||
function appendScripts() {
|
||||
// No Obsidian scripts to load (markup or scrape mismatch); clear the splash instead of pulsing forever.
|
||||
if (scripts.length === 0) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < scripts.length; i++) {
|
||||
var s = document.createElement("script");
|
||||
s.type = "text/javascript";
|
||||
s.src = scripts[i];
|
||||
s.async = false;
|
||||
s.onload = function () {
|
||||
loaded++;
|
||||
update();
|
||||
if (loaded === scripts.length) done();
|
||||
};
|
||||
s.onerror = function () {
|
||||
loaded++;
|
||||
update();
|
||||
if (loaded === scripts.length) done();
|
||||
};
|
||||
document.body.appendChild(s);
|
||||
update();
|
||||
|
||||
for (var i = 0; i < scripts.length; i++) {
|
||||
var s = document.createElement("script");
|
||||
s.type = "text/javascript";
|
||||
s.src = scripts[i];
|
||||
s.async = false;
|
||||
s.onload = function () {
|
||||
loaded++;
|
||||
update();
|
||||
if (loaded === scripts.length) done();
|
||||
};
|
||||
s.onerror = function () {
|
||||
loaded++;
|
||||
update();
|
||||
if (loaded === scripts.length) done();
|
||||
};
|
||||
document.body.appendChild(s);
|
||||
}
|
||||
}
|
||||
|
||||
// Hold Obsidian's scripts until the shim signals the priority cache slice has landed (window.__ignisBootReady), so Obsidian's early config and plugin reads hit the warm cache.
|
||||
// A timeout proceeds anyway, so a missing or never-resolving promise degrades to loading immediately instead of blocking boot.
|
||||
var ready = window.__ignisBootReady;
|
||||
if (!ready || typeof ready.then !== "function") {
|
||||
appendScripts();
|
||||
return;
|
||||
}
|
||||
|
||||
var started = false;
|
||||
|
||||
function start() {
|
||||
if (started) {
|
||||
return;
|
||||
}
|
||||
|
||||
started = true;
|
||||
// Tell the shim's progress writer to stop touching the splash label now that we own it.
|
||||
window.__ignisBootStarted = true;
|
||||
appendScripts();
|
||||
}
|
||||
|
||||
var timer = setTimeout(start, 3000);
|
||||
|
||||
ready.then(
|
||||
function () {
|
||||
clearTimeout(timer);
|
||||
start();
|
||||
},
|
||||
function () {
|
||||
clearTimeout(timer);
|
||||
start();
|
||||
},
|
||||
);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
@@ -8,3 +8,9 @@
|
||||
.is-hidden-frameless:not(.is-fullscreen):not(.mod-macos) .workspace-tabs.mod-top-right-space .workspace-tab-header-container:after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* fix table cell height in firefox in edit mode with live preview */
|
||||
.markdown-source-view.mod-cm6 .cm-table-widget th,
|
||||
.markdown-source-view.mod-cm6 .cm-table-widget td {
|
||||
height: auto !important;
|
||||
}
|
||||
62
apps/ignis-server/server/bridge-plugin.js
Normal file
62
apps/ignis-server/server/bridge-plugin.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const BRIDGE_PLUGIN_ID = "ignis-bridge";
|
||||
|
||||
// Old vaults still have bridge in .obsidian/plugins from before it became virtual.
|
||||
async function migratePluginFromVault(vaultPath, vaultName, pluginId) {
|
||||
let didWork = false;
|
||||
|
||||
const pluginDir = path.join(vaultPath, ".obsidian", "plugins", pluginId);
|
||||
|
||||
if (await fs.promises.stat(pluginDir).catch(() => null)) {
|
||||
await fs.promises.rm(pluginDir, { recursive: true, force: true });
|
||||
didWork = true;
|
||||
}
|
||||
|
||||
const cpFile = path.join(vaultPath, ".obsidian", "community-plugins.json");
|
||||
|
||||
try {
|
||||
const list = JSON.parse(await fs.promises.readFile(cpFile, "utf-8"));
|
||||
|
||||
if (Array.isArray(list)) {
|
||||
const filtered = list.filter((id) => id !== pluginId);
|
||||
|
||||
if (filtered.length !== list.length) {
|
||||
await fs.promises.writeFile(cpFile, JSON.stringify(filtered));
|
||||
didWork = true;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (didWork) {
|
||||
console.log(`[ignis] Migrated ${pluginId} out of vault: ${vaultName}`);
|
||||
}
|
||||
|
||||
return didWork;
|
||||
}
|
||||
|
||||
async function migratePluginsFromAllVaults(vaultRoot, pluginIds) {
|
||||
if (!(await fs.promises.stat(vaultRoot).catch(() => null))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = await fs.promises.readdir(vaultRoot, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const vaultPath = path.join(vaultRoot, entry.name);
|
||||
|
||||
for (const pluginId of pluginIds) {
|
||||
await migratePluginFromVault(vaultPath, entry.name, pluginId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BRIDGE_PLUGIN_ID,
|
||||
migratePluginsFromAllVaults,
|
||||
};
|
||||
@@ -1,12 +1,13 @@
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..", "..");
|
||||
|
||||
// VAULT_ROOT: a directory that contains vault folders.
|
||||
// Each subdirectory is a vault. New vaults are created as new subdirs.
|
||||
const vaultRoot =
|
||||
process.env.VAULT_ROOT || path.join(__dirname, "..", "vaults");
|
||||
const vaultRoot = process.env.VAULT_ROOT || path.join(REPO_ROOT, "vaults");
|
||||
|
||||
const dataRoot = process.env.DATA_ROOT || path.join(__dirname, "..", "data");
|
||||
const dataRoot = process.env.DATA_ROOT || path.join(REPO_ROOT, "data");
|
||||
|
||||
// Ensure required directories exist
|
||||
try {
|
||||
@@ -74,10 +75,6 @@ module.exports = {
|
||||
vaults = discoverVaults();
|
||||
return vaults;
|
||||
},
|
||||
writeCoalesceMs:
|
||||
process.env.WRITE_COALESCE_MS !== undefined
|
||||
? parseInt(process.env.WRITE_COALESCE_MS)
|
||||
: 5000,
|
||||
|
||||
demoMode: process.env.DEMO_MODE === "true",
|
||||
demoMaxSessions: parseInt(process.env.DEMO_MAX_SESSIONS) || 20,
|
||||
@@ -86,12 +83,11 @@ module.exports = {
|
||||
parseInt(process.env.DEMO_SESSION_QUOTA_BYTES) || 700 * 1024,
|
||||
demoTimeoutMs: parseInt(process.env.DEMO_TIMEOUT_MS) || 30 * 60 * 1000,
|
||||
demoTemplateDir:
|
||||
process.env.DEMO_TEMPLATE_DIR ||
|
||||
path.join(__dirname, "demo-template"),
|
||||
process.env.DEMO_TEMPLATE_DIR || path.join(__dirname, "demo-template"),
|
||||
|
||||
obsidianAssetsPath:
|
||||
process.env.OBSIDIAN_ASSETS_PATH ||
|
||||
path.join(__dirname, "..", "investigation", "obsidian_1.12.7_unpacked"),
|
||||
path.join(REPO_ROOT, "investigation", "obsidian_1.12.7_unpacked"),
|
||||
|
||||
get obsidianVersion() {
|
||||
const assetsPath =
|
||||
@@ -5,7 +5,7 @@ const fsp = fs.promises;
|
||||
const path = require("path");
|
||||
|
||||
const config = require("../config");
|
||||
const watcher = require("../watcher");
|
||||
const { watcher } = require("@ignis/server-core");
|
||||
const bootstrapRoutes = require("../routes/bootstrap");
|
||||
|
||||
const {
|
||||
@@ -272,16 +272,36 @@ function trackVaultLifecycle(req, res, next) {
|
||||
|
||||
if (s) {
|
||||
if (req.path === "/create" && body.id) {
|
||||
s.vaults.add(body.id);
|
||||
// body.id is storage-prefixed at this point (outboundTranslator runs after us).
|
||||
// Translate to the user-visible name so it matches what pageLoadHandler queries with.
|
||||
const userName = tryParseUserVaultName(sessionId, body.id);
|
||||
|
||||
if (userName !== null) {
|
||||
s.vaults.add(userName);
|
||||
} else {
|
||||
console.warn(
|
||||
"[demo] trackVaultLifecycle: could not parse user name from create response id:",
|
||||
body.id,
|
||||
);
|
||||
}
|
||||
} else if (req.path === "/rename") {
|
||||
const oldName = req.body && req.body._origVault;
|
||||
const oldName = req._demoOriginalVault;
|
||||
|
||||
if (oldName) {
|
||||
s.vaults.delete(oldName);
|
||||
}
|
||||
|
||||
if (body.id) {
|
||||
s.vaults.add(body.id);
|
||||
const userName = tryParseUserVaultName(sessionId, body.id);
|
||||
|
||||
if (userName !== null) {
|
||||
s.vaults.add(userName);
|
||||
} else {
|
||||
console.warn(
|
||||
"[demo] trackVaultLifecycle: could not parse user name from rename response id:",
|
||||
body.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (req.method === "DELETE" && req.path === "/remove") {
|
||||
const removed = req._demoOriginalVault;
|
||||
@@ -1,6 +1,6 @@
|
||||
// Vault provisioning for demo sessions.
|
||||
//
|
||||
// Copies the template into a session-prefixed dir, installs the bridge plugin, and registers the vault on the session.
|
||||
// Copies the template into a session-prefixed dir and registers the vault on the session.
|
||||
// Re-provisions if disk was wiped under an existing session.
|
||||
|
||||
const fs = require("fs");
|
||||
@@ -8,7 +8,6 @@ const fsp = fs.promises;
|
||||
const path = require("path");
|
||||
|
||||
const config = require("../config");
|
||||
const { installBridgePlugin } = require("../bridge-plugin");
|
||||
const bootstrapRoutes = require("../routes/bootstrap");
|
||||
|
||||
const { sessions, makeStorageName } = require("./demo-sessions");
|
||||
@@ -81,6 +80,14 @@ async function provisionVault(sessionId, userVaultName) {
|
||||
const storageName = makeStorageName(sessionId, userVaultName);
|
||||
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 });
|
||||
|
||||
try {
|
||||
@@ -96,9 +103,6 @@ async function provisionVault(sessionId, userVaultName) {
|
||||
// Copy template (default: Welcome.md, Getting Started.md, .obsidian/*).
|
||||
await fsp.cp(config.demoTemplateDir, vaultPath, { recursive: true });
|
||||
|
||||
// Install bridge plugin
|
||||
await installBridgePlugin(vaultPath);
|
||||
|
||||
config.refreshVaults();
|
||||
bootstrapRoutes.invalidateVault(storageName);
|
||||
|
||||
@@ -16,6 +16,13 @@ function newSessionId() {
|
||||
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) {
|
||||
return "demo-" + sessionId + PREFIX_SEPARATOR;
|
||||
}
|
||||
@@ -61,20 +68,25 @@ function setSessionCookie(res, sessionId) {
|
||||
|
||||
res.setHeader(
|
||||
"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).
|
||||
function getOrCreateSession(req, res, options = {}) {
|
||||
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)) {
|
||||
return 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.
|
||||
sessions.set(existing, {
|
||||
lastActivity: Date.now(),
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
sessions,
|
||||
parseCookies,
|
||||
makeStorageName,
|
||||
tryParseUserVaultName,
|
||||
touchSession,
|
||||
} = require("./demo-sessions");
|
||||
|
||||
@@ -28,6 +29,20 @@ function wireWebSocket(server) {
|
||||
if (userVault && !userVault.startsWith("demo-")) {
|
||||
u.searchParams.set("vault", makeStorageName(sessionId, userVault));
|
||||
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);
|
||||
@@ -70,6 +70,16 @@ function setupDemo(app) {
|
||||
// Hide server-side plugins (headless-sync) from the demo UI
|
||||
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
|
||||
const interval = setInterval(() => {
|
||||
cleanupExpired().catch((e) =>
|
||||
@@ -3,15 +3,29 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
const compression = require("compression");
|
||||
const config = require("./config");
|
||||
const settings = require("./settings");
|
||||
const { getVersion } = require("./version");
|
||||
const { setupWebSocket } = require("./ws");
|
||||
const watcher = require("./watcher");
|
||||
const { updateBridgePluginInAllVaults } = require("./bridge-plugin");
|
||||
const { initPlugins, shutdownPlugins } = require("./plugin-system/manager");
|
||||
const {
|
||||
setupWebSocket,
|
||||
watcher,
|
||||
writeCoalescer,
|
||||
} = require("@ignis/server-core");
|
||||
const {
|
||||
BRIDGE_PLUGIN_ID,
|
||||
migratePluginsFromAllVaults,
|
||||
} = require("./bridge-plugin");
|
||||
const {
|
||||
initPlugins,
|
||||
shutdownPlugins,
|
||||
getBundledPluginDirs,
|
||||
} = require("./plugin-system/manager");
|
||||
const pluginRoutes = require("./routes/plugins");
|
||||
const { flushAll } = require("./write-coalescer");
|
||||
writeCoalescer.configure({ writeCoalesceMs: settings.get("writeCoalesceMs") });
|
||||
const { flushAll } = writeCoalescer;
|
||||
const { setupDemo, wireDemoWebSocket } = require("./demo");
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, "..", "..", "..");
|
||||
|
||||
const ANSI_RED = "\x1b[31m";
|
||||
const ANSI_YELLOW = "\x1b[33m";
|
||||
const ANSI_GREEN = "\x1b[32m";
|
||||
@@ -19,7 +33,18 @@ const ANSI_RESET = "\x1b[0m";
|
||||
|
||||
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());
|
||||
|
||||
// logger middleware
|
||||
@@ -53,6 +78,7 @@ const fsRoutes = require("./routes/fs");
|
||||
const vaultRoutes = require("./routes/vault");
|
||||
const proxyRoutes = require("./routes/proxy");
|
||||
const versionRoutes = require("./routes/version");
|
||||
const settingsRoutes = require("./routes/settings");
|
||||
const bootstrapRoutes = require("./routes/bootstrap");
|
||||
|
||||
app.use("/assets", express.static(path.join(__dirname, "assets")));
|
||||
@@ -65,6 +91,7 @@ app.use("/api/fs", fsRoutes);
|
||||
app.use("/api/vault", vaultRoutes);
|
||||
app.use("/api/proxy", proxyRoutes);
|
||||
app.use("/api/version", versionRoutes);
|
||||
app.use("/api/settings", settingsRoutes);
|
||||
app.use("/api/plugins", pluginRoutes);
|
||||
app.use("/api/bootstrap", bootstrapRoutes);
|
||||
|
||||
@@ -90,9 +117,7 @@ app.use("/vault-files", (req, res, next) => {
|
||||
express.static(vaultPath)(req, res, next);
|
||||
});
|
||||
|
||||
// Serve our own index.html. Obsidian's scripts are discovered at startup
|
||||
// and injected dynamically by the client -- no Obsidian files are read or
|
||||
// transformed in the response.
|
||||
// Serve our own index.html. Obsidian's scripts are discovered at startup and injected dynamically by the client.
|
||||
let cachedHtml = null;
|
||||
|
||||
function buildIndexHtml() {
|
||||
@@ -139,7 +164,7 @@ app.get(["/", "/index.html"], (req, res) => {
|
||||
});
|
||||
|
||||
app.get("/favicon.png", (req, res) => {
|
||||
res.sendFile(path.join(__dirname, "..", "images", "favicon.png"));
|
||||
res.sendFile(path.join(REPO_ROOT, "images", "favicon.png"));
|
||||
});
|
||||
|
||||
// Serve dist files with cache headers based on version param
|
||||
@@ -156,7 +181,8 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(__dirname, "..", "dist")));
|
||||
app.use(express.static(path.join(REPO_ROOT, "packages", "ui", "dist")));
|
||||
app.use(express.static(path.join(REPO_ROOT, "packages", "shim", "dist")));
|
||||
|
||||
app.use(express.static(config.obsidianAssetsPath));
|
||||
|
||||
@@ -165,14 +191,28 @@ const server = app.listen(config.port, async () => {
|
||||
console.log(`[ignis] Vault root: ${config.vaultRoot}`);
|
||||
console.log(`[ignis] Vaults: ${Object.keys(config.vaults).join(", ")}`);
|
||||
|
||||
await updateBridgePluginInAllVaults(config.vaultRoot);
|
||||
await initPlugins({ app, config, wss, watcher });
|
||||
|
||||
const bundledPluginDirs = getBundledPluginDirs();
|
||||
|
||||
for (const { distDir } of bundledPluginDirs) {
|
||||
app.use(express.static(distDir));
|
||||
}
|
||||
|
||||
await migratePluginsFromAllVaults(config.vaultRoot, [
|
||||
BRIDGE_PLUGIN_ID,
|
||||
...bundledPluginDirs.map((d) => d.bundledPluginId),
|
||||
]);
|
||||
|
||||
bootstrapRoutes
|
||||
.warmUp()
|
||||
.catch((e) => console.warn("[bootstrap] warm-up error:", e.message));
|
||||
});
|
||||
|
||||
const wss = setupWebSocket(server);
|
||||
const wss = setupWebSocket(server, {
|
||||
getVaultPath: config.getVaultPath,
|
||||
originAllowlist: settings.get("wsOrigins"),
|
||||
});
|
||||
wireDemoWebSocket(server);
|
||||
|
||||
async function gracefulShutdown(signal) {
|
||||
@@ -40,17 +40,16 @@ function discoverPlugins(pluginsDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let bundledPluginId = null;
|
||||
let bundledManifest = null;
|
||||
|
||||
if (plugin.obsidianPlugin) {
|
||||
try {
|
||||
const manifest = JSON.parse(
|
||||
bundledManifest = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(plugin.obsidianPlugin, "manifest.json"),
|
||||
"utf-8",
|
||||
),
|
||||
);
|
||||
bundledPluginId = manifest.id;
|
||||
} catch {
|
||||
// No valid bundled plugin manifest
|
||||
}
|
||||
@@ -61,7 +60,8 @@ function discoverPlugins(pluginsDir) {
|
||||
name: plugin.name,
|
||||
description: plugin.description || "",
|
||||
obsidianPlugin: plugin.obsidianPlugin || null,
|
||||
bundledPluginId,
|
||||
bundledPluginId: bundledManifest ? bundledManifest.id : null,
|
||||
bundledManifest,
|
||||
module: plugin,
|
||||
});
|
||||
|
||||
@@ -3,10 +3,7 @@ const path = require("path");
|
||||
const express = require("express");
|
||||
const { discoverPlugins } = require("./discovery");
|
||||
const configStore = require("./config-store");
|
||||
const {
|
||||
installObsidianPlugin,
|
||||
removeObsidianPlugin,
|
||||
} = require("./obsidian-plugin");
|
||||
const { getVersion } = require("../version");
|
||||
|
||||
let discoveredPlugins = new Map();
|
||||
const loadedPlugins = new Map();
|
||||
@@ -50,18 +47,6 @@ async function initPlugins(ctx) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const discovered = discoveredPlugins.get(pluginId);
|
||||
|
||||
if (discovered.obsidianPlugin) {
|
||||
try {
|
||||
await installObsidianPlugin(discovered.obsidianPlugin, vaultPath);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[plugins] Failed to verify bundled plugin for ${pluginId} in ${vaultId}: ${e.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const loaded = loadedPlugins.get(pluginId);
|
||||
|
||||
if (loaded?.module?.onVaultEnabled) {
|
||||
@@ -182,30 +167,28 @@ async function enablePluginForVault(pluginId, vaultId) {
|
||||
await loadPlugin(pluginId);
|
||||
}
|
||||
|
||||
if (discovered.obsidianPlugin) {
|
||||
try {
|
||||
const result = await installObsidianPlugin(
|
||||
discovered.obsidianPlugin,
|
||||
vaultPath,
|
||||
);
|
||||
|
||||
if (result.installed) {
|
||||
console.log(
|
||||
`[plugins] Installed bundled Obsidian plugin for ${pluginId} in vault: ${vaultId}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[plugins] Failed to install bundled plugin for ${pluginId}: ${e.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const loaded = loadedPlugins.get(pluginId);
|
||||
|
||||
if (loaded?.module?.onVaultEnabled) {
|
||||
await loaded.module.onVaultEnabled(vaultId, vaultPath);
|
||||
}
|
||||
|
||||
// Broadcast to any open tabs on this vault so they load the plugin properly.
|
||||
if (discovered.obsidianPlugin && discovered.bundledPluginId) {
|
||||
const v = `?v=${getVersion()}`;
|
||||
const entry = {
|
||||
id: discovered.bundledPluginId,
|
||||
scriptUrl: `/${discovered.bundledPluginId}.js${v}`,
|
||||
cssUrl: `/${discovered.bundledPluginId}.css${v}`,
|
||||
manifest: discovered.bundledManifest,
|
||||
};
|
||||
|
||||
serverCtx.wss?.broadcastToVault?.(vaultId, {
|
||||
type: "virtual-plugin-enable",
|
||||
vault: vaultId,
|
||||
entry,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function disablePluginForVault(pluginId, vaultId) {
|
||||
@@ -227,25 +210,6 @@ async function disablePluginForVault(pluginId, vaultId) {
|
||||
await loaded.module.onVaultDisabled(vaultId, vaultPath);
|
||||
}
|
||||
|
||||
if (discovered.obsidianPlugin) {
|
||||
try {
|
||||
const result = await removeObsidianPlugin(
|
||||
discovered.obsidianPlugin,
|
||||
vaultPath,
|
||||
);
|
||||
|
||||
if (result.removed) {
|
||||
console.log(
|
||||
`[plugins] Removed bundled Obsidian plugin for ${pluginId} from vault: ${vaultId}`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[plugins] Failed to remove bundled plugin for ${pluginId}: ${e.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const enabledVaults = configStore.getEnabledVaults(pluginConfig, pluginId);
|
||||
const updated = enabledVaults.filter((id) => id !== vaultId);
|
||||
configStore.setEnabledVaults(pluginConfig, pluginId, updated);
|
||||
@@ -254,6 +218,55 @@ async function disablePluginForVault(pluginId, vaultId) {
|
||||
if (updated.length === 0) {
|
||||
await unloadPlugin(pluginId);
|
||||
}
|
||||
|
||||
if (discovered.bundledPluginId) {
|
||||
serverCtx.wss?.broadcastToVault?.(vaultId, {
|
||||
type: "virtual-plugin-disable",
|
||||
vault: vaultId,
|
||||
id: discovered.bundledPluginId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getBundledPluginDirs() {
|
||||
const dirs = [];
|
||||
|
||||
for (const [, discovered] of discoveredPlugins) {
|
||||
if (discovered.obsidianPlugin && discovered.bundledPluginId) {
|
||||
dirs.push({
|
||||
bundledPluginId: discovered.bundledPluginId,
|
||||
distDir: path.join(discovered.obsidianPlugin, "dist"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function getVirtualPluginsForVault(vaultId, version) {
|
||||
const v = version ? `?v=${version}` : "";
|
||||
const result = [];
|
||||
|
||||
for (const [pluginId, discovered] of discoveredPlugins) {
|
||||
if (!discovered.obsidianPlugin || !discovered.bundledPluginId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const enabledVaults = configStore.getEnabledVaults(pluginConfig, pluginId);
|
||||
|
||||
if (!enabledVaults.includes(vaultId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: discovered.bundledPluginId,
|
||||
scriptUrl: `/${discovered.bundledPluginId}.js${v}`,
|
||||
cssUrl: `/${discovered.bundledPluginId}.css${v}`,
|
||||
manifest: discovered.bundledManifest,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getDiscoveredPlugins() {
|
||||
@@ -280,4 +293,6 @@ module.exports = {
|
||||
enablePluginForVault,
|
||||
disablePluginForVault,
|
||||
getDiscoveredPlugins,
|
||||
getBundledPluginDirs,
|
||||
getVirtualPluginsForVault,
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const { getObHome } = require("./ob-cli");
|
||||
|
||||
function getObAuthFile() {
|
||||
function getObAuthFile(dataDir) {
|
||||
return path.join(
|
||||
os.homedir(),
|
||||
getObHome(dataDir),
|
||||
".config",
|
||||
"obsidian-headless",
|
||||
"auth_token",
|
||||
@@ -23,14 +23,14 @@ function loadToken(dataDir) {
|
||||
const data = JSON.parse(fs.readFileSync(internalFile, "utf-8"));
|
||||
|
||||
if (data && data.token) {
|
||||
syncToObCli(data.token);
|
||||
syncToObCli(dataDir, data.token);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fall back to ob CLI's own auth file
|
||||
const obAuthFile = getObAuthFile();
|
||||
const obAuthFile = getObAuthFile(dataDir);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(obAuthFile)) {
|
||||
@@ -49,7 +49,7 @@ function loadToken(dataDir) {
|
||||
|
||||
function saveToken(dataDir, tokenData) {
|
||||
saveInternal(dataDir, tokenData);
|
||||
syncToObCli(tokenData.token);
|
||||
syncToObCli(dataDir, tokenData.token);
|
||||
}
|
||||
|
||||
function clearToken(dataDir) {
|
||||
@@ -61,7 +61,7 @@ function clearToken(dataDir) {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const obAuthFile = getObAuthFile();
|
||||
const obAuthFile = getObAuthFile(dataDir);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(obAuthFile)) {
|
||||
@@ -83,28 +83,36 @@ function isAuthenticated(dataDir) {
|
||||
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) {
|
||||
const internalFile = getInternalTokenFile(dataDir);
|
||||
const dir = path.dirname(internalFile);
|
||||
|
||||
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(token) {
|
||||
const obAuthFile = getObAuthFile();
|
||||
function syncToObCli(dataDir, token) {
|
||||
const obAuthFile = getObAuthFile(dataDir);
|
||||
|
||||
try {
|
||||
const dir = path.dirname(obAuthFile);
|
||||
|
||||
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 {}
|
||||
}
|
||||
|
||||
@@ -124,4 +132,10 @@ function getTokenInfo(dataDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { loadToken, saveToken, clearToken, isAuthenticated, getTokenInfo };
|
||||
module.exports = {
|
||||
loadToken,
|
||||
saveToken,
|
||||
clearToken,
|
||||
isAuthenticated,
|
||||
getTokenInfo,
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
const CHANNEL = "plugin:headless-sync";
|
||||
|
||||
class SyncBroadcaster {
|
||||
constructor(wss) {
|
||||
this._channel = wss.channel(CHANNEL);
|
||||
}
|
||||
|
||||
broadcastLog(vaultId, line) {
|
||||
this._channel.broadcastToVault(vaultId, {
|
||||
type: "sync-log",
|
||||
payload: { vaultId, line },
|
||||
});
|
||||
}
|
||||
|
||||
broadcastStatus(state) {
|
||||
if (!state || !state.vaultId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._channel.broadcastToVault(state.vaultId, {
|
||||
type: "sync-status",
|
||||
payload: state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SyncBroadcaster };
|
||||
@@ -11,7 +11,7 @@ module.exports = {
|
||||
version: "0.3.0",
|
||||
//TODO: add server plugin manifest
|
||||
|
||||
obsidianPlugin: path.join(__dirname, "plugin"),
|
||||
obsidianPlugin: path.join(__dirname, "obsidian"),
|
||||
|
||||
_ctx: null,
|
||||
_obStatus: null,
|
||||
@@ -29,6 +29,10 @@ module.exports = {
|
||||
ctx.log("ob CLI not found. Install obsidian-headless to enable sync.");
|
||||
}
|
||||
|
||||
// Redirect ob's HOME under the plugin's data dir so its config (per-vault sync setups, etc.)
|
||||
// survives container recreates. Must happen before auth.loadToken since loadToken pushes the token into ob's config location via syncToObCli.
|
||||
obCli.configure({ dataDir: ctx.dataDir });
|
||||
|
||||
const token = auth.loadToken(ctx.dataDir);
|
||||
|
||||
if (token) {
|
||||
@@ -59,22 +63,9 @@ module.exports = {
|
||||
|
||||
const { mountRoutes } = require("./routes");
|
||||
mountRoutes(ctx.router, this);
|
||||
|
||||
// Register WebSocket message handler for log subscriptions
|
||||
if (ctx.wss && ctx.wss.messageHandlers) {
|
||||
ctx.wss.messageHandlers.set("subscribe-logs", (msg) => {
|
||||
if (msg.vaultId && this._broadcaster) {
|
||||
this._broadcaster.subscribeToLogs(msg.vaultId);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async shutdown() {
|
||||
if (this._ctx?.wss?.messageHandlers) {
|
||||
this._ctx.wss.messageHandlers.delete("subscribe-logs");
|
||||
}
|
||||
|
||||
if (this._syncManager) {
|
||||
await this._syncManager.shutdown();
|
||||
this._syncManager = null;
|
||||
@@ -1,7 +1,25 @@
|
||||
const { spawn, execSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
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
|
||||
// ob's config dir (~/.config/obsidian-headless/) survives container recreates.
|
||||
let configuredDataDir = null;
|
||||
|
||||
function getObHome(dataDir) {
|
||||
return path.join(dataDir, "ob-home");
|
||||
}
|
||||
|
||||
function configure(opts) {
|
||||
configuredDataDir = opts && opts.dataDir ? opts.dataDir : null;
|
||||
|
||||
if (configuredDataDir) {
|
||||
try {
|
||||
fs.mkdirSync(getObHome(configuredDataDir), { recursive: true });
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function checkInstalled() {
|
||||
try {
|
||||
@@ -19,9 +37,11 @@ function checkInstalled() {
|
||||
}
|
||||
|
||||
function spawnOb(args, opts = {}) {
|
||||
const home = configuredDataDir ? getObHome(configuredDataDir) : os.homedir();
|
||||
|
||||
return spawn("ob", args, {
|
||||
env: { ...process.env, HOME: os.homedir() },
|
||||
shell: isWindows,
|
||||
env: { ...process.env, HOME: home },
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
...opts,
|
||||
});
|
||||
@@ -58,4 +78,10 @@ function runCommand(args, opts = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { checkInstalled, spawnOb, runCommand };
|
||||
module.exports = {
|
||||
checkInstalled,
|
||||
spawnOb,
|
||||
runCommand,
|
||||
configure,
|
||||
getObHome,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "ignis-headless-sync",
|
||||
"name": "Ignis Headless Sync",
|
||||
"name": "Headless Sync",
|
||||
"version": "0.3.0",
|
||||
"minAppVersion": "1.12.4",
|
||||
"description": "Client-side companion for server-side Obsidian Sync",
|
||||
@@ -32,13 +32,12 @@ function showConflictWarning(title, message) {
|
||||
});
|
||||
}
|
||||
|
||||
function startCoreSyncGuard(plugin, api, wsListener) {
|
||||
function startCoreSyncGuard(plugin, api) {
|
||||
const app = plugin.app;
|
||||
const vaultId = app.vault.getName();
|
||||
|
||||
// Monkey-patch syncPlugin.enable() to clear the shim flag before
|
||||
// Obsidian writes core-plugins.json. This ensures the read transform
|
||||
// doesn't block a user-initiated core sync enable.
|
||||
// Monkey-patch syncPlugin.enable() to clear the shim flag before Obsidian writes core-plugins.json.
|
||||
// This ensures the read transform doesn't block a user-initiated core sync enable.
|
||||
const syncPlugin = app.internalPlugins.getPluginById("sync");
|
||||
let origEnable = null;
|
||||
|
||||
@@ -52,16 +51,13 @@ function startCoreSyncGuard(plugin, api, wsListener) {
|
||||
};
|
||||
}
|
||||
|
||||
// Watch for core-plugins.json changes via WebSocket.
|
||||
let wasEnabled = isCoreSyncEnabled();
|
||||
|
||||
const rawHandler = (msg) => {
|
||||
if (msg.type === "modified" && msg.path === CORE_PLUGINS_PATH) {
|
||||
const unsubModified = window.__ignis.ws.subscribe("modified", (msg) => {
|
||||
if (msg.path === CORE_PLUGINS_PATH) {
|
||||
handleCoreSyncChange();
|
||||
}
|
||||
};
|
||||
|
||||
wsListener.onRaw(rawHandler);
|
||||
});
|
||||
|
||||
function handleCoreSyncChange() {
|
||||
const enabled = isCoreSyncEnabled();
|
||||
@@ -80,7 +76,7 @@ function startCoreSyncGuard(plugin, api, wsListener) {
|
||||
|
||||
return {
|
||||
cleanup() {
|
||||
wsListener.offRaw();
|
||||
unsubModified();
|
||||
|
||||
if (syncPlugin && origEnable) {
|
||||
syncPlugin.enable = origEnable;
|
||||
@@ -1,6 +1,8 @@
|
||||
const api = require("./api");
|
||||
|
||||
async function renderLogViewer(containerEl, vaultId, wsListener) {
|
||||
const CHANNEL = "plugin:headless-sync";
|
||||
|
||||
async function renderLogViewer(containerEl, vaultId) {
|
||||
const details = containerEl.createEl("details", {
|
||||
cls: "ignis-log-details",
|
||||
});
|
||||
@@ -32,19 +34,12 @@ async function renderLogViewer(containerEl, vaultId, wsListener) {
|
||||
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
|
||||
if (!wsListener) {
|
||||
return () => {};
|
||||
}
|
||||
const channel = window.__ignis.ws.channel(CHANNEL);
|
||||
let unsubLog = null;
|
||||
|
||||
details.addEventListener("toggle", () => {
|
||||
if (details.open) {
|
||||
wsListener.subscribeLogs(vaultId);
|
||||
} else {
|
||||
wsListener.unsubscribeLogs();
|
||||
}
|
||||
});
|
||||
const onLog = (msg) => {
|
||||
const payload = msg.payload || {};
|
||||
|
||||
const onLog = (payload) => {
|
||||
if (payload.vaultId !== vaultId) {
|
||||
return;
|
||||
}
|
||||
@@ -66,11 +61,22 @@ async function renderLogViewer(containerEl, vaultId, wsListener) {
|
||||
}
|
||||
};
|
||||
|
||||
wsListener.on("sync-log", onLog);
|
||||
details.addEventListener("toggle", () => {
|
||||
if (details.open) {
|
||||
if (!unsubLog) {
|
||||
unsubLog = channel.subscribe("sync-log", onLog);
|
||||
}
|
||||
} else if (unsubLog) {
|
||||
unsubLog();
|
||||
unsubLog = null;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
wsListener.off("sync-log", onLog);
|
||||
wsListener.unsubscribeLogs();
|
||||
if (unsubLog) {
|
||||
unsubLog();
|
||||
unsubLog = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
const { Plugin } = require("obsidian");
|
||||
const { HeadlessSyncSettingTab } = require("./settings-tab");
|
||||
const { WsListener } = require("./ws-listener");
|
||||
const { initSyncStatusBar } = require("./sync-status-bar");
|
||||
const { startCoreSyncGuard } = require("./core-sync-guard");
|
||||
const api = require("./api");
|
||||
@@ -14,14 +13,11 @@ class IgnisHeadlessSyncPlugin extends Plugin {
|
||||
return;
|
||||
}
|
||||
|
||||
this.wsListener = new WsListener();
|
||||
this.wsListener.start();
|
||||
|
||||
this._syncStatusBarCleanup = initSyncStatusBar(this, this.wsListener);
|
||||
this._syncStatusBarCleanup = initSyncStatusBar(this);
|
||||
|
||||
this.addSettingTab(new HeadlessSyncSettingTab(this.app, this));
|
||||
|
||||
this._coreSyncGuard = startCoreSyncGuard(this, api, this.wsListener);
|
||||
this._coreSyncGuard = startCoreSyncGuard(this, api);
|
||||
|
||||
this.addCommand({
|
||||
id: "start-sync",
|
||||
@@ -75,11 +71,6 @@ class IgnisHeadlessSyncPlugin extends Plugin {
|
||||
this._syncStatusBarCleanup();
|
||||
this._syncStatusBarCleanup = null;
|
||||
}
|
||||
|
||||
if (this.wsListener) {
|
||||
this.wsListener.stop();
|
||||
this.wsListener = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,11 +316,7 @@ class HeadlessSyncSettingTab extends PluginSettingTab {
|
||||
}
|
||||
|
||||
async renderLogs(containerEl, vaultId) {
|
||||
this._logCleanup = await renderLogViewer(
|
||||
containerEl,
|
||||
vaultId,
|
||||
this.plugin.wsListener,
|
||||
);
|
||||
this._logCleanup = await renderLogViewer(containerEl, vaultId);
|
||||
}
|
||||
|
||||
hide() {
|
||||
@@ -1,6 +1,8 @@
|
||||
const { setIcon } = require("obsidian");
|
||||
const api = require("./api");
|
||||
|
||||
const CHANNEL = "plugin:headless-sync";
|
||||
|
||||
const TOOLTIP_MAP = {
|
||||
running: "Syncing...",
|
||||
synced: "Synced",
|
||||
@@ -8,8 +10,11 @@ const TOOLTIP_MAP = {
|
||||
error: "Sync error",
|
||||
};
|
||||
|
||||
function initSyncStatusBar(plugin, wsListener) {
|
||||
function initSyncStatusBar(plugin) {
|
||||
const vaultId = plugin.app.vault.getName();
|
||||
const ws = window.__ignis.ws;
|
||||
const channel = ws.channel(CHANNEL);
|
||||
|
||||
const item = plugin.addStatusBarItem();
|
||||
item.addClass("ignis-sync-statusbar");
|
||||
item.style.display = "none";
|
||||
@@ -21,6 +26,7 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||
let popoverOpen = false;
|
||||
let currentStatus = "stopped";
|
||||
let outsideClickHandler = null;
|
||||
let unsubLog = null;
|
||||
|
||||
function updateState(status, error) {
|
||||
currentStatus = status;
|
||||
@@ -62,7 +68,7 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||
|
||||
popoverOpen = true;
|
||||
|
||||
wsListener.subscribeLogs(vaultId);
|
||||
unsubLog = channel.subscribe("sync-log", onLog);
|
||||
|
||||
outsideClickHandler = (e) => {
|
||||
if (!item.contains(e.target)) {
|
||||
@@ -86,7 +92,11 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||
outsideClickHandler = null;
|
||||
}
|
||||
|
||||
wsListener.unsubscribeLogs();
|
||||
if (unsubLog) {
|
||||
unsubLog();
|
||||
unsubLog = null;
|
||||
}
|
||||
|
||||
popoverOpen = false;
|
||||
}
|
||||
|
||||
@@ -95,7 +105,7 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return "\u2026" + path.slice(-(maxLen - 1));
|
||||
return "…" + path.slice(-(maxLen - 1));
|
||||
}
|
||||
|
||||
function formatPopoverText(prefix, path) {
|
||||
@@ -115,35 +125,30 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||
}
|
||||
|
||||
function extractFileActivity(line) {
|
||||
// Downloading/Downloaded path
|
||||
let match = line.match(/^(?:Downloading|Downloaded)\s+(.+)$/);
|
||||
|
||||
if (match) {
|
||||
return { prefix: "Syncing", path: match[1].trim() };
|
||||
}
|
||||
|
||||
// Uploading file / Upload complete path
|
||||
match = line.match(/^(?:Uploading file|Upload complete|New file)\s+(.+)$/);
|
||||
|
||||
if (match) {
|
||||
return { prefix: "Syncing", path: match[1].trim() };
|
||||
}
|
||||
|
||||
// Deleting path
|
||||
match = line.match(/^Deleting\s+(.+)$/);
|
||||
|
||||
if (match) {
|
||||
return { prefix: "Deleting", path: match[1].trim() };
|
||||
}
|
||||
|
||||
// Push: path (updated)
|
||||
match = line.match(/^Push:\s+(.+?)\s+\(updated\)$/);
|
||||
|
||||
if (match) {
|
||||
return { prefix: "Syncing", path: match[1].trim() };
|
||||
}
|
||||
|
||||
// Push: path (deleted)
|
||||
match = line.match(/^Push:\s+(.+?)\s+\(deleted\)$/);
|
||||
|
||||
if (match) {
|
||||
@@ -157,7 +162,6 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||
return /Fully synced/i.test(line);
|
||||
}
|
||||
|
||||
// Click toggles popover
|
||||
item.addEventListener("click", () => {
|
||||
if (popoverOpen) {
|
||||
hidePopover();
|
||||
@@ -166,16 +170,15 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for status updates
|
||||
const onStatus = (payload) => {
|
||||
const onStatus = (msg) => {
|
||||
const payload = msg.payload || {};
|
||||
|
||||
if (payload.vaultId !== vaultId) {
|
||||
return;
|
||||
}
|
||||
|
||||
item.style.display = "";
|
||||
|
||||
// "running" from server means the process is alive, but we refine
|
||||
// the visual state based on log activity.
|
||||
if (payload.status === "running") {
|
||||
updateState("synced");
|
||||
} else {
|
||||
@@ -183,10 +186,8 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||
}
|
||||
};
|
||||
|
||||
wsListener.on("sync-status", onStatus);
|
||||
const unsubStatus = channel.subscribe("sync-status", onStatus);
|
||||
|
||||
// Debounce the transition to "synced" state to avoid flickering
|
||||
// during rapid delete cycles (Fully synced -> Deleting -> Fully synced).
|
||||
let syncedTimer = null;
|
||||
|
||||
function deferSynced() {
|
||||
@@ -208,8 +209,9 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for log lines
|
||||
const onLog = (payload) => {
|
||||
function onLog(msg) {
|
||||
const payload = msg.payload || {};
|
||||
|
||||
if (payload.vaultId !== vaultId) {
|
||||
return;
|
||||
}
|
||||
@@ -226,11 +228,8 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||
updateState("running");
|
||||
updatePopoverText(formatPopoverText(activity.prefix, activity.path));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
wsListener.on("sync-log", onLog);
|
||||
|
||||
// Fetch initial state
|
||||
api
|
||||
.getVaults()
|
||||
.then((data) => {
|
||||
@@ -244,16 +243,16 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Poll WebSocket state to detect server disconnect/reconnect
|
||||
// Reflect WebSocket disconnect/reconnect in the indicator.
|
||||
let wasDisconnected = false;
|
||||
|
||||
const wsCheckInterval = setInterval(() => {
|
||||
const disconnected = !wsListener.isConnected();
|
||||
const unsubState = ws.onStateChange((state) => {
|
||||
const open = state === "open";
|
||||
|
||||
if (disconnected && currentStatus === "running") {
|
||||
if (!open && currentStatus === "running") {
|
||||
updateState("error", "Server connection lost");
|
||||
wasDisconnected = true;
|
||||
} else if (!disconnected && wasDisconnected) {
|
||||
} else if (open && wasDisconnected) {
|
||||
wasDisconnected = false;
|
||||
|
||||
api
|
||||
@@ -268,14 +267,12 @@ function initSyncStatusBar(plugin, wsListener) {
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
clearInterval(wsCheckInterval);
|
||||
cancelDeferredSynced();
|
||||
wsListener.off("sync-status", onStatus);
|
||||
wsListener.off("sync-log", onLog);
|
||||
unsubStatus();
|
||||
unsubState();
|
||||
hidePopover();
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ const { spawn } = require("child_process");
|
||||
const { spawnOb, runCommand } = require("./ob-cli");
|
||||
|
||||
const MAX_LOG_ENTRIES = 200;
|
||||
const MAX_LOG_LINE = 4096;
|
||||
|
||||
function killProcess(proc) {
|
||||
if (!proc) {
|
||||
@@ -151,10 +152,13 @@ class SyncManager {
|
||||
const lines = data.toString().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
this.addLog(state, line.trim());
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed) {
|
||||
const capped = trimmed.slice(0, MAX_LOG_LINE);
|
||||
this.addLog(state, capped);
|
||||
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) {
|
||||
state.logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
line,
|
||||
line: line.slice(0, MAX_LOG_LINE),
|
||||
});
|
||||
|
||||
if (state.logs.length > MAX_LOG_ENTRIES) {
|
||||
@@ -9,8 +9,12 @@ const fsp = fs.promises;
|
||||
const path = require("path");
|
||||
const zlib = require("zlib");
|
||||
const config = require("../config");
|
||||
const { isBridgePluginInstalled, getIgnisMeta } = require("../bridge-plugin");
|
||||
const { getDiscoveredPlugins } = require("../plugin-system/manager");
|
||||
const {
|
||||
getDiscoveredPlugins,
|
||||
getVirtualPluginsForVault,
|
||||
} = require("../plugin-system/manager");
|
||||
const { getVersion } = require("../version");
|
||||
const settings = require("../settings");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -76,20 +80,13 @@ async function walkTree(rootPath) {
|
||||
return { tree, dirMtimes };
|
||||
}
|
||||
|
||||
async function buildVaultInfo(vaultId, vaultPath) {
|
||||
const pluginInstalled = await isBridgePluginInstalled(vaultPath);
|
||||
const ignisMeta = await getIgnisMeta(vaultPath);
|
||||
|
||||
function buildVaultInfo(vaultId, vaultPath) {
|
||||
return {
|
||||
id: vaultId,
|
||||
name: vaultId,
|
||||
path: vaultPath,
|
||||
platform: process.platform,
|
||||
version: config.obsidianVersion,
|
||||
ignisPlugin: {
|
||||
installed: pluginInstalled,
|
||||
prompted: ignisMeta.pluginPrompted || false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,10 +131,8 @@ async function buildEntry(vaultId) {
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
const [vault, { tree, dirMtimes }] = await Promise.all([
|
||||
buildVaultInfo(vaultId, vaultPath),
|
||||
walkTree(vaultPath),
|
||||
]);
|
||||
const vault = buildVaultInfo(vaultId, vaultPath);
|
||||
const { tree, dirMtimes } = await walkTree(vaultPath);
|
||||
|
||||
const response = {
|
||||
vault,
|
||||
@@ -145,6 +140,12 @@ async function buildEntry(vaultId) {
|
||||
tree,
|
||||
// In demo mode, hide server-side plugins from the client.
|
||||
plugins: config.demoMode ? [] : getDiscoveredPlugins(),
|
||||
virtualPlugins: getVirtualPluginsForVault(vaultId, getVersion()),
|
||||
settings: {
|
||||
contentCacheBytes: settings.get("contentCacheBytes"),
|
||||
inputCacheBytes: settings.get("inputCacheBytes"),
|
||||
inputCacheTtlMs: settings.get("inputCacheTtlMs"),
|
||||
},
|
||||
};
|
||||
|
||||
const jsonBuf = Buffer.from(JSON.stringify(response));
|
||||
@@ -190,6 +191,10 @@ function invalidateVault(vaultId) {
|
||||
cache.delete(vaultId);
|
||||
}
|
||||
|
||||
function invalidateAll() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
async function warmUp() {
|
||||
const ids = Object.keys(config.vaults);
|
||||
|
||||
@@ -256,4 +261,5 @@ router.get("/", async (req, res) => {
|
||||
|
||||
module.exports = router;
|
||||
module.exports.invalidateVault = invalidateVault;
|
||||
module.exports.invalidateAll = invalidateAll;
|
||||
module.exports.warmUp = warmUp;
|
||||
@@ -3,59 +3,16 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
const archiver = require("archiver");
|
||||
const config = require("../config");
|
||||
const { writeCoalesced, getPending } = require("../write-coalescer");
|
||||
const {
|
||||
writeCoalescer,
|
||||
encodeContentDispositionFilename,
|
||||
resolveVaultPath,
|
||||
} = require("@ignis/server-core");
|
||||
const { writeCoalesced, getPending } = writeCoalescer;
|
||||
const bootstrapRoutes = require("./bootstrap");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Encode a filename for use in Content-Disposition header.
|
||||
* Handles non-ASCII characters and special characters to prevent header injection.
|
||||
* Uses RFC 5987 encoding for filename* parameter when needed.
|
||||
*
|
||||
* @param {string} filename - The filename to encode
|
||||
* @returns {string} - Properly formatted Content-Disposition value
|
||||
*/
|
||||
function encodeContentDispositionFilename(filename) {
|
||||
// Check if filename contains non-ASCII characters
|
||||
const hasNonASCII = /[^\x00-\x7F]/.test(filename);
|
||||
|
||||
// Escape quotes and backslashes in ASCII filename by prefixing with backslash
|
||||
const escapedFilename = filename.replace(/["\\ ]/g, function (match) {
|
||||
if (match === '"') return '\\"';
|
||||
if (match === "\\") return "\\\\";
|
||||
return match;
|
||||
});
|
||||
|
||||
// Remove any control characters that could cause header injection
|
||||
const sanitizedFilename = escapedFilename.replace(/[\x00-\x1F\x7F]/g, "");
|
||||
|
||||
if (!hasNonASCII) {
|
||||
// Simple ASCII filename - use standard format
|
||||
return `attachment; filename="${sanitizedFilename}"`;
|
||||
}
|
||||
|
||||
// Non-ASCII filename - use RFC 5987 encoding
|
||||
// Encode using percent-encoding for UTF-8
|
||||
const encodedFilename = encodeURIComponent(filename)
|
||||
.replace(/['()]/g, function (c) {
|
||||
return "%" + c.charCodeAt(0).toString(16).toUpperCase();
|
||||
})
|
||||
.replace(/\*/g, "%2A");
|
||||
|
||||
// Provide both filename (ASCII fallback) and filename* (UTF-8 encoded)
|
||||
// For fallback, replace non-ASCII with underscores
|
||||
const asciiFallback = filename
|
||||
.replace(/[^\x00-\x7F]/g, "_")
|
||||
.replace(/["\\ ]/g, function (match) {
|
||||
if (match === '"') return '\\"';
|
||||
if (match === "\\") return "\\\\";
|
||||
return match;
|
||||
});
|
||||
|
||||
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodedFilename}`;
|
||||
}
|
||||
|
||||
// Resolve the vault root for a request. Reads vault ID from query or body.
|
||||
function getVaultRoot(req, res) {
|
||||
const vaultId = req.query.vault || req.body?.vault || config.defaultVaultId;
|
||||
@@ -76,20 +33,6 @@ function invalidateBootstrap(req) {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve a client-provided path to an absolute path within a vault.
|
||||
// Strips leading slashes so paths from the client are always treated as relative to the vault root.
|
||||
function resolveVaultPath(vaultRoot, relativePath) {
|
||||
const cleaned = (relativePath || "").replace(/^\/+/, "");
|
||||
const resolved = path.resolve(vaultRoot, cleaned);
|
||||
|
||||
const resolvedRoot = path.resolve(vaultRoot);
|
||||
|
||||
if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function guardPath(req, res, source = "query") {
|
||||
const vaultRoot = getVaultRoot(req, res);
|
||||
|
||||
@@ -155,7 +98,7 @@ router.get("/stat", async (req, res) => {
|
||||
} catch (e) {
|
||||
res
|
||||
.status(e.code === "ENOENT" ? 404 : 500)
|
||||
.json({ error: e.message, code: e.code });
|
||||
.json({ error: e.code || "internal", code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -190,7 +133,7 @@ router.get("/readdir", async (req, res) => {
|
||||
} catch (e) {
|
||||
res
|
||||
.status(e.code === "ENOENT" ? 404 : 500)
|
||||
.json({ error: e.message, code: e.code });
|
||||
.json({ error: e.code || "internal", code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -241,7 +184,7 @@ router.get("/readFile", async (req, res) => {
|
||||
} catch (e) {
|
||||
res
|
||||
.status(e.code === "ENOENT" ? 404 : 500)
|
||||
.json({ error: e.message, code: e.code });
|
||||
.json({ error: e.code || "internal", code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -270,7 +213,7 @@ router.post("/writeFile", async (req, res) => {
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true, mtime: result.mtime, size: result.size });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -288,7 +231,7 @@ router.post("/appendFile", async (req, res) => {
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -308,7 +251,7 @@ router.post("/mkdir", async (req, res) => {
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -320,8 +263,12 @@ router.post("/rename", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldResolved = resolveVaultPath(vaultRoot, req.body?.oldPath);
|
||||
const newResolved = resolveVaultPath(vaultRoot, req.body?.newPath);
|
||||
if (!req.body?.oldPath || !req.body?.newPath) {
|
||||
return res.status(400).json({ error: "Missing oldPath or newPath" });
|
||||
}
|
||||
|
||||
const oldResolved = resolveVaultPath(vaultRoot, req.body.oldPath);
|
||||
const newResolved = resolveVaultPath(vaultRoot, req.body.newPath);
|
||||
|
||||
if (!oldResolved || !newResolved) {
|
||||
return res.status(403).json({ error: "Invalid path" });
|
||||
@@ -333,7 +280,7 @@ router.post("/rename", async (req, res) => {
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -345,8 +292,12 @@ router.post("/copyFile", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const srcResolved = resolveVaultPath(vaultRoot, req.body?.src);
|
||||
const destResolved = resolveVaultPath(vaultRoot, req.body?.dest);
|
||||
if (!req.body?.src || !req.body?.dest) {
|
||||
return res.status(400).json({ error: "Missing src or dest" });
|
||||
}
|
||||
|
||||
const srcResolved = resolveVaultPath(vaultRoot, req.body.src);
|
||||
const destResolved = resolveVaultPath(vaultRoot, req.body.dest);
|
||||
|
||||
if (!srcResolved || !destResolved) {
|
||||
return res.status(403).json({ error: "Invalid path" });
|
||||
@@ -358,7 +309,7 @@ router.post("/copyFile", async (req, res) => {
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -380,7 +331,7 @@ router.delete("/unlink", async (req, res) => {
|
||||
// File already gone - desired outcome achieved
|
||||
res.json({ ok: true });
|
||||
} else {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -399,7 +350,7 @@ router.delete("/rmdir", async (req, res) => {
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -419,7 +370,7 @@ router.delete("/rm", async (req, res) => {
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -437,23 +388,7 @@ router.get("/access", async (req, res) => {
|
||||
} catch (e) {
|
||||
res
|
||||
.status(e.code === "ENOENT" ? 404 : 500)
|
||||
.json({ error: e.message, 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 });
|
||||
.json({ error: e.code || "internal", code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -475,7 +410,7 @@ router.post("/utimes", async (req, res) => {
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -490,6 +425,11 @@ router.post("/batch-read", async (req, res) => {
|
||||
|
||||
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) {
|
||||
return res.json({ files: {} });
|
||||
}
|
||||
@@ -580,7 +520,7 @@ router.get("/tree", async (req, res) => {
|
||||
|
||||
res.json(tree);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -610,7 +550,7 @@ router.get("/download", async (req, res) => {
|
||||
} catch (e) {
|
||||
res
|
||||
.status(e.code === "ENOENT" ? 404 : 500)
|
||||
.json({ error: e.message, code: e.code });
|
||||
.json({ error: e.code || "internal", code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -648,10 +588,8 @@ router.get("/download-zip", async (req, res) => {
|
||||
} catch (e) {
|
||||
res
|
||||
.status(e.code === "ENOENT" ? 404 : 500)
|
||||
.json({ error: e.message, code: e.code });
|
||||
.json({ error: e.code || "internal", code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.resolveVaultPath = resolveVaultPath;
|
||||
module.exports.encodeContentDispositionFilename = encodeContentDispositionFilename;
|
||||
@@ -6,7 +6,7 @@ const require = createRequire(import.meta.url);
|
||||
const {
|
||||
resolveVaultPath,
|
||||
encodeContentDispositionFilename,
|
||||
} = require("./fs.js");
|
||||
} = require("@ignis/server-core");
|
||||
|
||||
// -- encodeContentDispositionFilename --------------------------------
|
||||
|
||||
@@ -77,12 +77,12 @@ describe("resolveVaultPath", () => {
|
||||
expect(resolveVaultPath(root, "")).toBe(path.resolve(root));
|
||||
});
|
||||
|
||||
it("treats null input as vault root", () => {
|
||||
expect(resolveVaultPath(root, null)).toBe(path.resolve(root));
|
||||
it("returns null for null input", () => {
|
||||
expect(resolveVaultPath(root, null)).toBe(null);
|
||||
});
|
||||
|
||||
it("treats undefined input as vault root", () => {
|
||||
expect(resolveVaultPath(root, undefined)).toBe(path.resolve(root));
|
||||
it("returns null for undefined input", () => {
|
||||
expect(resolveVaultPath(root, undefined)).toBe(null);
|
||||
});
|
||||
|
||||
it("strips leading slashes", () => {
|
||||
429
apps/ignis-server/server/routes/proxy.js
Normal file
429
apps/ignis-server/server/routes/proxy.js
Normal file
@@ -0,0 +1,429 @@
|
||||
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 MAX_RESPONSE_BYTES = 50 * 1024 * 1024;
|
||||
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) => {
|
||||
const { url, method, headers, body, binary } = req.body;
|
||||
|
||||
if (!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 {
|
||||
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",
|
||||
headers: headers || {},
|
||||
body: reqBody,
|
||||
});
|
||||
|
||||
const declaredLength = Number(upstream.headers["content-length"]);
|
||||
|
||||
if (
|
||||
Number.isFinite(declaredLength) &&
|
||||
declaredLength > MAX_RESPONSE_BYTES
|
||||
) {
|
||||
upstream.destroy();
|
||||
return res.status(413).json({ error: "Upstream response too large" });
|
||||
}
|
||||
|
||||
const respBody = await readBody(upstream, MAX_RESPONSE_BYTES);
|
||||
|
||||
// Strip hop-by-hop and encoding headers; the body is already decompressed.
|
||||
const skipHeaders = new Set([
|
||||
"content-encoding",
|
||||
"transfer-encoding",
|
||||
"content-length",
|
||||
"connection",
|
||||
]);
|
||||
const respHeaders = {};
|
||||
|
||||
for (const [key, val] of Object.entries(upstream.headers)) {
|
||||
if (!skipHeaders.has(key.toLowerCase())) {
|
||||
respHeaders[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
status: upstream.statusCode,
|
||||
headers: respHeaders,
|
||||
body: respBody.toString("base64"),
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(e.statusCode || 502).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
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({});
|
||||
});
|
||||
});
|
||||
@@ -2,16 +2,29 @@ const express = require("express");
|
||||
const fs = require("fs");
|
||||
const config = require("../config");
|
||||
const path = require("path");
|
||||
const {
|
||||
isBridgePluginInstalled,
|
||||
getIgnisMeta,
|
||||
setIgnisMeta,
|
||||
installBridgePlugin,
|
||||
} = require("../bridge-plugin");
|
||||
const bootstrapRoutes = require("./bootstrap");
|
||||
|
||||
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)
|
||||
router.get("/list", (req, res) => {
|
||||
config.refreshVaults();
|
||||
@@ -34,19 +47,12 @@ router.get("/info", async (req, res) => {
|
||||
return res.status(404).json({ error: "Vault not found", id: vaultId });
|
||||
}
|
||||
|
||||
const pluginInstalled = await isBridgePluginInstalled(vaultPath);
|
||||
const ignisMeta = await getIgnisMeta(vaultPath);
|
||||
|
||||
res.json({
|
||||
id: vaultId,
|
||||
name: vaultId,
|
||||
path: vaultPath,
|
||||
platform: process.platform,
|
||||
version: config.obsidianVersion,
|
||||
ignisPlugin: {
|
||||
installed: pluginInstalled,
|
||||
prompted: ignisMeta.pluginPrompted || false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +60,7 @@ router.get("/info", async (req, res) => {
|
||||
router.post("/create", async (req, res) => {
|
||||
const name = req.body?.name;
|
||||
|
||||
if (!name || /[\/\\:*?"<>|]/.test(name)) {
|
||||
if (!isValidVaultName(name)) {
|
||||
return res.status(400).json({ error: "Invalid vault name" });
|
||||
}
|
||||
|
||||
@@ -66,8 +72,6 @@ router.post("/create", async (req, res) => {
|
||||
recursive: false,
|
||||
});
|
||||
|
||||
await installBridgePlugin(vaultPath);
|
||||
|
||||
config.refreshVaults();
|
||||
bootstrapRoutes.invalidateVault(name);
|
||||
|
||||
@@ -77,7 +81,7 @@ router.post("/create", async (req, res) => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -86,7 +90,7 @@ router.post("/rename", async (req, res) => {
|
||||
const vaultId = req.body?.vault;
|
||||
const newName = req.body?.name;
|
||||
|
||||
if (!newName || /[\/\\:*?"<>|]/.test(newName)) {
|
||||
if (!isValidVaultName(newName)) {
|
||||
return res.status(400).json({ error: "Invalid vault name" });
|
||||
}
|
||||
|
||||
@@ -113,7 +117,7 @@ router.post("/rename", async (req, res) => {
|
||||
.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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -134,45 +138,7 @@ router.delete("/remove", async (req, res) => {
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/vault/install-plugin { vault, dismiss } - install plugin or mark as prompted
|
||||
router.post("/install-plugin", async (req, res) => {
|
||||
const vaultId = req.body?.vault;
|
||||
const dismiss = req.body?.dismiss || false;
|
||||
|
||||
if (!vaultId) {
|
||||
return res.status(400).json({ error: "Missing vault ID" });
|
||||
}
|
||||
|
||||
const vaultPath = config.getVaultPath(vaultId);
|
||||
|
||||
if (!vaultPath) {
|
||||
return res.status(404).json({ error: "Vault not found" });
|
||||
}
|
||||
|
||||
try {
|
||||
const meta = await getIgnisMeta(vaultPath);
|
||||
|
||||
if (dismiss) {
|
||||
// User clicked "Don't Ask Again" or "Not Now"
|
||||
meta.pluginPrompted = true;
|
||||
await setIgnisMeta(vaultPath, meta);
|
||||
|
||||
return res.json({ ok: true, prompted: true });
|
||||
} else {
|
||||
// User wants to install the plugin
|
||||
const installed = await installBridgePlugin(vaultPath);
|
||||
|
||||
meta.pluginPrompted = true;
|
||||
await setIgnisMeta(vaultPath, meta);
|
||||
|
||||
return res.json({ ok: true, installed, prompted: true });
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
res.status(500).json({ error: e.code || "internal", code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
const express = require("express");
|
||||
const { getVersion } = require("../version");
|
||||
const { getSemver, getBuild } = require("../version");
|
||||
const config = require("../config");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// `version` is the display-friendly SemVer. `build` is the per-build stamp for cache-bust.
|
||||
router.get("/", (req, res) => {
|
||||
const pkg = require("../../package.json");
|
||||
|
||||
res.json({
|
||||
version: getVersion(),
|
||||
semver: pkg.version,
|
||||
version: getSemver(),
|
||||
build: getBuild(),
|
||||
obsidianVersion: config.obsidianVersion,
|
||||
});
|
||||
});
|
||||
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,
|
||||
};
|
||||
51
apps/ignis-server/server/version.js
Normal file
51
apps/ignis-server/server/version.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
let cached = null;
|
||||
|
||||
function load() {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Production: root build.js writes this next to us.
|
||||
try {
|
||||
cached = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "build-info.json"), "utf-8"),
|
||||
);
|
||||
return cached;
|
||||
} catch {}
|
||||
|
||||
// Local dev fallback. Read root package.json.
|
||||
try {
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "..", "package.json"),
|
||||
"utf-8",
|
||||
),
|
||||
);
|
||||
cached = {
|
||||
semver: pkg.version,
|
||||
build: "dev",
|
||||
version: `${pkg.version}-dev`,
|
||||
};
|
||||
return cached;
|
||||
} catch {}
|
||||
|
||||
cached = { semver: "0.0.0", build: "unknown", version: "0.0.0-unknown" };
|
||||
return cached;
|
||||
}
|
||||
|
||||
function getVersion() {
|
||||
return load().version;
|
||||
}
|
||||
|
||||
function getSemver() {
|
||||
return load().semver;
|
||||
}
|
||||
|
||||
function getBuild() {
|
||||
return load().build;
|
||||
}
|
||||
|
||||
module.exports = { getVersion, getSemver, getBuild };
|
||||
123
build.js
123
build.js
@@ -1,80 +1,61 @@
|
||||
const esbuild = require("esbuild");
|
||||
const sveltePlugin = require("esbuild-svelte");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const { version: ignisVersion } = require("./package.json");
|
||||
const headlessSyncDir = path.join(
|
||||
__dirname,
|
||||
"apps",
|
||||
"ignis-server",
|
||||
"server",
|
||||
"plugins",
|
||||
"headless-sync",
|
||||
"obsidian",
|
||||
);
|
||||
|
||||
// Compute version info once and share across per-package builds.
|
||||
const { version: semver } = require("./package.json");
|
||||
const build = process.env.IGNIS_BUILD || Date.now().toString(36).slice(-7);
|
||||
const version = `${semver}+${build}`;
|
||||
|
||||
const buildInfoPath = path.join(
|
||||
__dirname,
|
||||
"apps",
|
||||
"ignis-server",
|
||||
"server",
|
||||
"build-info.json",
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
buildInfoPath,
|
||||
JSON.stringify({ semver, build, version }, null, 2),
|
||||
);
|
||||
|
||||
// Used by packages.
|
||||
process.env.IGNIS_BUILD_RESOLVED = build;
|
||||
|
||||
Promise.all([
|
||||
// Build shim-loader.js
|
||||
esbuild.build({
|
||||
entryPoints: [path.join(__dirname, "src", "shims", "loader.js")],
|
||||
bundle: true,
|
||||
outfile: path.join(__dirname, "dist", "shim-loader.js"),
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
target: ["chrome90"],
|
||||
alias: {
|
||||
path: "path-browserify",
|
||||
},
|
||||
define: {
|
||||
__IGNIS_VERSION__: JSON.stringify(ignisVersion),
|
||||
},
|
||||
logLevel: "info",
|
||||
}),
|
||||
// Build shim-loader.js (delegated to packages/shim)
|
||||
require("./packages/shim/build.js"),
|
||||
|
||||
// Build ignis-ui.js
|
||||
esbuild.build({
|
||||
entryPoints: [path.join(__dirname, "src", "ui", "index.js")],
|
||||
bundle: true,
|
||||
outfile: path.join(__dirname, "dist", "ignis-ui.js"),
|
||||
format: "iife",
|
||||
globalName: "IgnisUI",
|
||||
platform: "browser",
|
||||
target: ["chrome90"],
|
||||
mainFields: ["svelte", "browser", "module", "main"],
|
||||
conditions: ["svelte", "browser"],
|
||||
plugins: [sveltePlugin({ compilerOptions: { css: "injected" } })],
|
||||
logLevel: "info",
|
||||
}),
|
||||
|
||||
// Build ignis-bridge plugin
|
||||
esbuild.build({
|
||||
entryPoints: [path.join(__dirname, "plugin", "src", "main.js")],
|
||||
bundle: true,
|
||||
outfile: path.join(__dirname, "plugin", "main.js"),
|
||||
format: "cjs",
|
||||
platform: "browser",
|
||||
target: ["chrome90"],
|
||||
external: ["obsidian", "fs"],
|
||||
logLevel: "info",
|
||||
}),
|
||||
// Build ignis-ui.js (delegated to packages/ui)
|
||||
require("./packages/ui/build.js"),
|
||||
|
||||
// Build headless-sync bundled plugin
|
||||
esbuild.build({
|
||||
entryPoints: [
|
||||
path.join(
|
||||
__dirname,
|
||||
"server",
|
||||
"plugins",
|
||||
"headless-sync",
|
||||
"plugin",
|
||||
"src",
|
||||
"main.js",
|
||||
),
|
||||
],
|
||||
bundle: true,
|
||||
outfile: path.join(
|
||||
__dirname,
|
||||
"server",
|
||||
"plugins",
|
||||
"headless-sync",
|
||||
"plugin",
|
||||
"main.js",
|
||||
),
|
||||
format: "cjs",
|
||||
platform: "browser",
|
||||
target: ["chrome90"],
|
||||
external: ["obsidian", "fs"], //using fs shim
|
||||
logLevel: "info",
|
||||
}),
|
||||
esbuild
|
||||
.build({
|
||||
entryPoints: [path.join(headlessSyncDir, "src", "main.js")],
|
||||
bundle: true,
|
||||
outfile: path.join(headlessSyncDir, "dist", "ignis-headless-sync.js"),
|
||||
format: "cjs",
|
||||
platform: "browser",
|
||||
target: ["chrome90"],
|
||||
external: ["obsidian", "fs"],
|
||||
logLevel: "info",
|
||||
})
|
||||
.then(() => {
|
||||
fs.copyFileSync(
|
||||
path.join(headlessSyncDir, "styles.css"),
|
||||
path.join(headlessSyncDir, "dist", "ignis-headless-sync.css"),
|
||||
);
|
||||
}),
|
||||
]).catch(() => process.exit(1));
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
|
||||
Ignis runs Obsidian in a browser by replacing its Electron backend with a shim layer that routes Node.js and Electron API calls to an Express server over HTTP and WebSocket.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Shim Layer](#shim-layer)
|
||||
- [Loading](#loading)
|
||||
- [Modules](#modules)
|
||||
- [Filesystem](#filesystem)
|
||||
- [Transforms](#transforms)
|
||||
- [IPC](#ipc)
|
||||
- [Cross-origin requests](#cross-origin-requests)
|
||||
- [Workspaces in browser tabs](#workspaces-in-browser-tabs)
|
||||
- [Bridge](#bridge)
|
||||
- [Vaults](#vaults)
|
||||
- [Server](#server)
|
||||
- [Plugins](#plugins)
|
||||
- [Obsidian Plugins](#obsidian-plugins)
|
||||
- [Ignis Plugins](#ignis-plugins)
|
||||
- [Virtual Plugins](#virtual-plugins)
|
||||
- [Demo mode](#demo-mode)
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
@@ -12,19 +32,19 @@ Browser Server
|
||||
│ Shim layer │ <────> │ /api/vault/* │
|
||||
│ fs, electron, etc. │ WS │ /api/plugins/* │
|
||||
│ ↕ │ <────> │ /api/ext/:plugin/* │
|
||||
│ Bridge plugin │ │ Ignis plugins │
|
||||
│ Bridge │ │ Ignis plugins │
|
||||
└──────────────────────┘ └──────────────────────┘
|
||||
↕
|
||||
Filesystem (vaults/)
|
||||
```
|
||||
|
||||
The shim layer makes Obsidian think it's running in Electron. The bridge plugin adds Ignis-specific features inside Obsidian.
|
||||
The shim layer makes Obsidian think it's running in Electron. The bridge adds Ignis-specific features inside Obsidian.
|
||||
|
||||
## Shim Layer
|
||||
|
||||
### Loading
|
||||
|
||||
The server serves its own `index.html` (in `server/assets/`) rather than Obsidian's. At startup it reads Obsidian's `index.html` once to discover which scripts Obsidian expects, then embeds that list in our HTML as a JSON array. The client-side HTML loads the shim loader and UI bundle first (non-deferred), then a small inline script dynamically injects Obsidian's scripts in order. Obsidian's files are never modified on disk, or transformed in transit.
|
||||
The server serves its own `index.html` (in `apps/ignis-server/server/assets/`) rather than Obsidian's. At startup it reads Obsidian's `index.html` once to discover which scripts Obsidian expects, then embeds that list in our HTML as a JSON array. The client-side HTML loads the shim loader and UI bundle first (non-deferred), then a small inline script dynamically injects Obsidian's scripts in order. Obsidian's files are never modified on disk, or transformed in transit.
|
||||
|
||||
Before injecting Obsidian's scripts, the shim loader sets `localStorage.EmulateMobile` based on viewport width (< 600px) so Obsidian boots into its mobile UI on phones and narrow windows. The loader replaces the module system, then issues a single blocking bootstrap request that returns the vault info, vault list, metadata tree, and Ignis plugin list in one pre-compressed response. The request has to be blocking because Obsidian makes synchronous filesystem calls during page load, before the event loop is running, so the cache has to already be populated.
|
||||
|
||||
@@ -49,6 +69,9 @@ Immediately after the bootstrap response is applied, the client kicks off a batc
|
||||
| `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). |
|
||||
| `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).
|
||||
|
||||
@@ -58,17 +81,17 @@ 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`).
|
||||
|
||||
Writes go through a server-side write coalescer (`server/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.
|
||||
|
||||
### Translation registry
|
||||
### Transforms
|
||||
|
||||
The shim has a registry (`src/shims/fs/transforms.js`) for hooks applied at the public shim surface, before caches or transport see the path. Three hook types:
|
||||
The shim has a transforms registry (`packages/shim/src/fs/transforms.js`) for hooks applied at the public shim surface, before caches or transport see the path. Three hook types:
|
||||
|
||||
- **Path resolvers** map a logical path to a physical path. Used by the workspaces shim to redirect reads and writes of `.obsidian/workspace.json` to `.obsidian/workspace.<name>.json` based on the `?workspace=` URL parameter, so each browser tab can hold a separate layout.
|
||||
- **Read transforms** post-process bytes returned by a read (cache hit or transport miss). Used to mask the Obsidian Sync setting in `core-plugins.json` when headless-sync is active for the vault, and to override the `active` field on reads of `workspaces.json` so each tab sees its own workspace as selected.
|
||||
- **Write transforms** pre-process bytes before a write hits the cache or transport. Used to override the `active` field on writes to `workspaces.json` so cross-tab disk state stays canonical.
|
||||
|
||||
All hooks are synchronous and registered at module load. Translation happens once at the shim entry; downstream layers (content cache, metadata cache, transport) operate only on resolved physical paths and as-stored bytes. This keeps cache keys coherent with what transport actually reads and writes, so prefetch and on-demand fetches share the same cache slot.
|
||||
All hooks are synchronous and registered at module load. They fire once at the shim entry; downstream layers (content cache, metadata cache, transport) operate only on resolved physical paths and as-stored bytes. This keeps cache keys coherent with what transport actually reads and writes, so prefetch and on-demand fetches share the same cache slot.
|
||||
|
||||
### IPC
|
||||
|
||||
@@ -82,19 +105,27 @@ 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 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
|
||||
|
||||
Obsidian's Workspaces core plugin lets you save a window layout under a name. Ignis adds a `?workspace=<name>` URL parameter that binds a tab to a specific layout. The bridge plugin's "Open workspace in new tab" command opens the picked workspace at `?workspace=<name>` in a fresh tab.
|
||||
Obsidian's Workspaces core plugin lets you save a window layout under a name. Ignis adds a `?workspace=<name>` URL parameter that binds a tab to a specific layout. The bridge plugin's "Open workspace in new tab" command opens the picked workspace in a fresh tab.
|
||||
|
||||
The fs shim redirects reads and writes of `.obsidian/workspace.json` to a per-workspace file (`.obsidian/workspace.<name>.json`), giving each tab its own layout. It also rewrites the active field on reads of `workspaces.json` so each tab's menu shows its own workspace as active.
|
||||
The implementation uses all three transforms (above): a path resolver redirects `.obsidian/workspace.json` to `.obsidian/workspace.<name>.json` so each tab has its own state file; a read transform overrides the `active` field on `workspaces.json` so the current tab's menu shows its own workspace as selected; a write transform keeps the canonical `active` value stable on disk so concurrent tabs don't clobber each other.
|
||||
|
||||
Two tabs sharing a vault stay in sync through the file watcher.
|
||||
Two tabs in the same workspace share the same state file and stay in sync through the file watcher. Two tabs in different workspaces hold independent layout state.
|
||||
|
||||
### Obsidian Plugin Compatibility
|
||||
## Bridge
|
||||
|
||||
Obsidian evals plugin code with its own require that checks its internal module map first, then falls back to the window-level require, which is the shim. Plugins that use the filesystem, path utilities, or crypto get shim implementations without any changes. Plugins that need child processes, raw sockets, or native addons will load but throw on use; the error message names the missing API.
|
||||
Ignis's built-in integration with the Obsidian UI. It subclasses Obsidian's `Plugin` to get convenient hooks (commands, ribbon icons, status bar items, settings tabs, workspace events), but it is not a plugin in the managed sense: it isn't discovered, toggled, enabled per vault, or installed into `.obsidian/plugins/`. It's bundled into `shim-loader.js` (source in `packages/bridge/`), instantiated directly by the shim loader after Obsidian boots, and always on.
|
||||
|
||||
The bridge contributes:
|
||||
|
||||
- **File actions**: a ribbon icon for uploading files into the current folder, and right-click menu items: Download (single file), Download as ZIP (folder), and Upload file (folder).
|
||||
- **Commands**: `Open workspace in new tab`.
|
||||
- **Status bar item**: a dot showing the WebSocket connection state to the Ignis server.
|
||||
- **Settings injection**: monkey-patches `app.setting.onOpen` to add two tabs in their own "Ignis" sidebar group. Each enabled Ignis plugin's companion is pulled into a separate "Ignis Core Plugins" sidebar group.
|
||||
- **Demo guards**: in demo mode, a MutationObserver disables every email/password input that appears anywhere in the document.
|
||||
|
||||
## Vaults
|
||||
|
||||
@@ -109,45 +140,39 @@ An Express server that handles filesystem operations, vault management, static f
|
||||
- `/api/vault/*` - vault CRUD and config.
|
||||
- `/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/version` - server version and git hash.
|
||||
- `/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/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.
|
||||
|
||||
**WebSocket:** A file watcher monitors vault directories and pushes change events to connected clients, keeping the client-side metadata and content caches in sync. An echo guard suppresses events caused by the same client's recent writes so they don't bounce back. The watcher also carries plugin-defined message types (e.g. headless-sync status broadcasts).
|
||||
|
||||
**Bridge plugin auto-install:** On server startup and on vault creation, the server copies the ignis-bridge plugin into each vault's `.obsidian/plugins/` directory.
|
||||
**Legacy bridge cleanup:** Earlier versions installed the bridge into each vault's `.obsidian/plugins/`. The bridge is now bundled into the shim and loaded client-side, so on startup the server removes any leftover on-disk `ignis-bridge` install from each vault (and strips it from `community-plugins.json`).
|
||||
|
||||
## Plugins
|
||||
|
||||
Three things are called "plugin" in this project.
|
||||
Aside from the built-in [Bridge](#bridge), three kinds of plugin exist in Ignis, distinguished by who loads them and where they run.
|
||||
|
||||
### Obsidian Plugins
|
||||
|
||||
Standard community and core Obsidian plugins. They work through the shim layer with no Ignis involvement beyond providing fs, path, and crypto.
|
||||
|
||||
### Bridge Plugin (ignis-bridge)
|
||||
|
||||
An Obsidian plugin auto-installed into every vault by the server. Source lives in `plugin/`, built to `plugin/main.js`.
|
||||
|
||||
It contributes:
|
||||
- **File actions**: a ribbon icon for uploading files into the current folder, and right-click menu items: Download (single file), Download as ZIP (folder), and Upload file (folder).
|
||||
- **Commands**: `Open workspace in new tab` (with a FuzzySuggestModal listing saved workspaces).
|
||||
- **Status bar item**: a dot showing the WebSocket connection state to the Ignis server.
|
||||
- **Settings injection**: monkey-patches `app.setting.onOpen` to add two tabs in their own "Ignis" sidebar group. General (server status, version, GitHub link, update check against the GitHub releases API) and Core plugins (toggle the bundled Obsidian plugins of enabled Ignis plugins on/off per vault). Each enabled Ignis plugin's bundled Obsidian plugin also gets pulled into a separate "Ignis Core Plugins" sidebar group.
|
||||
- **Demo guards**: in demo mode, a MutationObserver disables every email/password input that appears anywhere in the document and rewrites its placeholder.
|
||||
|
||||
Not user-installable through Obsidian's plugin browser. Managed entirely by the server.
|
||||
Standard community and core Obsidian plugins. Obsidian evals plugin code with its own require that checks its internal module map first, then falls back to the window-level require, which Ignis replaces with the shim. Plugins that use the filesystem, path utilities, or crypto get shim implementations transparently. Plugins that need child processes, raw sockets, or native addons load but throw on first use; the error message names the missing API.
|
||||
|
||||
### Ignis Plugins
|
||||
|
||||
A basic plugin system for extending the server. Still early, the core lifecycle works but the API surface is minimal and likely to change.
|
||||
A plugin system for extending the server. Still early, the core lifecycle works but the API surface is minimal and likely to change.
|
||||
|
||||
An Ignis plugin is a Node.js package under `server/plugins/<name>/` that exports an id, name, and a `register` function. On load it receives a context object with access to config, the WebSocket server, a file watcher, an Express router, a logger, and a persistent data directory. Plugins are enabled and disabled per vault, with state persisted in `data/plugin-config.json`.
|
||||
An Ignis plugin is a Node.js package under `apps/ignis-server/server/plugins/<name>/` that exports an id, name, and a `register` function. On load it receives a context object with access to config, the WebSocket server, a file watcher, an Express router, a logger, and a persistent data directory. Plugins are enabled and disabled per vault, with state persisted in `data/plugin-config.json`. When enabled, a plugin's Express router is mounted at `/api/ext/<pluginId>/`.
|
||||
|
||||
When enabled, a plugin's Express router is mounted at `/api/ext/<pluginId>/`. A plugin can also optionally bundle an Obsidian plugin, a directory containing a standard Obsidian plugin (manifest.json, main.js) that gets auto-installed into the vault on enable and removed on disable. This bridges the server and client sides: the Ignis plugin handles server logic and routes, while the bundled Obsidian plugin provides the in-app UI or behavior.
|
||||
An Ignis plugin can optionally ship a **virtual plugin** (see below): an Obsidian-side companion that provides the in-app UI. The Ignis plugin handles server logic and routes; the virtual plugin runs in the browser.
|
||||
|
||||
The one Ignis plugin currently in the repo is **headless-sync** (`server/plugins/headless-sync/`). It wraps the [obsidian-headless](https://github.com/Yuri-Khomyakov/obsidian-headless) CLI (`ob`) and runs `ob sync --continuous` as a per-vault child process, optionally with `--pull-only` or `--mirror-remote`. Process state (running/stopped/error, pid, last activity, recent log lines) is broadcast over the WebSocket via a small per-vault subscription protocol. The bundled Obsidian plugin (`ignis-headless-sync`) adds a status bar item, a settings tab with start/stop/unlink controls, and a core-sync guard that hides Obsidian's own Sync setting from `core-plugins.json` reads while headless sync is active for that vault, so a different device syncing the "Active core plugins list" can't accidentally re-enable it.
|
||||
The one Ignis plugin currently in the repo is **headless-sync** (`apps/ignis-server/server/plugins/headless-sync/`). It wraps the [obsidian-headless](https://github.com/obsidianmd/obsidian-headless) CLI (`ob`) and runs `ob sync --continuous` as a per-vault child process, optionally with `--pull-only` or `--mirror-remote`. Process state (running/stopped/error, pid, last activity, recent log lines) is broadcast to subscribed clients over a WebSocket channel.
|
||||
|
||||
### Virtual Plugins
|
||||
|
||||
The client-side companion of an Ignis plugin: a standard Obsidian plugin (a `manifest.json` plus a bundled script) that Ignis loads in the browser rather than installing to disk. The virtual-plugin-loader (`packages/shim/src/virtual-plugin-loader.js`) fetches the bundle from the server, evals it, instantiates the plugin class against the live `app`. Loaded instances are tracked in `window.__ignis.plugins` and can be toggled per vault. Nothing is ever written to `.obsidian/plugins/`.
|
||||
|
||||
headless-sync's companion (`ignis-headless-sync`) adds a status bar item, a settings tab with start/stop/unlink controls, and a core-sync guard that hides Obsidian's own Sync setting from `core-plugins.json` reads while headless sync is active for that vault, so a different device syncing the "Active core plugins list" can't accidentally re-enable it.
|
||||
|
||||
## Demo mode
|
||||
|
||||
@@ -164,4 +189,4 @@ Other demo behaviors:
|
||||
- Server-side plugins (e.g. headless-sync) hidden from the client; enable/disable returns 403.
|
||||
- The bridge plugin disables any `<input type="email">` or `<input type="password">` it sees anywhere in the document, with a placeholder telling users not to enter credentials.
|
||||
|
||||
All server-side demo code lives in `server/demo/`. The client-side hooks live in `src/shims/demo.js`. The deployment example is in `examples/demo/` (tmpfs-mounted vaults, restricted proxy, all the env vars).
|
||||
All server-side demo code lives in `apps/ignis-server/server/demo/`. The client-side hooks live in `packages/shim/src/demo.js`. The deployment example is in `apps/ignis-server/examples/demo/` (tmpfs-mounted vaults, restricted proxy, all the env vars).
|
||||
279
package-lock.json
generated
279
package-lock.json
generated
@@ -1,12 +1,16 @@
|
||||
{
|
||||
"name": "ignis",
|
||||
"version": "0.8.0",
|
||||
"name": "ignis-monorepo",
|
||||
"version": "0.8.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ignis",
|
||||
"version": "0.8.0",
|
||||
"name": "ignis-monorepo",
|
||||
"version": "0.8.5",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"apps/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"chokidar": "^3.6.0",
|
||||
@@ -26,11 +30,19 @@
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
},
|
||||
"apps/ignis": {
|
||||
"name": "@ignis/app",
|
||||
"version": "0.8.1",
|
||||
"extraneous": true
|
||||
},
|
||||
"apps/ignis-server": {
|
||||
"name": "@ignis/app",
|
||||
"version": "0.0.0-internal"
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
@@ -482,6 +494,30 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@ignis/app": {
|
||||
"resolved": "apps/ignis-server",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@ignis/bridge": {
|
||||
"resolved": "packages/bridge",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@ignis/server-core": {
|
||||
"resolved": "packages/server-core",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@ignis/services": {
|
||||
"resolved": "packages/services",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@ignis/shim": {
|
||||
"resolved": "packages/shim",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@ignis/ui": {
|
||||
"resolved": "packages/ui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -503,7 +539,6 @@
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -514,7 +549,6 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -524,14 +558,12 @@
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
@@ -542,7 +574,6 @@
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
@@ -972,19 +1003,18 @@
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz",
|
||||
"integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"@vitest/spy": "3.2.6",
|
||||
"@vitest/utils": "3.2.6",
|
||||
"chai": "^5.2.0",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
@@ -993,13 +1023,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
||||
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz",
|
||||
"integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/spy": "3.2.6",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17"
|
||||
},
|
||||
@@ -1020,9 +1050,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
|
||||
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz",
|
||||
"integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1033,13 +1063,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
|
||||
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz",
|
||||
"integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.4",
|
||||
"@vitest/utils": "3.2.6",
|
||||
"pathe": "^2.0.3",
|
||||
"strip-literal": "^3.0.0"
|
||||
},
|
||||
@@ -1048,13 +1078,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
|
||||
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz",
|
||||
"integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"@vitest/pretty-format": "3.2.6",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -1063,9 +1093,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
|
||||
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz",
|
||||
"integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1076,13 +1106,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
|
||||
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz",
|
||||
"integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"@vitest/pretty-format": "3.2.6",
|
||||
"loupe": "^3.1.4",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
@@ -1119,7 +1149,6 @@
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -1205,7 +1234,6 @@
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -1237,7 +1265,6 @@
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -1387,9 +1414,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
@@ -1400,7 +1427,7 @@
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.14.0",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
@@ -1411,9 +1438,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
||||
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -1567,7 +1594,6 @@
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
|
||||
"integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15",
|
||||
@@ -1752,7 +1778,6 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
|
||||
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mdn-data": "2.0.30",
|
||||
@@ -1867,9 +1892,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
@@ -1944,7 +1969,6 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
@@ -1997,14 +2021,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.3",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
@@ -2023,7 +2047,7 @@
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.14.0",
|
||||
"qs": "~6.15.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
@@ -2236,9 +2260,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -2369,7 +2393,6 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.6"
|
||||
@@ -2467,13 +2490,12 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
@@ -2493,7 +2515,6 @@
|
||||
"version": "0.577.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.577.0.tgz",
|
||||
"integrity": "sha512-0i88o57KsaHWnc80J57fY99CWzlZsSdtH5kKjLUJa7z8dum/9/AbINNLzJ7NiRFUdOgMnfAmJt8jFbW2zeC5qQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"svelte": "^3 || ^4 || ^5.0.0-next.42"
|
||||
@@ -2503,7 +2524,6 @@
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
@@ -2522,7 +2542,6 @@
|
||||
"version": "2.0.30",
|
||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
|
||||
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
@@ -2719,7 +2738,6 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
@@ -2748,9 +2766,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
@@ -2774,7 +2792,6 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
|
||||
"integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0",
|
||||
@@ -2790,9 +2807,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
@@ -2859,9 +2876,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
@@ -3090,14 +3107,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
@@ -3109,13 +3126,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
@@ -3184,7 +3201,6 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -3346,7 +3362,6 @@
|
||||
"version": "4.2.20",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz",
|
||||
"integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.1",
|
||||
@@ -4145,20 +4160,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz",
|
||||
"integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
"@vitest/mocker": "3.2.4",
|
||||
"@vitest/pretty-format": "^3.2.4",
|
||||
"@vitest/runner": "3.2.4",
|
||||
"@vitest/snapshot": "3.2.4",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"@vitest/expect": "3.2.6",
|
||||
"@vitest/mocker": "3.2.6",
|
||||
"@vitest/pretty-format": "^3.2.6",
|
||||
"@vitest/runner": "3.2.6",
|
||||
"@vitest/snapshot": "3.2.6",
|
||||
"@vitest/spy": "3.2.6",
|
||||
"@vitest/utils": "3.2.6",
|
||||
"chai": "^5.2.0",
|
||||
"debug": "^4.4.1",
|
||||
"expect-type": "^1.2.1",
|
||||
@@ -4188,8 +4203,8 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"@vitest/browser": "3.2.4",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"@vitest/browser": "3.2.6",
|
||||
"@vitest/ui": "3.2.6",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
@@ -4379,9 +4394,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -4412,6 +4427,56 @@
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"packages/bridge": {
|
||||
"name": "@ignis/bridge",
|
||||
"version": "0.0.0-internal"
|
||||
},
|
||||
"packages/bridge-plugin": {
|
||||
"name": "@ignis/bridge-plugin",
|
||||
"version": "0.0.0-internal",
|
||||
"extraneous": true,
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.20.0"
|
||||
}
|
||||
},
|
||||
"packages/server-core": {
|
||||
"name": "@ignis/server-core",
|
||||
"version": "0.0.0-internal",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.6.0",
|
||||
"ws": "^8.16.0"
|
||||
}
|
||||
},
|
||||
"packages/services": {
|
||||
"name": "@ignis/services",
|
||||
"version": "0.0.0-internal"
|
||||
},
|
||||
"packages/shim": {
|
||||
"name": "@ignis/shim",
|
||||
"version": "0.0.0-internal",
|
||||
"dependencies": {
|
||||
"@ignis/services": "*",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"pako": "^2.1.0",
|
||||
"path-browserify": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.20.0"
|
||||
}
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@ignis/ui",
|
||||
"version": "0.0.0-internal",
|
||||
"dependencies": {
|
||||
"@ignis/services": "*",
|
||||
"lucide-svelte": "^0.577.0",
|
||||
"svelte": "^4.2.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.20.0",
|
||||
"esbuild-svelte": "^0.9.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
package.json
13
package.json
@@ -1,12 +1,17 @@
|
||||
{
|
||||
"name": "ignis",
|
||||
"version": "0.8.0",
|
||||
"name": "ignis-monorepo",
|
||||
"version": "0.8.6",
|
||||
"private": true,
|
||||
"description": "An Electron shim and server bridge for running Obsidian in a browser.",
|
||||
"description": "Monorepo for Ignis: a browser-based Obsidian client. Self-hosted server in apps/ignis-server; shim, UI, and shared libraries in packages/.",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"apps/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"dev:server": "node server/index.js",
|
||||
"dev:server": "node apps/ignis-server/server/index.js",
|
||||
"dev": "npm run build && npm run dev:server",
|
||||
"docker:build": "node apps/ignis-server/scripts/build-image.js",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
|
||||
7
packages/bridge/package.json
Normal file
7
packages/bridge/package.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@ignis/bridge",
|
||||
"version": "0.0.0-internal",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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() {
|
||||
return window.__currentVaultId || "";
|
||||
@@ -92,4 +92,4 @@ function addFolderMenuItems(menu, folder, app) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { showFilePicker, addFileMenuItems, addFolderMenuItems };
|
||||
export { showFilePicker, addFileMenuItems, addFolderMenuItems };
|
||||
@@ -1,19 +1,17 @@
|
||||
const { Plugin, TFile, TFolder } = require("obsidian");
|
||||
const {
|
||||
import { Plugin, TFile, TFolder } from "obsidian";
|
||||
import {
|
||||
showFilePicker,
|
||||
addFileMenuItems,
|
||||
addFolderMenuItems,
|
||||
} = require("./file-actions");
|
||||
const {
|
||||
} from "./file-actions.js";
|
||||
import {
|
||||
patchSettingsModal,
|
||||
unpatchSettingsModal,
|
||||
} = require("./settings/inject");
|
||||
const pluginRegistry = require("./plugin-registry");
|
||||
const { initStatusBar } = require("./status-bar");
|
||||
const { WorkspacePickerModal } = require("./workspace-picker");
|
||||
const { startDemoGuards, stopDemoGuards } = require("./demo-guards");
|
||||
|
||||
window.__obsidianAPI = require("obsidian");
|
||||
} from "./settings/inject.js";
|
||||
import * as pluginRegistry from "./plugin-registry.js";
|
||||
import { initStatusBar } from "./status-bar.js";
|
||||
import { WorkspacePickerModal } from "./workspace-picker.js";
|
||||
import { startDemoGuards, stopDemoGuards } from "./demo-guards.js";
|
||||
|
||||
class IgnisBridgePlugin extends Plugin {
|
||||
async onload() {
|
||||
@@ -67,4 +65,4 @@ class IgnisBridgePlugin extends Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = IgnisBridgePlugin;
|
||||
export default IgnisBridgePlugin;
|
||||
@@ -34,4 +34,4 @@ function getKnownIds() {
|
||||
return knownIds;
|
||||
}
|
||||
|
||||
module.exports = { refresh, isIgnisPlugin, addId, getKnownIds };
|
||||
export { refresh, isIgnisPlugin, addId, getKnownIds };
|
||||
362
packages/bridge/src/settings/general-tab.js
Normal file
362
packages/bridge/src/settings/general-tab.js
Normal file
@@ -0,0 +1,362 @@
|
||||
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_API_LATEST =
|
||||
"https://api.github.com/repos/Nystik-gh/ignis/releases/latest";
|
||||
|
||||
function getVersion() {
|
||||
return window.__ignis?.version || "unknown";
|
||||
}
|
||||
|
||||
async function checkForUpdate(currentVersion) {
|
||||
try {
|
||||
const res = await fetch(GITHUB_API_LATEST);
|
||||
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const latest = stripBuildMetadata(data.tag_name?.replace(/^v/, ""));
|
||||
const current = stripBuildMetadata(currentVersion);
|
||||
|
||||
if (isNewer(latest, current)) {
|
||||
return { version: latest, url: data.html_url };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function display(containerEl, app) {
|
||||
const version = getVersion();
|
||||
|
||||
const header = containerEl.createDiv("ignis-header");
|
||||
|
||||
const logo = header.createEl("img", {
|
||||
cls: "ignis-header-logo",
|
||||
attr: { src: "/assets/ignis.webp", alt: "Ignis" },
|
||||
});
|
||||
|
||||
const info = header.createDiv("ignis-header-info");
|
||||
info.createEl("div", { text: "Ignis", cls: "ignis-header-title" });
|
||||
info.createEl("div", {
|
||||
text: "Obsidian server bridge",
|
||||
cls: "ignis-header-subtitle",
|
||||
});
|
||||
|
||||
const right = header.createDiv("ignis-header-right");
|
||||
|
||||
const versionCol = right.createDiv("ignis-header-version-col");
|
||||
versionCol.createEl("span", {
|
||||
text: `Version ${version}`,
|
||||
cls: "ignis-header-version",
|
||||
});
|
||||
|
||||
const updateIndicator = versionCol.createEl("a", {
|
||||
text: "Checking...",
|
||||
cls: "ignis-update-indicator",
|
||||
attr: { target: "_blank", rel: "noopener noreferrer" },
|
||||
});
|
||||
|
||||
const githubLink = right.createEl("a", {
|
||||
cls: "ignis-github-link",
|
||||
href: GITHUB_URL,
|
||||
attr: { target: "_blank", "aria-label": "GitHub" },
|
||||
});
|
||||
|
||||
const githubIcon = githubLink.createEl("img", {
|
||||
cls: "ignis-github-icon",
|
||||
attr: { src: "/assets/github.svg", alt: "GitHub" },
|
||||
});
|
||||
|
||||
checkForUpdate(version).then((latest) => {
|
||||
if (latest) {
|
||||
updateIndicator.textContent = `v${latest.version} available`;
|
||||
updateIndicator.addClass("ignis-update-available");
|
||||
updateIndicator.href = latest.url;
|
||||
} else {
|
||||
updateIndicator.textContent = "Up to date";
|
||||
}
|
||||
});
|
||||
|
||||
addServerStatus(containerEl);
|
||||
addServerSettings(containerEl, app);
|
||||
}
|
||||
|
||||
const STATUS_LABELS = {
|
||||
open: "Connected",
|
||||
connecting: "Connecting...",
|
||||
closed: "Disconnected",
|
||||
};
|
||||
|
||||
const STATUS_DOT_CLASSES = {
|
||||
open: "ignis-status-connected",
|
||||
connecting: "ignis-status-connecting",
|
||||
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) {
|
||||
const ws = window.__ignis.ws;
|
||||
|
||||
const items = createSettingGroup(containerEl);
|
||||
|
||||
const setting = new Setting(items).setName("Server status");
|
||||
|
||||
const dotEl = setting.controlEl.createEl("span", {
|
||||
cls: "ignis-status-dot",
|
||||
});
|
||||
|
||||
const labelEl = setting.controlEl.createEl("span", {
|
||||
cls: "ignis-status-label",
|
||||
});
|
||||
|
||||
function render(state) {
|
||||
dotEl.className = `ignis-status-dot ${STATUS_DOT_CLASSES[state] || STATUS_DOT_CLASSES.closed}`;
|
||||
labelEl.textContent = STATUS_LABELS[state] || STATUS_LABELS.closed;
|
||||
}
|
||||
|
||||
render(ws.isOpen() ? "open" : "closed");
|
||||
|
||||
const unsub = ws.onStateChange(render);
|
||||
|
||||
// Detach when the settings tab DOM goes away.
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!containerEl.isConnected) {
|
||||
unsub();
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(containerEl.parentElement || document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
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,13 +1,14 @@
|
||||
const generalTab = require("./general-tab");
|
||||
const serverPluginsTab = require("./server-plugins-tab");
|
||||
const { createNavEl, createTab, createGroup } = require("./settings-ui");
|
||||
const {
|
||||
import * as generalTab from "./general-tab.js";
|
||||
import * as serverPluginsTab from "./server-plugins-tab.js";
|
||||
import { createNavEl, createTab, createGroup } from "./settings-ui.js";
|
||||
import {
|
||||
allIgnisNavEls,
|
||||
setupPluginTabs,
|
||||
reconcilePluginTabs,
|
||||
hideIgnisFromCommunityPlugins,
|
||||
restoreCommunityPlugins,
|
||||
clearOwnedPluginIds,
|
||||
} = require("./plugin-tabs");
|
||||
} from "./plugin-tabs.js";
|
||||
|
||||
function removeExistingIgnisGroups(tabHeadersEl) {
|
||||
const groups = tabHeadersEl.querySelectorAll(".vertical-tab-header-group");
|
||||
@@ -24,10 +25,6 @@ function removeExistingIgnisGroups(tabHeadersEl) {
|
||||
}
|
||||
}
|
||||
|
||||
// All ignis-managed nav elements (both Ignis group and Ignis Core Plugins group).
|
||||
// Collected here so the openTab patch can manage is-active across all of them.
|
||||
const allIgnisNavEls = new Map(); // tab id -> nav element
|
||||
|
||||
function replaceInstallerVersionRow(setting, ignisVersion) {
|
||||
const container = setting.tabContentContainer || setting.contentEl;
|
||||
|
||||
@@ -117,7 +114,7 @@ function injectIgnisSettings(setting, app, plugin) {
|
||||
setting.tabHeadersEl.appendChild(corePlugins.group);
|
||||
|
||||
hideIgnisFromCommunityPlugins(setting);
|
||||
setupPluginTabs(setting, corePlugins.items, allIgnisNavEls);
|
||||
setupPluginTabs(setting, corePlugins.items);
|
||||
}
|
||||
|
||||
function patchSettingsModal(plugin) {
|
||||
@@ -142,7 +139,4 @@ function unpatchSettingsModal(plugin) {
|
||||
clearOwnedPluginIds();
|
||||
}
|
||||
|
||||
window.__ignisReconcilePluginTabs = (setting) =>
|
||||
reconcilePluginTabs(setting, allIgnisNavEls);
|
||||
|
||||
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,11 +1,15 @@
|
||||
const { setIcon } = require("obsidian");
|
||||
const { findGroupByTitle } = require("./settings-ui");
|
||||
const { isIgnisPlugin } = require("../plugin-registry");
|
||||
import { setIcon } from "obsidian";
|
||||
import { findGroupByTitle } from "./settings-ui.js";
|
||||
import { isIgnisPlugin } from "../plugin-registry.js";
|
||||
|
||||
// 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.
|
||||
const allIgnisNavEls = new Map(); // tab id -> nav element
|
||||
|
||||
// Tracks which plugin IDs have nav items we created.
|
||||
const ownedPluginIds = new Set();
|
||||
|
||||
function addPluginNavItem(pluginId, setting, corePluginsItems, ignisNavEls) {
|
||||
function addPluginNavItem(pluginId, setting, corePluginsItems) {
|
||||
const tab = setting.pluginTabs.find((t) => t.id === pluginId);
|
||||
|
||||
if (!tab) {
|
||||
@@ -41,16 +45,16 @@ function addPluginNavItem(pluginId, setting, corePluginsItems, ignisNavEls) {
|
||||
|
||||
corePluginsItems.appendChild(nav);
|
||||
ownedPluginIds.add(pluginId);
|
||||
ignisNavEls.set(pluginId, nav);
|
||||
allIgnisNavEls.set(pluginId, nav);
|
||||
}
|
||||
|
||||
function removePluginNavItem(pluginId, ignisNavEls) {
|
||||
const nav = ignisNavEls.get(pluginId);
|
||||
function removePluginNavItem(pluginId) {
|
||||
const nav = allIgnisNavEls.get(pluginId);
|
||||
|
||||
if (nav && ownedPluginIds.has(pluginId)) {
|
||||
nav.remove();
|
||||
ownedPluginIds.delete(pluginId);
|
||||
ignisNavEls.delete(pluginId);
|
||||
allIgnisNavEls.delete(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,11 +120,11 @@ function hideIgnisNavFromCommunityGroup(setting) {
|
||||
communityGroup.style.display = hasVisible ? "" : "none";
|
||||
}
|
||||
|
||||
function hideCorePluginsGroupIfEmpty(ignisNavEls) {
|
||||
function hideCorePluginsGroupIfEmpty() {
|
||||
let hasConnected = false;
|
||||
|
||||
for (const id of ownedPluginIds) {
|
||||
const nav = ignisNavEls.get(id);
|
||||
const nav = allIgnisNavEls.get(id);
|
||||
|
||||
if (nav?.isConnected) {
|
||||
hasConnected = true;
|
||||
@@ -140,15 +144,15 @@ function hideCorePluginsGroupIfEmpty(ignisNavEls) {
|
||||
}
|
||||
}
|
||||
|
||||
function setupPluginTabs(setting, corePluginsItems, ignisNavEls) {
|
||||
function setupPluginTabs(setting, corePluginsItems) {
|
||||
for (const tab of setting.pluginTabs) {
|
||||
if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") {
|
||||
addPluginNavItem(tab.id, setting, corePluginsItems, ignisNavEls);
|
||||
addPluginNavItem(tab.id, setting, corePluginsItems);
|
||||
}
|
||||
}
|
||||
|
||||
hideIgnisNavFromCommunityGroup(setting);
|
||||
hideCorePluginsGroupIfEmpty(ignisNavEls);
|
||||
hideCorePluginsGroupIfEmpty();
|
||||
|
||||
const communityGroup = findGroupByTitle(
|
||||
setting.tabHeadersEl,
|
||||
@@ -159,12 +163,12 @@ function setupPluginTabs(setting, corePluginsItems, ignisNavEls) {
|
||||
const observer = new MutationObserver(() => {
|
||||
for (const tab of setting.pluginTabs) {
|
||||
if (isIgnisPlugin(tab.id) && tab.id !== "ignis-bridge") {
|
||||
addPluginNavItem(tab.id, setting, corePluginsItems, ignisNavEls);
|
||||
addPluginNavItem(tab.id, setting, corePluginsItems);
|
||||
}
|
||||
}
|
||||
|
||||
hideIgnisNavFromCommunityGroup(setting);
|
||||
hideCorePluginsGroupIfEmpty(ignisNavEls);
|
||||
hideCorePluginsGroupIfEmpty();
|
||||
});
|
||||
|
||||
observer.observe(communityGroup, { childList: true, subtree: true });
|
||||
@@ -186,7 +190,7 @@ function setupPluginTabs(setting, corePluginsItems, ignisNavEls) {
|
||||
}
|
||||
}
|
||||
|
||||
function reconcilePluginTabs(setting, ignisNavEls) {
|
||||
function reconcilePluginTabs(setting) {
|
||||
const corePluginsGroup = findGroupByTitle(
|
||||
setting.tabHeadersEl,
|
||||
"Ignis Core Plugins",
|
||||
@@ -212,23 +216,24 @@ function reconcilePluginTabs(setting, ignisNavEls) {
|
||||
|
||||
for (const id of ownedPluginIds) {
|
||||
if (!activeIds.has(id)) {
|
||||
removePluginNavItem(id, ignisNavEls);
|
||||
removePluginNavItem(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of activeIds) {
|
||||
addPluginNavItem(id, setting, corePluginsItems, ignisNavEls);
|
||||
addPluginNavItem(id, setting, corePluginsItems);
|
||||
}
|
||||
|
||||
hideIgnisNavFromCommunityGroup(setting);
|
||||
hideCorePluginsGroupIfEmpty(ignisNavEls);
|
||||
hideCorePluginsGroupIfEmpty();
|
||||
}
|
||||
|
||||
function clearOwnedPluginIds() {
|
||||
ownedPluginIds.clear();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
export {
|
||||
allIgnisNavEls,
|
||||
setupPluginTabs,
|
||||
reconcilePluginTabs,
|
||||
hideIgnisFromCommunityPlugins,
|
||||
@@ -1,18 +1,10 @@
|
||||
const { Setting, Notice } = require("obsidian");
|
||||
import { Setting, Notice } from "obsidian";
|
||||
import { reconcilePluginTabs } from "./plugin-tabs.js";
|
||||
|
||||
function getVaultId() {
|
||||
return window.__currentVaultId || "";
|
||||
}
|
||||
|
||||
async function refreshPluginCache(bundledPluginId) {
|
||||
const pluginPath = `.obsidian/plugins/${bundledPluginId}`;
|
||||
const fs = require("fs");
|
||||
|
||||
if (fs._refreshSubtree) {
|
||||
await fs._refreshSubtree(pluginPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPlugins() {
|
||||
const res = await fetch("/api/plugins");
|
||||
|
||||
@@ -23,7 +15,7 @@ async function fetchPlugins() {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function togglePlugin(pluginId, enable, app) {
|
||||
async function togglePlugin(pluginId, enable) {
|
||||
const action = enable ? "enable" : "disable";
|
||||
const vaultId = getVaultId();
|
||||
|
||||
@@ -41,25 +33,10 @@ async function togglePlugin(pluginId, enable, app) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function activateBundledPlugin(bundledPluginId, enable, app) {
|
||||
if (!bundledPluginId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const plugins = app.plugins;
|
||||
|
||||
if (enable) {
|
||||
await plugins.loadManifests();
|
||||
await plugins.enablePluginAndSave(bundledPluginId);
|
||||
} else {
|
||||
await plugins.disablePluginAndSave(bundledPluginId);
|
||||
}
|
||||
}
|
||||
|
||||
function display(containerEl, app) {
|
||||
containerEl.createEl("h2", { text: "Ignis Core Plugins" });
|
||||
|
||||
const descEl = containerEl.createEl("p", {
|
||||
containerEl.createEl("p", {
|
||||
text:
|
||||
"Ignis plugins extend server functionality and run alongside your vaults. " +
|
||||
"They are separate from Obsidian's built-in plugins.",
|
||||
@@ -92,28 +69,16 @@ function display(containerEl, app) {
|
||||
toggle.setValue(enabled);
|
||||
toggle.onChange(async (value) => {
|
||||
try {
|
||||
await togglePlugin(plugin.id, value, app);
|
||||
|
||||
if (value && plugin.bundledPluginId) {
|
||||
await refreshPluginCache(plugin.bundledPluginId);
|
||||
}
|
||||
|
||||
await activateBundledPlugin(
|
||||
plugin.bundledPluginId,
|
||||
value,
|
||||
app,
|
||||
);
|
||||
await togglePlugin(plugin.id, value);
|
||||
|
||||
new Notice(
|
||||
`${plugin.name} ${value ? "enabled" : "disabled"} for this vault.`,
|
||||
);
|
||||
|
||||
// Give Obsidian a moment to update its plugin tabs,
|
||||
// then reconcile our sidebar groups.
|
||||
// The server's WS broadcast drives the actual load/unload via virtual-plugin-loader.
|
||||
// Reconcile the settings sidebar so the new plugin's settings tab gets grouped correctly.
|
||||
setTimeout(() => {
|
||||
if (typeof window.__ignisReconcilePluginTabs === "function") {
|
||||
window.__ignisReconcilePluginTabs(app.setting);
|
||||
}
|
||||
reconcilePluginTabs(app.setting);
|
||||
}, 100);
|
||||
} catch (e) {
|
||||
new Notice(`Failed: ${e.message}`);
|
||||
@@ -129,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) {
|
||||
const nav = document.createElement("div");
|
||||
@@ -86,4 +86,4 @@ function findGroupByTitle(tabHeadersEl, title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { createNavEl, createTab, createGroup, findGroupByTitle };
|
||||
export { createNavEl, createTab, createGroup, findGroupByTitle };
|
||||
35
packages/bridge/src/status-bar.js
Normal file
35
packages/bridge/src/status-bar.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const STATUS_LABELS = {
|
||||
open: "Ignis server: Connected",
|
||||
connecting: "Ignis server: Connecting...",
|
||||
closed: "Ignis server: Disconnected",
|
||||
};
|
||||
|
||||
const STATUS_DOT_CLASSES = {
|
||||
open: "ignis-statusbar-connected",
|
||||
connecting: "ignis-statusbar-connecting",
|
||||
closed: "ignis-statusbar-disconnected",
|
||||
};
|
||||
|
||||
function initStatusBar(plugin) {
|
||||
const ws = window.__ignis.ws;
|
||||
|
||||
const item = plugin.addStatusBarItem();
|
||||
item.addClass("ignis-statusbar-item");
|
||||
|
||||
const dot = item.createEl("span", {
|
||||
cls: "ignis-statusbar-dot",
|
||||
});
|
||||
|
||||
item.setAttribute("data-tooltip-position", "top");
|
||||
|
||||
function render(state) {
|
||||
dot.className = `ignis-statusbar-dot ${STATUS_DOT_CLASSES[state] || STATUS_DOT_CLASSES.closed}`;
|
||||
item.setAttribute("aria-label", STATUS_LABELS[state] || STATUS_LABELS.closed);
|
||||
}
|
||||
|
||||
render(ws.isOpen() ? "open" : "closed");
|
||||
|
||||
return ws.onStateChange(render);
|
||||
}
|
||||
|
||||
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 {
|
||||
constructor(app) {
|
||||
@@ -29,4 +29,4 @@ class WorkspacePickerModal extends FuzzySuggestModal {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { WorkspacePickerModal };
|
||||
export { WorkspacePickerModal };
|
||||
@@ -54,12 +54,17 @@
|
||||
.ignis-update-indicator {
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-faint);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ignis-update-indicator.ignis-update-available {
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.ignis-update-indicator.ignis-update-available:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ignis-github-link {
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
@@ -136,3 +141,18 @@
|
||||
font-size: var(--font-ui-small);
|
||||
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;
|
||||
}
|
||||
10
packages/server-core/package.json
Normal file
10
packages/server-core/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@ignis/server-core",
|
||||
"version": "0.0.0-internal",
|
||||
"private": true,
|
||||
"main": "src/index.js",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.6.0",
|
||||
"ws": "^8.16.0"
|
||||
}
|
||||
}
|
||||
15
packages/server-core/src/index.js
Normal file
15
packages/server-core/src/index.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const writeCoalescer = require("./write-coalescer");
|
||||
const watcher = require("./watcher");
|
||||
const { setupWebSocket } = require("./ws");
|
||||
const {
|
||||
encodeContentDispositionFilename,
|
||||
resolveVaultPath,
|
||||
} = require("./path-utils");
|
||||
|
||||
module.exports = {
|
||||
writeCoalescer,
|
||||
watcher,
|
||||
setupWebSocket,
|
||||
encodeContentDispositionFilename,
|
||||
resolveVaultPath,
|
||||
};
|
||||
69
packages/server-core/src/path-utils.js
Normal file
69
packages/server-core/src/path-utils.js
Normal file
@@ -0,0 +1,69 @@
|
||||
const path = require("path");
|
||||
|
||||
/**
|
||||
* Encode a filename for use in Content-Disposition header.
|
||||
* Handles non-ASCII characters and special characters to prevent header injection.
|
||||
* Uses RFC 5987 encoding for filename* parameter when needed.
|
||||
*/
|
||||
function encodeContentDispositionFilename(filename) {
|
||||
const hasNonASCII = /[^\x00-\x7F]/.test(filename);
|
||||
|
||||
// Escape quotes and backslashes in ASCII filename
|
||||
const escapedFilename = filename.replace(/["\\ ]/g, function (match) {
|
||||
if (match === '"') return '\\"';
|
||||
if (match === "\\") return "\\\\";
|
||||
return match;
|
||||
});
|
||||
|
||||
// Remove any control characters that could cause header injection
|
||||
const sanitizedFilename = escapedFilename.replace(/[\x00-\x1F\x7F]/g, "");
|
||||
|
||||
if (!hasNonASCII) {
|
||||
// Simple ASCII filename - use standard format
|
||||
return `attachment; filename="${sanitizedFilename}"`;
|
||||
}
|
||||
|
||||
// Non-ASCII filename - use RFC 5987 encoding
|
||||
// Encode using percent-encoding for UTF-8
|
||||
const encodedFilename = encodeURIComponent(filename)
|
||||
.replace(/['()]/g, function (c) {
|
||||
return "%" + c.charCodeAt(0).toString(16).toUpperCase();
|
||||
})
|
||||
.replace(/\*/g, "%2A");
|
||||
|
||||
// Provide both filename (ASCII fallback) and filename* (UTF-8 encoded)
|
||||
// For fallback, replace non-ASCII with underscores
|
||||
const asciiFallback = filename
|
||||
.replace(/[^\x00-\x7F]/g, "_")
|
||||
.replace(/["\\ ]/g, function (match) {
|
||||
if (match === '"') return '\\"';
|
||||
if (match === "\\") return "\\\\";
|
||||
return match;
|
||||
});
|
||||
|
||||
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encodedFilename}`;
|
||||
}
|
||||
|
||||
// Resolve a client-provided path to an absolute path within a vault.
|
||||
// Strips leading slashes so paths from the client are always treated as relative to the vault root.
|
||||
// Rejects nullish input so missing-field bugs in callers don't silently target the vault root.
|
||||
function resolveVaultPath(vaultRoot, relativePath) {
|
||||
if (relativePath === null || relativePath === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleaned = relativePath.replace(/^\/+/, "");
|
||||
const resolved = path.resolve(vaultRoot, cleaned);
|
||||
|
||||
const resolvedRoot = path.resolve(vaultRoot);
|
||||
|
||||
if (
|
||||
resolved !== resolvedRoot &&
|
||||
!resolved.startsWith(resolvedRoot + path.sep)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
module.exports = { encodeContentDispositionFilename, resolveVaultPath };
|
||||
@@ -5,10 +5,18 @@
|
||||
// Buffered writes respond to the HTTP client right away with synthetic mtime/size. Otherwise the browser's per-host connection cap blocks unrelated reads while writes sit in the buffer.
|
||||
|
||||
const fs = require("fs");
|
||||
const config = require("./config");
|
||||
|
||||
const FLUSH_TIMEOUT_MS = 10000;
|
||||
|
||||
// Coalesce window in ms. 0 disables coalescing. Set via configure({ writeCoalesceMs }).
|
||||
let writeCoalesceMs = 0;
|
||||
|
||||
function configure(opts) {
|
||||
if (typeof opts?.writeCoalesceMs === "number") {
|
||||
writeCoalesceMs = opts.writeCoalesceMs;
|
||||
}
|
||||
}
|
||||
|
||||
// absPath -> timestamp of last completed (or scheduled) write
|
||||
const lastWriteTime = new Map();
|
||||
|
||||
@@ -23,9 +31,19 @@ async function writeToDisk(absPath, data, encoding) {
|
||||
);
|
||||
|
||||
lastWriteTime.set(absPath, Date.now());
|
||||
const stat = await fs.promises.stat(absPath);
|
||||
|
||||
return { mtime: stat.mtimeMs, size: stat.size };
|
||||
// A concurrent delete can remove the file between the write and the stat (a rapid write-then-delete on the same path).
|
||||
// The write itself succeeds, so report synthetic metadata rather than failing the request on the now-missing file.
|
||||
try {
|
||||
const stat = await fs.promises.stat(absPath);
|
||||
return { mtime: stat.mtimeMs, size: stat.size };
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") {
|
||||
return { mtime: Date.now(), size: estimateSize(data, encoding) };
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function flushEntry(absPath) {
|
||||
@@ -51,7 +69,7 @@ function scheduleFlush(absPath) {
|
||||
}
|
||||
|
||||
clearTimeout(entry.timer);
|
||||
entry.timer = setTimeout(() => flushEntry(absPath), config.writeCoalesceMs);
|
||||
entry.timer = setTimeout(() => flushEntry(absPath), writeCoalesceMs);
|
||||
}
|
||||
|
||||
function estimateSize(data, encoding) {
|
||||
@@ -67,7 +85,7 @@ function estimateSize(data, encoding) {
|
||||
* Fresh writes resolve with real mtime/size once data is on disk. Buffered writes resolve immediately with synthetic values; the disk flush happens later when the debounce timer fires.
|
||||
*/
|
||||
async function writeCoalesced(absPath, data, encoding) {
|
||||
const windowMs = config.writeCoalesceMs;
|
||||
const windowMs = writeCoalesceMs;
|
||||
const last = lastWriteTime.get(absPath);
|
||||
|
||||
// Fast path: coalescing disabled or far enough from the last write.
|
||||
@@ -140,9 +158,7 @@ async function flushAll() {
|
||||
|
||||
const timeout = new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.warn(
|
||||
"[write-coalesce] Flush timeout -- some writes may be lost",
|
||||
);
|
||||
console.warn("[write-coalesce] Flush timeout. Some writes may be lost");
|
||||
resolve();
|
||||
}, FLUSH_TIMEOUT_MS);
|
||||
});
|
||||
@@ -159,4 +175,4 @@ function _reset() {
|
||||
lastWriteTime.clear();
|
||||
}
|
||||
|
||||
module.exports = { writeCoalesced, getPending, flushAll, _reset };
|
||||
module.exports = { writeCoalesced, getPending, flushAll, configure, _reset };
|
||||
@@ -6,15 +6,13 @@ import os from "os";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const coalescer = require("./write-coalescer.js");
|
||||
const config = require("./config.js");
|
||||
|
||||
const SHORT_WINDOW_MS = 50;
|
||||
const originalWindow = config.writeCoalesceMs;
|
||||
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(async () => {
|
||||
config.writeCoalesceMs = SHORT_WINDOW_MS;
|
||||
coalescer.configure({ writeCoalesceMs: SHORT_WINDOW_MS });
|
||||
coalescer._reset();
|
||||
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "coalesce-test-"));
|
||||
});
|
||||
@@ -22,7 +20,7 @@ beforeEach(async () => {
|
||||
afterEach(async () => {
|
||||
coalescer._reset();
|
||||
vi.restoreAllMocks();
|
||||
config.writeCoalesceMs = originalWindow;
|
||||
coalescer.configure({ writeCoalesceMs: 0 });
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -101,6 +99,18 @@ describe("writeCoalesced", () => {
|
||||
|
||||
expect(elapsed).toBeLessThan(20);
|
||||
});
|
||||
|
||||
it("returns synthetic metadata when the file is deleted before the post-write stat", async () => {
|
||||
const filePath = path.join(tmpDir, "race.txt");
|
||||
vi.spyOn(fs.promises, "stat").mockRejectedValueOnce(
|
||||
Object.assign(new Error("ENOENT"), { code: "ENOENT" }),
|
||||
);
|
||||
|
||||
const result = await coalescer.writeCoalesced(filePath, "hello", "utf-8");
|
||||
|
||||
expect(result.size).toBe(5);
|
||||
expect(result.mtime).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPending", () => {
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user