mirror of
https://github.com/Nystik-gh/ignis.git
synced 2026-06-17 04:35:53 +00:00
move server into apps/ignis-server
This commit is contained in:
53
apps/ignis-server/Dockerfile
Normal file
53
apps/ignis-server/Dockerfile
Normal file
@@ -0,0 +1,53 @@
|
||||
# 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"]
|
||||
15
apps/ignis-server/docker-compose.yml
Normal file
15
apps/ignis-server/docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
ignis:
|
||||
build: .
|
||||
ports:
|
||||
- "8082:8080"
|
||||
environment:
|
||||
- OBSIDIAN_VERSION=1.12.7
|
||||
volumes:
|
||||
- ./vaults:/vaults
|
||||
- ./data:/app/data
|
||||
- obsidian-app:/app/obsidian-app
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
obsidian-app:
|
||||
139
apps/ignis-server/examples/INSTRUCTIONS.md
Normal file
139
apps/ignis-server/examples/INSTRUCTIONS.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Deploying Ignis with Authentication
|
||||
|
||||
Ignis has no built-in authentication. These examples provide ready-to-use Docker Compose setups that put an authentication layer in front of Ignis using [Caddy](https://caddyserver.com/) as a reverse proxy.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- A domain name pointing to your server (or a local DNS setup)
|
||||
- Ports 80 and 443 available
|
||||
|
||||
## Choose Your Setup
|
||||
|
||||
| Setup | Complexity | Features |
|
||||
| ----- | ---------- | -------- |
|
||||
| [Caddy + Basic Auth](#caddy--basic-auth) | Minimal | Username/password prompt on every new browser session |
|
||||
| [Caddy + Authelia](#caddy--authelia) | Low | Login page, sessions, optional 2FA, multi-user support |
|
||||
|
||||
Basic auth is fine if you just need a password gate for yourself. Authelia is better if you want a proper login page, persistent sessions, or might add more users later.
|
||||
|
||||
---
|
||||
|
||||
## Caddy + Basic Auth
|
||||
|
||||
The simplest option. Caddy prompts for a username and password before allowing access.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Copy the `caddy-basic-auth/` folder to wherever you want to run Ignis.
|
||||
|
||||
2. Generate a password hash:
|
||||
```bash
|
||||
docker run --rm caddy:2 caddy hash-password --plaintext YOUR_PASSWORD
|
||||
```
|
||||
|
||||
3. Edit `Caddyfile`:
|
||||
- Replace `ignis.example.com` with your domain.
|
||||
- Replace `$2a$14$REPLACE_THIS_WITH_YOUR_BCRYPT_HASH` with the hash from step 2.
|
||||
- Optionally change the username `admin` to something else.
|
||||
|
||||
4. Start it:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Caddy will automatically obtain a TLS certificate from Let's Encrypt for your domain.
|
||||
|
||||
---
|
||||
|
||||
## Caddy + Authelia
|
||||
|
||||
A more robust setup with a dedicated login page, session cookies, and optional two-factor authentication.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Copy the `caddy-authelia/` folder to wherever you want to run Ignis.
|
||||
|
||||
2. Generate two random secrets (used for signing tokens and encrypting the database):
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
Run this twice. You need two different values.
|
||||
|
||||
3. Edit `authelia/configuration.yml`:
|
||||
- Replace both `REPLACE_WITH_A_RANDOM_SECRET` and `REPLACE_WITH_ANOTHER_RANDOM_SECRET` with the secrets from step 2.
|
||||
- Replace `example.com` with your root domain (e.g. `mydomain.com`).
|
||||
- Replace `auth.example.com` with your auth subdomain (e.g. `auth.mydomain.com`).
|
||||
|
||||
4. Generate a password hash for your user:
|
||||
```bash
|
||||
docker run --rm authelia/authelia:latest authelia crypto hash generate argon2 --password YOUR_PASSWORD
|
||||
```
|
||||
|
||||
5. Edit `authelia/users_database.yml`:
|
||||
- Replace the placeholder hash with the output from step 4.
|
||||
- Optionally change the username, display name, and email.
|
||||
|
||||
6. Edit `Caddyfile`:
|
||||
- Replace `auth.example.com` with your auth subdomain.
|
||||
- Replace `ignis.example.com` with your Ignis domain.
|
||||
|
||||
7. Start it:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Adding more users
|
||||
|
||||
Add entries to `authelia/users_database.yml` following the same format as the existing user. Each user needs a unique username, email, and password hash. Restart Authelia after editing:
|
||||
```bash
|
||||
docker compose restart authelia
|
||||
```
|
||||
|
||||
### Enabling two-factor authentication
|
||||
|
||||
Authelia supports TOTP (authenticator apps like Google Authenticator, Authy, etc.) out of the box. To require 2FA, change the access control policy in `authelia/configuration.yml`:
|
||||
|
||||
```yaml
|
||||
access_control:
|
||||
default_policy: two_factor
|
||||
```
|
||||
|
||||
After restarting, users will be prompted to register a TOTP device on their next login.
|
||||
|
||||
### Password reset
|
||||
|
||||
The default configuration uses a filesystem notifier, which writes password reset links to a file inside the container instead of emailing them. To check for reset links:
|
||||
```bash
|
||||
docker compose exec authelia cat /data/notification.txt
|
||||
```
|
||||
|
||||
For production use, replace the `notifier` section in `configuration.yml` with your SMTP server details. See the [Authelia notifier docs](https://www.authelia.com/configuration/notifications/smtp/).
|
||||
|
||||
---
|
||||
|
||||
## Common Notes
|
||||
|
||||
### DNS
|
||||
|
||||
Both examples require DNS records pointing to your server:
|
||||
- For basic auth: one A/CNAME record for your Ignis domain.
|
||||
- For Authelia: two A/CNAME records, one for Ignis and one for the auth subdomain.
|
||||
|
||||
### HTTPS
|
||||
|
||||
Caddy handles TLS automatically via Let's Encrypt. For this to work, your domain must be publicly resolvable and ports 80/443 must be reachable from the internet (Let's Encrypt needs to verify domain ownership).
|
||||
|
||||
If you're running on a local network without public DNS, you can use Caddy's [internal TLS](https://caddyserver.com/docs/caddyfile/directives/tls#internal) to generate self-signed certificates. Add `tls internal` inside each site block in the Caddyfile.
|
||||
|
||||
### Vault data
|
||||
|
||||
Both examples store vault data in a `vaults/` directory and Ignis state in a `data/` directory next to the compose file. These are bind mounts, so your data lives on the host filesystem and persists across container restarts.
|
||||
|
||||
### Building from source
|
||||
|
||||
If you're building Ignis from source instead of using the published image, edit `docker-compose.yml` and swap `image: nobbe/ignis:latest` for `build: ../../` (assuming you're running from the cloned repo's `examples/` folder).
|
||||
|
||||
### Alternative: Cloudflare Tunnel
|
||||
|
||||
If you don't want to expose ports 80/443, [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) can route traffic to your server without opening any inbound ports. Cloudflare Access can also provide authentication. This is a different approach entirely and not covered by these examples, but it's worth considering if you already use Cloudflare.
|
||||
14
apps/ignis-server/examples/caddy-authelia/Caddyfile
Normal file
14
apps/ignis-server/examples/caddy-authelia/Caddyfile
Normal file
@@ -0,0 +1,14 @@
|
||||
# Authelia portal. Replace auth.example.com with your auth subdomain.
|
||||
auth.example.com {
|
||||
reverse_proxy authelia:9091
|
||||
}
|
||||
|
||||
# Ignis. Replace ignis.example.com with your domain.
|
||||
ignis.example.com {
|
||||
forward_auth authelia:9091 {
|
||||
uri /api/authz/forward-auth
|
||||
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
|
||||
}
|
||||
|
||||
reverse_proxy ignis:8080
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# Authelia minimal configuration for Ignis.
|
||||
# See https://www.authelia.com/configuration/prologue/introduction/ for full docs.
|
||||
|
||||
# -- Replace these with random strings (e.g. openssl rand -hex 32) --
|
||||
identity_validation:
|
||||
reset_password:
|
||||
jwt_secret: REPLACE_WITH_A_RANDOM_SECRET
|
||||
|
||||
server:
|
||||
address: tcp://0.0.0.0:9091
|
||||
|
||||
log:
|
||||
level: info
|
||||
|
||||
authentication_backend:
|
||||
file:
|
||||
path: /config/users_database.yml
|
||||
|
||||
session:
|
||||
cookies:
|
||||
- domain: example.com # Replace with your root domain
|
||||
authelia_url: https://auth.example.com
|
||||
|
||||
storage:
|
||||
encryption_key: REPLACE_WITH_ANOTHER_RANDOM_SECRET
|
||||
local:
|
||||
path: /data/db.sqlite3
|
||||
|
||||
notifier:
|
||||
# For production, replace this with an SMTP block.
|
||||
# The filesystem notifier writes password reset links to a file instead of emailing them.
|
||||
filesystem:
|
||||
filename: /data/notification.txt
|
||||
|
||||
access_control:
|
||||
default_policy: one_factor
|
||||
@@ -0,0 +1,13 @@
|
||||
# Authelia user database.
|
||||
#
|
||||
# To generate a password hash, run:
|
||||
# docker run --rm authelia/authelia:latest authelia crypto hash generate argon2 --password YOUR_PASSWORD
|
||||
#
|
||||
# Then paste the output as the `password` value below.
|
||||
|
||||
users:
|
||||
admin:
|
||||
disabled: false
|
||||
displayname: Admin
|
||||
email: admin@example.com
|
||||
password: "$argon2id$v=19$m=65536,t=3,p=4$REPLACE_THIS_WITH_YOUR_HASH"
|
||||
45
apps/ignis-server/examples/caddy-authelia/docker-compose.yml
Normal file
45
apps/ignis-server/examples/caddy-authelia/docker-compose.yml
Normal file
@@ -0,0 +1,45 @@
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
networks:
|
||||
- ignis
|
||||
restart: unless-stopped
|
||||
|
||||
authelia:
|
||||
image: authelia/authelia:latest
|
||||
volumes:
|
||||
- ./authelia:/config:ro
|
||||
- authelia_data:/data
|
||||
networks:
|
||||
- ignis
|
||||
restart: unless-stopped
|
||||
|
||||
ignis:
|
||||
image: nobbe/ignis:latest
|
||||
# To build from source instead, comment out `image` and uncomment:
|
||||
# build: ../../
|
||||
environment:
|
||||
- OBSIDIAN_VERSION=1.12.7
|
||||
volumes:
|
||||
- ./vaults:/vaults
|
||||
- ./data:/app/data
|
||||
- obsidian-app:/app/obsidian-app
|
||||
networks:
|
||||
- ignis
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
ignis:
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
authelia_data:
|
||||
obsidian-app:
|
||||
11
apps/ignis-server/examples/caddy-basic-auth/Caddyfile
Normal file
11
apps/ignis-server/examples/caddy-basic-auth/Caddyfile
Normal file
@@ -0,0 +1,11 @@
|
||||
# Replace with your domain, or use :443 for local access with a self-signed cert.
|
||||
ignis.example.com {
|
||||
basicauth {
|
||||
# Username: admin
|
||||
# Replace the hash below with your own. Generate one with:
|
||||
# docker run --rm caddy:2 caddy hash-password --plaintext YOUR_PASSWORD
|
||||
admin $2a$14$REPLACE_THIS_WITH_YOUR_BCRYPT_HASH
|
||||
}
|
||||
|
||||
reverse_proxy ignis:8080
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
networks:
|
||||
- ignis
|
||||
restart: unless-stopped
|
||||
|
||||
ignis:
|
||||
image: nobbe/ignis:latest
|
||||
# To build from source instead, comment out `image` and uncomment:
|
||||
# build: ../../
|
||||
environment:
|
||||
- OBSIDIAN_VERSION=1.12.7
|
||||
volumes:
|
||||
- ./vaults:/vaults
|
||||
- ./data:/app/data
|
||||
- obsidian-app:/app/obsidian-app
|
||||
networks:
|
||||
- ignis
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
ignis:
|
||||
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
obsidian-app:
|
||||
37
apps/ignis-server/examples/demo/README.md
Normal file
37
apps/ignis-server/examples/demo/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Public demo deployment
|
||||
|
||||
This example runs Ignis as a public, no-auth demo where anyone with the URL can spin up a transient vault and try the editor. The live demo at <https://ignis-demo.thiefling.com> uses this configuration.
|
||||
|
||||
Demo mode changes the security and lifecycle model:
|
||||
|
||||
- **Per-session vaults.** Each visitor gets their own isolated set of vaults, tracked by a session cookie. Sessions don't share storage.
|
||||
- **Transient files.** Vaults live on tmpfs in the example compose file, so everything is wiped on container restart and on session expiry.
|
||||
- **Auto-cleanup.** Sessions expire after a period of inactivity; their vaults are removed in-process.
|
||||
- **Capacity caps.** Concurrent sessions, vaults per session, and bytes per session are bounded, so a single visitor can't fill the disk and the host can't be flooded with sessions.
|
||||
- **Proxy allowlist.** The CORS proxy is restricted to a known-safe domain list so the public demo can't be used as an open relay.
|
||||
- **Login blocked.** Obsidian account login is blocked at both the proxy and the UI, so visitors can't accidentally enter credentials into a server they don't control.
|
||||
|
||||
## Running it
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The bundled [`docker-compose.yml`](docker-compose.yml) builds Ignis from the parent directory, mounts a 20 MB tmpfs at `/vaults`, and configures the demo limits. Adjust the env vars below for your own deployment.
|
||||
|
||||
## Demo environment variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| -------- | ----------- | ------- |
|
||||
| `DEMO_MODE` | Enable demo mode (per-session vaults, auto-cleanup, proxy allowlist, login blocking). | `false` |
|
||||
| `DEMO_MAX_SESSIONS` | Concurrent demo session cap. New visitors get a 503 capacity page when full. | `20` |
|
||||
| `DEMO_VAULTS_PER_SESSION` | Max vaults per session (vault create returns 507 past this). | `3` |
|
||||
| `DEMO_SESSION_QUOTA_BYTES` | Cumulative byte budget per session across all session vaults. | `716800` |
|
||||
| `DEMO_TIMEOUT_MS` | Inactivity timeout before a demo session and its vaults are cleaned up. | `1800000` |
|
||||
| `DEMO_TEMPLATE_DIR` | Directory copied into each new demo vault. | `server/demo-template/` |
|
||||
|
||||
The standard Ignis env vars (`PORT`, `VAULT_ROOT`, `OBSIDIAN_VERSION`, etc.) still apply. See the [main README](../../README.md#environment-variables) for those.
|
||||
|
||||
## Custom starter vault
|
||||
|
||||
The bundled `server/demo-template/` is a minimal walkthrough of what Ignis is. To ship a richer starter vault for your own demo without committing it to the repo, mount a directory at `/app/demo-template` and point `DEMO_TEMPLATE_DIR` at it. The compose file has the wiring commented out.
|
||||
38
apps/ignis-server/examples/demo/docker-compose.yml
Normal file
38
apps/ignis-server/examples/demo/docker-compose.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
# Public demo of Ignis.
|
||||
#
|
||||
# - Vaults live on tmpfs (RAM-backed, transient by design)
|
||||
# - 20 MB total tmpfs, 700 KB per session, 3 vaults per session
|
||||
# - 30-minute inactivity timeout, in-process cleanup
|
||||
# - CORS proxy locked down to a known-safe domain allowlist
|
||||
# - Obsidian account login blocked (proxy + UI)
|
||||
|
||||
services:
|
||||
ignis-demo:
|
||||
build:
|
||||
context: ../../../..
|
||||
dockerfile: apps/ignis-server/Dockerfile
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- DEMO_MODE=true
|
||||
- DEMO_MAX_SESSIONS=20
|
||||
- DEMO_VAULTS_PER_SESSION=3
|
||||
- DEMO_SESSION_QUOTA_BYTES=716800 # 700 KB
|
||||
- DEMO_TIMEOUT_MS=1800000 # 30 min
|
||||
# Mount your own template at /app/demo-template to ship a richer
|
||||
# starter vault without committing it to the repo. Defaults to the
|
||||
# bundled server/demo-template/.
|
||||
# - DEMO_TEMPLATE_DIR=/app/demo-template
|
||||
- WRITE_COALESCE_MS=0 # tmpfs doesn't need debouncing
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
tmpfs:
|
||||
- /vaults:size=20m,mode=1700
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- obsidian-app:/app/obsidian-app
|
||||
# - ./my-demo-template:/app/demo-template:ro
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
obsidian-app:
|
||||
71
apps/ignis-server/scripts/entrypoint.sh
Normal file
71
apps/ignis-server/scripts/entrypoint.sh
Normal file
@@ -0,0 +1,71 @@
|
||||
#!/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
|
||||
|
||||
# Fix ownership of volumes
|
||||
chown -R "$PUID:$PGID" /vaults /app/obsidian-app
|
||||
|
||||
OBSIDIAN_DIR="/app/obsidian-app"
|
||||
OBSIDIAN_VERSION="${OBSIDIAN_VERSION:-1.12.7}"
|
||||
|
||||
if [ ! -f "$OBSIDIAN_DIR/index.html" ]; then
|
||||
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
|
||||
|
||||
echo "[ignis] Obsidian v${OBSIDIAN_VERSION} ready."
|
||||
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/server/index.js
|
||||
1
apps/ignis-server/server/assets/github.svg
Normal file
1
apps/ignis-server/server/assets/github.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 98 96" fill="#a0a0a0"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"/></svg>
|
||||
|
After Width: | Height: | Size: 984 B |
BIN
apps/ignis-server/server/assets/ignis.webp
Normal file
BIN
apps/ignis-server/server/assets/ignis.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
90
apps/ignis-server/server/assets/index.html
Normal file
90
apps/ignis-server/server/assets/index.html
Normal file
@@ -0,0 +1,90 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"/>
|
||||
<title>Obsidian</title>
|
||||
<link href="app.css" type="text/css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
<link href="assets/overrides.css" type="text/css" rel="stylesheet"/>
|
||||
<style>
|
||||
#ignis-status {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 18px;
|
||||
background: #202020;
|
||||
color: #b3b3b3;
|
||||
font: 14px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
z-index: 9999;
|
||||
transition: opacity 200ms ease-out;
|
||||
}
|
||||
#ignis-status.fade { opacity: 0; pointer-events: none; }
|
||||
#ignis-status img {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
animation: ignis-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
#ignis-status-label { font-size: 13px; opacity: 0.75; }
|
||||
@keyframes ignis-pulse {
|
||||
0%, 100% { opacity: 0.85; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.04); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="theme-dark">
|
||||
<div id="ignis-status">
|
||||
<img src="favicon.png" alt=""/>
|
||||
<div id="ignis-status-label">Loading Obsidian...</div>
|
||||
</div>
|
||||
<!-- Ignis shims: must run before any Obsidian code. -->
|
||||
<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 () {
|
||||
var scripts = __OBSIDIAN_SCRIPTS__;
|
||||
var label = document.getElementById("ignis-status-label");
|
||||
var status = document.getElementById("ignis-status");
|
||||
var loaded = 0;
|
||||
|
||||
function update() {
|
||||
if (label) {
|
||||
label.textContent = "Loading Obsidian " + loaded + "/" + scripts.length;
|
||||
}
|
||||
}
|
||||
|
||||
function done() {
|
||||
if (!status) return;
|
||||
status.classList.add("fade");
|
||||
setTimeout(function () {
|
||||
if (status && status.parentNode) status.parentNode.removeChild(status);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
10
apps/ignis-server/server/assets/overrides.css
Normal file
10
apps/ignis-server/server/assets/overrides.css
Normal file
@@ -0,0 +1,10 @@
|
||||
/* CSS overrides for browser vs desktop differences. */
|
||||
|
||||
/* Remove right padding for non-existent window controls (minimize/maximize/close). */
|
||||
.is-hidden-frameless:not(.is-fullscreen) .workspace-tabs.mod-top-right-space .workspace-tab-header-container {
|
||||
padding-right: var(--size-4-2) !important;
|
||||
}
|
||||
|
||||
.is-hidden-frameless:not(.is-fullscreen):not(.mod-macos) .workspace-tabs.mod-top-right-space .workspace-tab-header-container:after {
|
||||
display: none !important;
|
||||
}
|
||||
70
apps/ignis-server/server/bridge-plugin.js
Normal file
70
apps/ignis-server/server/bridge-plugin.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const {
|
||||
installObsidianPlugin,
|
||||
isObsidianPluginInstalled,
|
||||
} = require("./plugin-system/obsidian-plugin");
|
||||
|
||||
const BRIDGE_PLUGIN_ID = "ignis-bridge";
|
||||
const BRIDGE_PLUGIN_DIR = path.join(__dirname, "..", "..", "..", "packages", "bridge-plugin");
|
||||
|
||||
// .ignis metadata helpers
|
||||
|
||||
async function getIgnisMeta(vaultPath) {
|
||||
const metaFile = path.join(vaultPath, ".ignis", "meta.json");
|
||||
|
||||
try {
|
||||
const content = await fs.promises.readFile(metaFile, "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function setIgnisMeta(vaultPath, data) {
|
||||
const ignisDir = path.join(vaultPath, ".ignis");
|
||||
const metaFile = path.join(ignisDir, "meta.json");
|
||||
|
||||
await fs.promises.mkdir(ignisDir, { recursive: true });
|
||||
await fs.promises.writeFile(metaFile, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
// Bridge plugin install/check
|
||||
|
||||
async function isBridgePluginInstalled(vaultPath) {
|
||||
return isObsidianPluginInstalled(BRIDGE_PLUGIN_ID, vaultPath);
|
||||
}
|
||||
|
||||
async function installBridgePlugin(vaultPath) {
|
||||
const result = await installObsidianPlugin(BRIDGE_PLUGIN_DIR, vaultPath);
|
||||
return result.installed;
|
||||
}
|
||||
|
||||
async function updateBridgePluginInAllVaults(vaultRoot) {
|
||||
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);
|
||||
const installed = await installBridgePlugin(vaultPath);
|
||||
|
||||
if (installed) {
|
||||
console.log(`[ignis] Installed bridge plugin in vault: ${entry.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
installBridgePlugin,
|
||||
updateBridgePluginInAllVaults,
|
||||
isBridgePluginInstalled,
|
||||
getIgnisMeta,
|
||||
setIgnisMeta,
|
||||
};
|
||||
111
apps/ignis-server/server/config.js
Normal file
111
apps/ignis-server/server/config.js
Normal file
@@ -0,0 +1,111 @@
|
||||
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(REPO_ROOT, "vaults");
|
||||
|
||||
const dataRoot = process.env.DATA_ROOT || path.join(REPO_ROOT, "data");
|
||||
|
||||
// Ensure required directories exist
|
||||
try {
|
||||
fs.mkdirSync(vaultRoot, { recursive: true });
|
||||
} catch (e) {
|
||||
console.error("[config] Failed to create VAULT_ROOT:", vaultRoot, e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(dataRoot, { recursive: true });
|
||||
} catch (e) {
|
||||
console.error("[config] Failed to create DATA_ROOT:", dataRoot, e.message);
|
||||
}
|
||||
|
||||
function discoverVaults() {
|
||||
const vaults = {};
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(vaultRoot, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
||||
vaults[entry.name] = path.join(vaultRoot, entry.name);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[config] Failed to read VAULT_ROOT:", vaultRoot, e.message);
|
||||
}
|
||||
|
||||
// Optionally create a default vault if none exist
|
||||
if (
|
||||
Object.keys(vaults).length === 0 &&
|
||||
process.env.AUTO_CREATE_DEFAULT === "true"
|
||||
) {
|
||||
const defaultPath = path.join(vaultRoot, "My Vault");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(defaultPath, ".obsidian"), { recursive: true });
|
||||
vaults["My Vault"] = defaultPath;
|
||||
|
||||
console.log("[config] Created default vault: My Vault");
|
||||
} catch (e) {
|
||||
console.error("[config] Failed to create default vault:", e.message);
|
||||
}
|
||||
}
|
||||
return vaults;
|
||||
}
|
||||
|
||||
let vaults = discoverVaults();
|
||||
|
||||
module.exports = {
|
||||
port: process.env.PORT || 8080,
|
||||
vaultRoot,
|
||||
dataRoot,
|
||||
get vaults() {
|
||||
return vaults;
|
||||
},
|
||||
get defaultVaultId() {
|
||||
return Object.keys(vaults)[0] || null;
|
||||
},
|
||||
getVaultPath(id) {
|
||||
return vaults[id] || null;
|
||||
},
|
||||
refreshVaults() {
|
||||
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,
|
||||
demoVaultsPerSession: parseInt(process.env.DEMO_VAULTS_PER_SESSION) || 3,
|
||||
demoSessionQuotaBytes:
|
||||
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"),
|
||||
|
||||
obsidianAssetsPath:
|
||||
process.env.OBSIDIAN_ASSETS_PATH ||
|
||||
path.join(REPO_ROOT, "investigation", "obsidian_1.12.7_unpacked"),
|
||||
|
||||
get obsidianVersion() {
|
||||
const assetsPath =
|
||||
process.env.OBSIDIAN_ASSETS_PATH ||
|
||||
path.join(__dirname, "..", "investigation", "obsidian_1.12.7_unpacked");
|
||||
try {
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(path.join(assetsPath, "package.json"), "utf-8"),
|
||||
);
|
||||
return pkg.version || "0.0.0";
|
||||
} catch {
|
||||
return "0.0.0";
|
||||
}
|
||||
},
|
||||
};
|
||||
1
apps/ignis-server/server/demo-template/.obsidian/app.json
vendored
Normal file
1
apps/ignis-server/server/demo-template/.obsidian/app.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
4
apps/ignis-server/server/demo-template/.obsidian/appearance.json
vendored
Normal file
4
apps/ignis-server/server/demo-template/.obsidian/appearance.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"baseFontSize": 16,
|
||||
"theme": "obsidian"
|
||||
}
|
||||
18
apps/ignis-server/server/demo-template/.obsidian/core-plugins.json
vendored
Normal file
18
apps/ignis-server/server/demo-template/.obsidian/core-plugins.json
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"file-explorer": true,
|
||||
"global-search": true,
|
||||
"switcher": true,
|
||||
"graph": true,
|
||||
"backlink": true,
|
||||
"outgoing-link": true,
|
||||
"tag-pane": true,
|
||||
"page-preview": true,
|
||||
"command-palette": true,
|
||||
"editor-status": true,
|
||||
"markdown-importer": false,
|
||||
"word-count": true,
|
||||
"outline": true,
|
||||
"file-recovery": false,
|
||||
"publish": false,
|
||||
"sync": false
|
||||
}
|
||||
19
apps/ignis-server/server/demo-template/Getting Started.md
Normal file
19
apps/ignis-server/server/demo-template/Getting Started.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Getting Started
|
||||
|
||||
Ignis runs the unmodified Obsidian client in your browser, and most Obsidian features work unchanged from the desktop. Most simple community plugins will work, with some compatibility issues for plugins that invoke native modules; a list of tested plugins can be found [here](https://github.com/Nystik-gh/ignis/issues/9) If you're new to Obsidian, the [Obsidian help](https://help.obsidian.md) is the place to start.
|
||||
|
||||
## Try a plugin or a theme
|
||||
|
||||
Themes and community plugins install the way they do in regular Obsidian. Try a theme to change how things look. For plugins, Dataview and Calendar are good first picks.
|
||||
|
||||
## File upload and download
|
||||
|
||||
To get a file into your vault, use the upload button in the left ribbon, or simply drag and drop files into the UI. To get files out, right-click any note for **Download** or any folder for **Download as ZIP**.
|
||||
|
||||
## Workspaces in browser tabs
|
||||
|
||||
Obsidian's Workspaces core plugin saves and restores window layouts. The bridge plugin adds the ability to have different workspaces active in different browser tabs/windows. This enables having multiple workspaces active in different windows on multiple monitors. After enabling the Workspaces core plugin and creating some workspaces, you can run the **Open workspace in tab** command to try it out.
|
||||
|
||||
## Going further
|
||||
|
||||
If Ignis seems interesting to you, the [README on GitHub](https://github.com/Nystik-gh/ignis) covers self-hosting your own instance. Feel free to contribute on plugin compatibility tracking, bug reporting, or feature requests.
|
||||
15
apps/ignis-server/server/demo-template/Welcome.md
Normal file
15
apps/ignis-server/server/demo-template/Welcome.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Welcome to Ignis
|
||||
|
||||
This is a live demo of [Ignis](https://github.com/Nystik-gh/ignis), Obsidian running in a browser tab with vault files held on a server. Edit this note, poke around the sidebar, create new vaults; changes save automatically until your session ends.
|
||||
|
||||
## Demo limits
|
||||
|
||||
- Vault data is wiped after about 30 minutes of inactivity.
|
||||
- Each session can hold up to 3 vaults, capped at 700 KB total.
|
||||
- Obsidian account login is disabled. (Do not put credentials into a server you do not control. Be mindful of your security.)
|
||||
|
||||
## What to read next
|
||||
|
||||
- [[What is Ignis]] for what this is and how it's put together.
|
||||
- [[Getting Started]] for things specific to Ignis worth trying.
|
||||
- [[What works]] for the compatibility picture and what Ignis adds on top.
|
||||
33
apps/ignis-server/server/demo-template/What is Ignis.md
Normal file
33
apps/ignis-server/server/demo-template/What is Ignis.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# What is Ignis
|
||||
|
||||
Ignis lets you run Obsidian in a web browser, with your vault stored on a server instead of your local disk. You open a URL and get the full Obsidian editor running in a regular browser tab: markdown, canvas, themes, community plugins, all of it.
|
||||
|
||||
Obsidian is a local-first app, so every install holds its own vault and its own state. Obsidian Sync replicates the vault between devices, but each device still runs its own local Obsidian with its own open notes, workspaces, and plugin configuration. The other way to reach a single Obsidian instance from elsewhere has been VNC or remote desktop, which is sluggish, bandwidth-heavy, and feels less like using an app than remote-controlling a computer. Ignis is a third option: Obsidian running on a server you control, accessed through any browser, with the vault and its state on the server.
|
||||
|
||||
## How it works
|
||||
|
||||
Ignis is made up of two parts: a compatibility layer, and a bridge plugin.
|
||||
|
||||
### The compatibility layer
|
||||
|
||||
The compatibility layer has two halves.
|
||||
|
||||
The first is a **server** that holds your vault and exposes its files over HTTP and WebSocket. It serves Obsidian's own application files to the browser when you open the page, and answers the filesystem questions Obsidian asks while it runs.
|
||||
|
||||
The second is a **browser-side shim** that loads alongside Obsidian in your tab. It replaces the Node.js and Electron APIs that Obsidian normally relies on, the filesystem module, inter-process communication, Electron's clipboard, dialogs, and so on, with browser-compatible equivalents that route those calls to the server.
|
||||
|
||||
Ignis itself doesn't include or distribute any of Obsidian's code. The server downloads Obsidian directly from its official source the first time you start the container, and serves it to your browser unmodified.
|
||||
|
||||
### The bridge plugin
|
||||
|
||||
The bridge plugin serves as the frontend part of Ignis, **bridging** the functionality of the server and the Obsidian app. It gets installed into each vault automatically.
|
||||
|
||||
The plugin provides dedicated settings tabs for Ignis specific configuration and functionality, including management of a server side plugin system (work in progress). It also provides status UI for server signals, and fills some of the obvious gaps that result from running an Electron app in the browser; adding convenient upload and download functionality among other things.
|
||||
|
||||
## Plugins and limits
|
||||
|
||||
Most plugins built on Obsidian's plugin API work in Ignis, along with themes and snippets. The compatibility layer doesn't cover Node native modules or `child_process`, so plugins that depend on those don't load. For a comprehensive list of what works and what doesn't, see the [README](https://github.com/Nystik-gh/ignis#what-doesnt-work).
|
||||
|
||||
## Self-hosting
|
||||
|
||||
Ignis is open source. If you want to run your own instance, pull the image from Docker Hub and `docker compose up -d`. Setup instructions, environment variables, and the full feature list are in the [README on GitHub](https://github.com/Nystik-gh/ignis).
|
||||
52
apps/ignis-server/server/demo-template/What works.md
Normal file
52
apps/ignis-server/server/demo-template/What works.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# What works
|
||||
|
||||
Most of Obsidian's features work in Ignis. Some have gaps or workarounds, and Ignis also adds things that make sense for running Obsidian in a browser.
|
||||
|
||||
## Obsidian features that work
|
||||
|
||||
- All core editor features: markdown, canvas, bases, and the command palette.
|
||||
- Context menus throughout the UI.
|
||||
- Image rendering, inline image URLs, and image paste from the clipboard.
|
||||
- Print to PDF, via a hidden popup iframe.
|
||||
- Mobile UI auto-activates when the window is under 600 px wide.
|
||||
- Themes and CSS snippets.
|
||||
- Most community plugins built on Obsidian's plugin API.
|
||||
- Cross-origin plugin requests via `requestUrl` and `fetch`, proxied through the server.
|
||||
- Obsidian Sync, in self-hosted deployments with a logged-in browser tab open.
|
||||
|
||||
## What doesn't work
|
||||
|
||||
- Plugins that depend on Node native modules or `child_process` won't load.
|
||||
- Streaming `zlib` classes (`createGzip`, `createDeflate`, etc.) aren't implemented. The synchronous and callback variants work via `pako`.
|
||||
- The synchronous file picker (`dialog.showOpenDialogSync`), used by plugins like Importer, has a staged-files workaround: the shim asks you to pick once and serves the result on retry. Usable but rough.
|
||||
- `safeStorage` is passthrough by design: `isEncryptionAvailable()` returns `false` and `encrypt`/`decrypt` are no-ops, so anything plugins store via `safeStorage` ends up as plaintext on disk. A server-side encrypted option is planned but not yet implemented.
|
||||
|
||||
## What Ignis adds
|
||||
|
||||
### Vaults
|
||||
- Custom UI for Obsidian's multi-vault support. Create, open, switch, rename, and delete vaults.
|
||||
- Different vaults can be loaded in different browser tabs.
|
||||
|
||||
### Files
|
||||
- File upload from the local machine: ribbon icon, right-click on a folder -> Upload file, or drag-and-drop into the UI.
|
||||
- File and folder download: right-click any note for **Download**, or any folder for **Download as ZIP**.
|
||||
|
||||
### 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 opened in separate browser tabs via a `?workspace=` URL parameter, so each tab can hold a different layout of the same vault.
|
||||
- An "Open workspace in tab" command added to the command palette by the bridge plugin.
|
||||
|
||||
### Sync
|
||||
- Obsidian Headless is implemented as a server-side plugin for continuous sync without needing an active browser tab. Only one of Obsidian Sync or Obsidian Headless can run per vault.
|
||||
|
||||
### Server integration
|
||||
- Server-side plugin system, separate from Obsidian's community plugin system. (WIP)
|
||||
- Ignis-specific settings shown as their own tabs inside Obsidian's Settings modal.
|
||||
- Status bar indicators for server state and headless sync activity.
|
||||
|
||||
## Performance note
|
||||
|
||||
- Pre-compressed bootstrap response covering vault info, vault list, metadata tree, and plugin list.
|
||||
- Indexer pre-fetch warms the content cache so Obsidian's startup index hits cache instead of the network.
|
||||
- LRU content cache (50 MB by default) so Ignis doesn't hold the whole vault in memory at once. Memory use stays bounded regardless of vault size.
|
||||
- Write coalescing for slow filesystems (rclone, FUSE, NFS, SMB).
|
||||
32
apps/ignis-server/server/demo/demo-capacity.html
Normal file
32
apps/ignis-server/server/demo/demo-capacity.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Demo at capacity</title>
|
||||
<style>
|
||||
body {
|
||||
font: 16px -apple-system, sans-serif;
|
||||
background: #202020;
|
||||
color: #b3b3b3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.box {
|
||||
max-width: 480px;
|
||||
padding: 32px;
|
||||
}
|
||||
h1 { color: #ddd; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h1>Demo at capacity</h1>
|
||||
<p>All demo slots are currently in use.</p>
|
||||
<p>Try again in a few minutes. Sessions auto-expire after a period of inactivity.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
101
apps/ignis-server/server/demo/demo-cleanup.js
Normal file
101
apps/ignis-server/server/demo/demo-cleanup.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// Inactivity sweep + orphan scan, run on a 60s setInterval.
|
||||
|
||||
const fs = require("fs");
|
||||
const fsp = fs.promises;
|
||||
const path = require("path");
|
||||
|
||||
const config = require("../config");
|
||||
const { watcher } = require("@ignis/server-core");
|
||||
const bootstrapRoutes = require("../routes/bootstrap");
|
||||
|
||||
const {
|
||||
sessions,
|
||||
makeStorageName,
|
||||
PREFIX_SEPARATOR,
|
||||
} = require("./demo-sessions");
|
||||
|
||||
async function cleanupSession(sessionId) {
|
||||
const s = sessions.get(sessionId);
|
||||
|
||||
if (!s) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const userVaultName of s.vaults) {
|
||||
const storageName = makeStorageName(sessionId, userVaultName);
|
||||
const vaultPath = config.getVaultPath(storageName);
|
||||
|
||||
if (!vaultPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
watcher.stopWatching(storageName);
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await fsp.rm(vaultPath, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.warn(`[demo] Failed to remove ${storageName}:`, e.message);
|
||||
}
|
||||
|
||||
bootstrapRoutes.invalidateVault(storageName);
|
||||
}
|
||||
|
||||
config.refreshVaults();
|
||||
sessions.delete(sessionId);
|
||||
|
||||
console.log(`[demo] Cleaned up session ${sessionId}`);
|
||||
}
|
||||
|
||||
async function cleanupExpired() {
|
||||
const now = Date.now();
|
||||
const expired = [];
|
||||
|
||||
for (const [sessionId, s] of sessions) {
|
||||
if (now - s.lastActivity > config.demoTimeoutMs) {
|
||||
expired.push(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const sessionId of expired) {
|
||||
await cleanupSession(sessionId);
|
||||
}
|
||||
|
||||
// Orphan scan: directories matching demo-* whose session is gone
|
||||
let entries;
|
||||
|
||||
try {
|
||||
entries = await fsp.readdir(config.vaultRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith("demo-")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const idx = entry.name.indexOf(PREFIX_SEPARATOR);
|
||||
|
||||
if (idx < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionId = entry.name.slice("demo-".length, idx);
|
||||
|
||||
if (!sessions.has(sessionId)) {
|
||||
const orphanPath = path.join(config.vaultRoot, entry.name);
|
||||
|
||||
try {
|
||||
await fsp.rm(orphanPath, { recursive: true, force: true });
|
||||
bootstrapRoutes.invalidateVault(entry.name);
|
||||
console.log(`[demo] Removed orphan ${entry.name}`);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
config.refreshVaults();
|
||||
}
|
||||
|
||||
module.exports = { cleanupSession, cleanupExpired };
|
||||
417
apps/ignis-server/server/demo/demo-middleware.js
Normal file
417
apps/ignis-server/server/demo/demo-middleware.js
Normal file
@@ -0,0 +1,417 @@
|
||||
// Demo Express middleware.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const url = require("url");
|
||||
|
||||
const config = require("../config");
|
||||
const {
|
||||
COOKIE_NAME,
|
||||
sessions,
|
||||
makeStorageName,
|
||||
tryParseUserVaultName,
|
||||
parseCookies,
|
||||
setSessionCookie,
|
||||
getOrCreateSession,
|
||||
touchSession,
|
||||
} = require("./demo-sessions");
|
||||
const { ensureDefaultVault } = require("./demo-provision");
|
||||
|
||||
const ALLOWED_PROXY_HOSTS = new Set([
|
||||
"releases.obsidian.md",
|
||||
"github.com",
|
||||
"raw.githubusercontent.com",
|
||||
"objects.githubusercontent.com",
|
||||
"api.github.com",
|
||||
"codeload.github.com",
|
||||
]);
|
||||
|
||||
// Bump lastActivity on any cookie-bearing request.
|
||||
function activityHeartbeat(req, res, next) {
|
||||
const cookies = parseCookies(req);
|
||||
const sessionId = cookies[COOKIE_NAME];
|
||||
|
||||
if (sessionId && sessions.has(sessionId)) {
|
||||
touchSession(sessionId);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// Snapshot the user-visible vault name before inbound translation rewrites it.
|
||||
function captureOriginalVaultName(req, res, next) {
|
||||
if (req.query && req.query.vault) {
|
||||
req._demoOriginalVault = req.query.vault;
|
||||
}
|
||||
|
||||
if (req.body && req.body.vault) {
|
||||
req._demoOriginalVault = req.body.vault;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// Rewrite inbound `?vault=` and request body vault names from user-visible to storage-prefixed.
|
||||
// Tags the request with the session id.
|
||||
function inboundTranslator(req, res, next) {
|
||||
const sessionId = getOrCreateSession(req, res, { peek: true });
|
||||
|
||||
if (!sessionId) {
|
||||
return next();
|
||||
}
|
||||
|
||||
touchSession(sessionId);
|
||||
req._demoSessionId = sessionId;
|
||||
|
||||
if (req.query && req.query.vault) {
|
||||
req.query.vault = makeStorageName(sessionId, req.query.vault);
|
||||
}
|
||||
|
||||
if (req.body) {
|
||||
if (req.body.vault) {
|
||||
req.body.vault = makeStorageName(sessionId, req.body.vault);
|
||||
}
|
||||
|
||||
// Vault create/rename pass the new name as `name`
|
||||
if (req.body.name && (req.path === "/create" || req.path === "/rename")) {
|
||||
req.body.name = makeStorageName(sessionId, req.body.name);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
function rewriteVaultIdInPlace(obj, sessionId) {
|
||||
if (!obj || typeof obj !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof obj.id === "string") {
|
||||
const userName = tryParseUserVaultName(sessionId, obj.id);
|
||||
|
||||
if (userName !== null) {
|
||||
obj.id = userName;
|
||||
obj.name = userName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// filter/translate vault names in the JSON response body from storage-prefixed to user-visible
|
||||
function outboundTranslator(req, res, next) {
|
||||
const sessionId = req._demoSessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const origJson = res.json.bind(res);
|
||||
|
||||
// clean path for UI display.
|
||||
const prefix = "demo-" + sessionId + "__";
|
||||
const stripPrefix = (s) =>
|
||||
typeof s === "string" ? s.split(prefix).join("") : s;
|
||||
|
||||
res.json = function (body) {
|
||||
if (Array.isArray(body)) {
|
||||
// /api/vault/list shape: [{ id, name, path }, ...]
|
||||
const filtered = [];
|
||||
|
||||
for (const entry of body) {
|
||||
const userName = tryParseUserVaultName(sessionId, entry.id);
|
||||
|
||||
if (userName !== null) {
|
||||
filtered.push({
|
||||
id: userName,
|
||||
name: userName,
|
||||
path: stripPrefix(entry.path),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return origJson(filtered);
|
||||
}
|
||||
|
||||
if (body && typeof body === "object") {
|
||||
// /api/vault/info, /api/bootstrap, /api/vault/create response
|
||||
rewriteVaultIdInPlace(body, sessionId);
|
||||
rewriteVaultIdInPlace(body.vault, sessionId);
|
||||
|
||||
if (typeof body.path === "string") {
|
||||
body.path = stripPrefix(body.path);
|
||||
}
|
||||
|
||||
if (body.vault && typeof body.vault.path === "string") {
|
||||
body.vault.path = stripPrefix(body.vault.path);
|
||||
}
|
||||
|
||||
if (Array.isArray(body.vaultList)) {
|
||||
body.vaultList = body.vaultList
|
||||
.map((v) => {
|
||||
const userName = tryParseUserVaultName(sessionId, v.id);
|
||||
|
||||
if (userName === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: userName,
|
||||
name: userName,
|
||||
path: stripPrefix(v.path),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
return origJson(body);
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
function vaultsPerSessionEnforcer(req, res, next) {
|
||||
if (req.path !== "/create" || req.method !== "POST") {
|
||||
return next();
|
||||
}
|
||||
|
||||
const sessionId = req._demoSessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const s = sessions.get(sessionId);
|
||||
|
||||
if (s && s.vaults.size >= config.demoVaultsPerSession) {
|
||||
return res.status(507).json({
|
||||
error: `Demo limit: max ${config.demoVaultsPerSession} vaults per session`,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
function quotaEnforcer(req, res, next) {
|
||||
if (req.path !== "/writeFile" || req.method !== "POST") {
|
||||
return next();
|
||||
}
|
||||
|
||||
const sessionId = req._demoSessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const s = sessions.get(sessionId);
|
||||
|
||||
if (!s) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Estimate the size of the incoming payload
|
||||
const content = req.body && req.body.content;
|
||||
let size = 0;
|
||||
|
||||
if (typeof content === "string") {
|
||||
size = req.body.base64
|
||||
? Math.floor((content.length * 3) / 4)
|
||||
: Buffer.byteLength(content, "utf-8");
|
||||
}
|
||||
|
||||
if (s.bytesUsed + size > config.demoSessionQuotaBytes) {
|
||||
return res.status(507).json({
|
||||
error: `Demo quota exceeded (${config.demoSessionQuotaBytes} bytes per session)`,
|
||||
});
|
||||
}
|
||||
|
||||
// Optimistically add. recomputeBytes() corrects drift periodically
|
||||
s.bytesUsed += size;
|
||||
next();
|
||||
}
|
||||
|
||||
function proxyAllowlist(req, res, next) {
|
||||
const target = req.body && req.body.url;
|
||||
|
||||
if (!target) {
|
||||
return next();
|
||||
}
|
||||
|
||||
let host;
|
||||
|
||||
try {
|
||||
host = new url.URL(target).hostname;
|
||||
} catch {
|
||||
return res.status(400).json({ error: "Invalid URL" });
|
||||
}
|
||||
|
||||
if (!ALLOWED_PROXY_HOSTS.has(host)) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: `Domain not allowed in demo mode: ${host}` });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
function trackVaultLifecycle(req, res, next) {
|
||||
const sessionId = req._demoSessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Hook res.json to update session.vaults on successful create/delete/rename
|
||||
const origJson = res.json.bind(res);
|
||||
|
||||
res.json = function (body) {
|
||||
const isOk =
|
||||
res.statusCode < 400 && body && typeof body === "object" && body.ok;
|
||||
|
||||
if (isOk) {
|
||||
const s = sessions.get(sessionId);
|
||||
|
||||
if (s) {
|
||||
if (req.path === "/create" && 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._demoOriginalVault;
|
||||
|
||||
if (oldName) {
|
||||
s.vaults.delete(oldName);
|
||||
}
|
||||
|
||||
if (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;
|
||||
|
||||
if (removed) {
|
||||
s.vaults.delete(removed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return origJson(body);
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// Server-side plugins (headless-sync) have no place in a sandbox.
|
||||
// Hide the list and refuse enable/disable calls.
|
||||
function pluginsBlocker(req, res, next) {
|
||||
if (req.method === "GET") {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Server plugins are disabled in demo mode" });
|
||||
}
|
||||
|
||||
const CAPACITY_HTML = fs.readFileSync(
|
||||
path.join(__dirname, "demo-capacity.html"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
function pageLoadHandler(req, res, next) {
|
||||
if (req.path !== "/" && req.path !== "/index.html") {
|
||||
return next();
|
||||
}
|
||||
|
||||
const cookies = parseCookies(req);
|
||||
let sessionId = cookies[COOKIE_NAME];
|
||||
let session =
|
||||
sessionId && sessions.has(sessionId) ? sessions.get(sessionId) : null;
|
||||
|
||||
if (!session) {
|
||||
if (sessions.size >= config.demoMaxSessions) {
|
||||
res.status(503).type("html").send(CAPACITY_HTML);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cookie missing or session expired/cleaned. Create or restore.
|
||||
sessionId = getOrCreateSession(req, res);
|
||||
session = sessionId ? sessions.get(sessionId) : null;
|
||||
} else {
|
||||
// Refresh max-age on every page load so long-tab users stay signed in.
|
||||
setSessionCookie(res, sessionId);
|
||||
}
|
||||
|
||||
// Recovery: if the requested vault no longer exists, redirect to / so the client's provisioning flow recreates it.
|
||||
const requestedVault = req.query?.vault;
|
||||
|
||||
if (requestedVault && session) {
|
||||
const storageName = makeStorageName(sessionId, requestedVault);
|
||||
const vaultPath = config.getVaultPath(storageName);
|
||||
const stillExists =
|
||||
session.vaults.has(requestedVault) &&
|
||||
vaultPath &&
|
||||
fs.existsSync(vaultPath);
|
||||
|
||||
if (!stillExists) {
|
||||
return res.redirect(302, "/");
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// GET /api/demo/provision - returns the default vault's user-visible name, creating it if needed.
|
||||
// Client calls this when no ?vault= is in the URL.
|
||||
function provisionEndpoint(req, res) {
|
||||
const sessionId = getOrCreateSession(req, res);
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(503).json({ error: "Demo at capacity" });
|
||||
}
|
||||
|
||||
ensureDefaultVault(sessionId)
|
||||
.then((userVaultName) => {
|
||||
if (!userVaultName) {
|
||||
return res.status(500).json({ error: "Provisioning failed" });
|
||||
}
|
||||
|
||||
res.json({ vault: userVaultName });
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("[demo] provision error:", e);
|
||||
res.status(500).json({ error: e.message });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
activityHeartbeat,
|
||||
captureOriginalVaultName,
|
||||
inboundTranslator,
|
||||
outboundTranslator,
|
||||
vaultsPerSessionEnforcer,
|
||||
quotaEnforcer,
|
||||
proxyAllowlist,
|
||||
trackVaultLifecycle,
|
||||
pluginsBlocker,
|
||||
pageLoadHandler,
|
||||
provisionEndpoint,
|
||||
};
|
||||
149
apps/ignis-server/server/demo/demo-provision.js
Normal file
149
apps/ignis-server/server/demo/demo-provision.js
Normal file
@@ -0,0 +1,149 @@
|
||||
// Vault provisioning for demo sessions.
|
||||
//
|
||||
// Copies the template into a session-prefixed dir, installs the bridge plugin, and registers the vault on the session.
|
||||
// Re-provisions if disk was wiped under an existing session.
|
||||
|
||||
const fs = require("fs");
|
||||
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");
|
||||
|
||||
const DEFAULT_VAULT_NAME = "Welcome";
|
||||
|
||||
async function dirSize(dir) {
|
||||
let total = 0;
|
||||
|
||||
async function walk(d) {
|
||||
let entries;
|
||||
|
||||
try {
|
||||
entries = await fsp.readdir(d, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const e of entries) {
|
||||
const full = path.join(d, e.name);
|
||||
|
||||
if (e.isDirectory()) {
|
||||
await walk(full);
|
||||
} else {
|
||||
try {
|
||||
const st = await fsp.stat(full);
|
||||
total += st.size;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(dir);
|
||||
return total;
|
||||
}
|
||||
|
||||
async function recomputeBytes(sessionId) {
|
||||
const s = sessions.get(sessionId);
|
||||
|
||||
if (!s) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
|
||||
for (const userVaultName of s.vaults) {
|
||||
const storageName = makeStorageName(sessionId, userVaultName);
|
||||
const vaultPath = config.getVaultPath(storageName);
|
||||
|
||||
if (vaultPath) {
|
||||
total += await dirSize(vaultPath);
|
||||
}
|
||||
}
|
||||
|
||||
s.bytesUsed = total;
|
||||
return total;
|
||||
}
|
||||
|
||||
async function provisionVault(sessionId, userVaultName) {
|
||||
const s = sessions.get(sessionId);
|
||||
|
||||
if (!s) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (s.vaults.size >= config.demoVaultsPerSession) {
|
||||
return { error: "vaults-per-session-limit" };
|
||||
}
|
||||
|
||||
const storageName = makeStorageName(sessionId, userVaultName);
|
||||
const vaultPath = path.join(config.vaultRoot, storageName);
|
||||
|
||||
await fsp.mkdir(config.vaultRoot, { recursive: true });
|
||||
|
||||
try {
|
||||
await fsp.mkdir(vaultPath, { recursive: false });
|
||||
} catch (e) {
|
||||
if (e.code === "EEXIST") {
|
||||
return { error: "vault-exists" };
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
s.vaults.add(userVaultName);
|
||||
await recomputeBytes(sessionId);
|
||||
|
||||
return { storageName, userVaultName };
|
||||
}
|
||||
|
||||
async function ensureDefaultVault(sessionId) {
|
||||
const s = sessions.get(sessionId);
|
||||
|
||||
if (!s) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storageName = makeStorageName(sessionId, DEFAULT_VAULT_NAME);
|
||||
const vaultPath = config.getVaultPath(storageName);
|
||||
const onDisk = vaultPath && fs.existsSync(vaultPath);
|
||||
|
||||
if (s.vaults.has(DEFAULT_VAULT_NAME) && onDisk) {
|
||||
return DEFAULT_VAULT_NAME;
|
||||
}
|
||||
|
||||
if (onDisk) {
|
||||
// Disk has it but session forgot (cookie outlived in-memory session).
|
||||
s.vaults.add(DEFAULT_VAULT_NAME);
|
||||
return DEFAULT_VAULT_NAME;
|
||||
}
|
||||
|
||||
// Disk wiped under us; clear stale Set entry before re-provisioning.
|
||||
s.vaults.delete(DEFAULT_VAULT_NAME);
|
||||
|
||||
const result = await provisionVault(sessionId, DEFAULT_VAULT_NAME);
|
||||
|
||||
if (result && result.userVaultName) {
|
||||
return result.userVaultName;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_VAULT_NAME,
|
||||
provisionVault,
|
||||
ensureDefaultVault,
|
||||
recomputeBytes,
|
||||
};
|
||||
126
apps/ignis-server/server/demo/demo-sessions.js
Normal file
126
apps/ignis-server/server/demo/demo-sessions.js
Normal file
@@ -0,0 +1,126 @@
|
||||
// In-memory session map keyed by cookie value.
|
||||
//
|
||||
// Each entry tracks the user's vault names, last-activity timestamp, and bytes used.
|
||||
// On disk, vaults are stored under a session-prefixed name so two sessions can both have a vault called "Notes".
|
||||
|
||||
const crypto = require("crypto");
|
||||
const config = require("../config");
|
||||
|
||||
const COOKIE_NAME = "ignis-demo";
|
||||
const PREFIX_SEPARATOR = "__";
|
||||
|
||||
// sessionId -> { lastActivity, vaults: Set<userVaultName>, bytesUsed }
|
||||
const sessions = new Map();
|
||||
|
||||
function newSessionId() {
|
||||
return crypto.randomBytes(12).toString("hex");
|
||||
}
|
||||
|
||||
function prefixFor(sessionId) {
|
||||
return "demo-" + sessionId + PREFIX_SEPARATOR;
|
||||
}
|
||||
|
||||
function makeStorageName(sessionId, userVaultName) {
|
||||
return prefixFor(sessionId) + userVaultName;
|
||||
}
|
||||
|
||||
function tryParseUserVaultName(sessionId, storageName) {
|
||||
const prefix = prefixFor(sessionId);
|
||||
|
||||
if (storageName && storageName.startsWith(prefix)) {
|
||||
return storageName.slice(prefix.length);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCookies(req) {
|
||||
const header = req.headers.cookie;
|
||||
|
||||
if (!header) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const out = {};
|
||||
|
||||
for (const part of header.split(/;\s*/)) {
|
||||
const eq = part.indexOf("=");
|
||||
|
||||
if (eq < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
out[part.slice(0, eq)] = decodeURIComponent(part.slice(eq + 1));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function setSessionCookie(res, sessionId) {
|
||||
const maxAgeSeconds = Math.floor(config.demoTimeoutMs / 1000);
|
||||
|
||||
res.setHeader(
|
||||
"Set-Cookie",
|
||||
`${COOKIE_NAME}=${sessionId}; Path=/; HttpOnly; 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];
|
||||
|
||||
if (existing && sessions.has(existing)) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (existing && !sessions.has(existing)) {
|
||||
// Cookie outlived in-memory session. reuse the id to keep the prefix.
|
||||
sessions.set(existing, {
|
||||
lastActivity: Date.now(),
|
||||
vaults: new Set(),
|
||||
bytesUsed: 0,
|
||||
});
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (options.peek) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sessions.size >= config.demoMaxSessions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionId = newSessionId();
|
||||
|
||||
sessions.set(sessionId, {
|
||||
lastActivity: Date.now(),
|
||||
vaults: new Set(),
|
||||
bytesUsed: 0,
|
||||
});
|
||||
|
||||
setSessionCookie(res, sessionId);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function touchSession(sessionId) {
|
||||
const s = sessions.get(sessionId);
|
||||
|
||||
if (s) {
|
||||
s.lastActivity = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COOKIE_NAME,
|
||||
PREFIX_SEPARATOR,
|
||||
sessions,
|
||||
prefixFor,
|
||||
makeStorageName,
|
||||
tryParseUserVaultName,
|
||||
parseCookies,
|
||||
setSessionCookie,
|
||||
getOrCreateSession,
|
||||
touchSession,
|
||||
};
|
||||
41
apps/ignis-server/server/demo/demo-ws.js
Normal file
41
apps/ignis-server/server/demo/demo-ws.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// Vault prefix translation for WebSocket upgrades.
|
||||
//
|
||||
// ws.js validates the upgrade's `?vault=` against config.vaults.
|
||||
// We translate to the storage-prefixed name before it sees the request, otherwise the handshake fails and the client reconnect-loops.
|
||||
|
||||
const url = require("url");
|
||||
|
||||
const {
|
||||
COOKIE_NAME,
|
||||
sessions,
|
||||
parseCookies,
|
||||
makeStorageName,
|
||||
touchSession,
|
||||
} = require("./demo-sessions");
|
||||
|
||||
function wireWebSocket(server) {
|
||||
const origEmit = server.emit.bind(server);
|
||||
|
||||
server.emit = function (event, req, ...rest) {
|
||||
if (event === "upgrade") {
|
||||
const cookies = parseCookies(req);
|
||||
const sessionId = cookies[COOKIE_NAME];
|
||||
|
||||
if (sessionId && sessions.has(sessionId)) {
|
||||
const u = new url.URL(req.url, "http://localhost");
|
||||
const userVault = u.searchParams.get("vault");
|
||||
|
||||
if (userVault && !userVault.startsWith("demo-")) {
|
||||
u.searchParams.set("vault", makeStorageName(sessionId, userVault));
|
||||
req.url = u.pathname + u.search;
|
||||
}
|
||||
|
||||
touchSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
return origEmit(event, req, ...rest);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { wireWebSocket };
|
||||
94
apps/ignis-server/server/demo/index.js
Normal file
94
apps/ignis-server/server/demo/index.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// Demo mode entrypoint.
|
||||
//
|
||||
// Each visitor gets an isolated session with up to N session-prefixed vaults, cleaned up after inactivity.
|
||||
// The proxy is allowlisted, Obsidian account login is blocked to discourage inputing credentials in a demo environment.
|
||||
|
||||
const config = require("../config");
|
||||
|
||||
const { cleanupExpired } = require("./demo-cleanup");
|
||||
const {
|
||||
activityHeartbeat,
|
||||
captureOriginalVaultName,
|
||||
inboundTranslator,
|
||||
outboundTranslator,
|
||||
vaultsPerSessionEnforcer,
|
||||
quotaEnforcer,
|
||||
proxyAllowlist,
|
||||
trackVaultLifecycle,
|
||||
pluginsBlocker,
|
||||
pageLoadHandler,
|
||||
provisionEndpoint,
|
||||
} = require("./demo-middleware");
|
||||
const { wireWebSocket } = require("./demo-ws");
|
||||
|
||||
// Mount HTTP middleware.
|
||||
// Call before the API routes mount so this middleware intercepts first.
|
||||
function setupDemo(app) {
|
||||
if (!config.demoMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[demo] Demo mode enabled");
|
||||
console.log(`[demo] Max sessions: ${config.demoMaxSessions}`);
|
||||
console.log(`[demo] Vaults per session: ${config.demoVaultsPerSession}`);
|
||||
console.log(
|
||||
`[demo] Quota per session: ${config.demoSessionQuotaBytes} bytes`,
|
||||
);
|
||||
console.log(`[demo] Inactivity timeout: ${config.demoTimeoutMs} ms`);
|
||||
|
||||
// Page-load capacity gate (before static html)
|
||||
app.use(pageLoadHandler);
|
||||
|
||||
// Heartbeat on every request so /api/ext/*, /vault-files/*, etc. keep the session alive too.
|
||||
app.use(activityHeartbeat);
|
||||
|
||||
// Provisioning endpoint for the client to call when no vault is selected
|
||||
app.get("/api/demo/provision", provisionEndpoint);
|
||||
|
||||
// Snapshot the user-visible name before inbound translation rewrites it.
|
||||
app.use(
|
||||
["/api/vault", "/api/fs", "/api/bootstrap"],
|
||||
captureOriginalVaultName,
|
||||
);
|
||||
|
||||
// Inbound: rewrite ?vault= and bodies to prefixed storage names
|
||||
app.use(["/api/vault", "/api/fs", "/api/bootstrap"], inboundTranslator);
|
||||
|
||||
// Outbound: filter vault lists and strip prefixes from responses
|
||||
app.use(["/api/vault", "/api/fs", "/api/bootstrap"], outboundTranslator);
|
||||
|
||||
// quota enforcement
|
||||
app.use("/api/vault", vaultsPerSessionEnforcer);
|
||||
app.use("/api/fs", quotaEnforcer);
|
||||
|
||||
// Track vault create/rename/delete in session.vaults
|
||||
app.use("/api/vault", trackVaultLifecycle);
|
||||
|
||||
// Restrict the CORS proxy
|
||||
app.use("/api/proxy", proxyAllowlist);
|
||||
|
||||
// Hide server-side plugins (headless-sync) from the demo UI
|
||||
app.use("/api/plugins", pluginsBlocker);
|
||||
|
||||
// Cleanup timer
|
||||
const interval = setInterval(() => {
|
||||
cleanupExpired().catch((e) =>
|
||||
console.warn("[demo] Cleanup error:", e.message),
|
||||
);
|
||||
}, 60 * 1000);
|
||||
|
||||
if (interval.unref) {
|
||||
interval.unref();
|
||||
}
|
||||
}
|
||||
|
||||
// Wire WebSocket-level vault translation. Called after setupWebSocket.
|
||||
function wireDemoWebSocket(server) {
|
||||
if (!config.demoMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
wireWebSocket(server);
|
||||
}
|
||||
|
||||
module.exports = { setupDemo, wireDemoWebSocket };
|
||||
199
apps/ignis-server/server/index.js
Normal file
199
apps/ignis-server/server/index.js
Normal file
@@ -0,0 +1,199 @@
|
||||
const express = require("express");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const compression = require("compression");
|
||||
const config = require("./config");
|
||||
const { getVersion } = require("./version");
|
||||
const { setupWebSocket, watcher, writeCoalescer } = require("@ignis/server-core");
|
||||
const { updateBridgePluginInAllVaults } = require("./bridge-plugin");
|
||||
const { initPlugins, shutdownPlugins } = require("./plugin-system/manager");
|
||||
const pluginRoutes = require("./routes/plugins");
|
||||
writeCoalescer.configure({ writeCoalesceMs: config.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";
|
||||
const ANSI_RESET = "\x1b[0m";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(express.json({ limit: "50mb" }));
|
||||
app.use(compression());
|
||||
|
||||
// logger middleware
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
const origEnd = res.end;
|
||||
|
||||
res.end = function (...args) {
|
||||
const duration = Date.now() - start;
|
||||
const status = res.statusCode;
|
||||
|
||||
const color =
|
||||
status >= 500 ? ANSI_RED : status >= 400 ? ANSI_YELLOW : ANSI_GREEN;
|
||||
|
||||
const path =
|
||||
req.originalUrl.length > 80
|
||||
? req.originalUrl.slice(0, 80) + "..."
|
||||
: req.originalUrl;
|
||||
|
||||
console.log(
|
||||
`${color}${req.method} ${status}${ANSI_RESET} ${path} (${duration}ms)`,
|
||||
);
|
||||
|
||||
origEnd.apply(this, args);
|
||||
};
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
const fsRoutes = require("./routes/fs");
|
||||
const vaultRoutes = require("./routes/vault");
|
||||
const proxyRoutes = require("./routes/proxy");
|
||||
const versionRoutes = require("./routes/version");
|
||||
const bootstrapRoutes = require("./routes/bootstrap");
|
||||
|
||||
app.use("/assets", express.static(path.join(__dirname, "assets")));
|
||||
|
||||
// Demo mode: layers session/quota/allowlist middleware on top of the existing routes.
|
||||
// Must run BEFORE the routes are mounted. No-op when DEMO_MODE != true.
|
||||
setupDemo(app);
|
||||
|
||||
app.use("/api/fs", fsRoutes);
|
||||
app.use("/api/vault", vaultRoutes);
|
||||
app.use("/api/proxy", proxyRoutes);
|
||||
app.use("/api/version", versionRoutes);
|
||||
app.use("/api/plugins", pluginRoutes);
|
||||
app.use("/api/bootstrap", bootstrapRoutes);
|
||||
|
||||
// Serve vault files for resource URLs (images, attachments, etc.)
|
||||
// Vault ID is the first path segment: /vault-files/<vault-id>/path/to/file
|
||||
app.use("/vault-files", (req, res, next) => {
|
||||
// Extract vault ID from the first path segment
|
||||
const parts = req.path.split("/").filter(Boolean);
|
||||
|
||||
if (parts.length === 0) {
|
||||
return res.status(400).json({ error: "Missing vault ID" });
|
||||
}
|
||||
|
||||
const vaultId = decodeURIComponent(parts[0]);
|
||||
const vaultPath = config.getVaultPath(vaultId);
|
||||
|
||||
if (!vaultPath) {
|
||||
return res.status(404).json({ error: "Vault not found" });
|
||||
}
|
||||
|
||||
// Rewrite req.url to strip the vault ID prefix, then serve statically
|
||||
req.url = "/" + parts.slice(1).join("/");
|
||||
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.
|
||||
let cachedHtml = null;
|
||||
|
||||
function buildIndexHtml() {
|
||||
if (cachedHtml) {
|
||||
return cachedHtml;
|
||||
}
|
||||
|
||||
const version = getVersion();
|
||||
|
||||
// Discover Obsidian's script tags from their index.html
|
||||
const obsidianHtmlPath = path.join(config.obsidianAssetsPath, "index.html");
|
||||
const obsidianHtml = fs.readFileSync(obsidianHtmlPath, "utf-8");
|
||||
const scriptRegex = /<script[^>]+src="([^"]+)"[^>]*>/g;
|
||||
const scripts = [];
|
||||
let match;
|
||||
|
||||
while ((match = scriptRegex.exec(obsidianHtml)) !== null) {
|
||||
scripts.push(match[1]);
|
||||
}
|
||||
|
||||
// Build from our own template
|
||||
const templatePath = path.join(__dirname, "assets", "index.html");
|
||||
let html = fs.readFileSync(templatePath, "utf-8");
|
||||
|
||||
html = html.replace("__IGNIS_UI_SRC__", `ignis-ui.js?v=${version}`);
|
||||
html = html.replace("__SHIM_LOADER_SRC__", `shim-loader.js?v=${version}`);
|
||||
html = html.replace("__OBSIDIAN_SCRIPTS__", JSON.stringify(scripts));
|
||||
|
||||
if (config.demoMode) {
|
||||
html = html.replace(
|
||||
'<body class="theme-dark">',
|
||||
'<body class="theme-dark" data-demo-mode="true">',
|
||||
);
|
||||
}
|
||||
|
||||
cachedHtml = html;
|
||||
return cachedHtml;
|
||||
}
|
||||
|
||||
app.get(["/", "/index.html"], (req, res) => {
|
||||
res.set("Content-Type", "text/html; charset=utf-8");
|
||||
res.set("Cache-Control", "no-cache");
|
||||
res.send(buildIndexHtml());
|
||||
});
|
||||
|
||||
app.get("/favicon.png", (req, res) => {
|
||||
res.sendFile(path.join(REPO_ROOT, "images", "favicon.png"));
|
||||
});
|
||||
|
||||
// Serve dist files with cache headers based on version param
|
||||
app.use((req, res, next) => {
|
||||
if (req.path.match(/\/(ignis-ui|shim-loader)\.js$/)) {
|
||||
if (req.query.v) {
|
||||
// Versioned assets - cache for 1 year
|
||||
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
} else {
|
||||
// No version param - short cache for dev/fallback
|
||||
res.setHeader("Cache-Control", "public, max-age=300");
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
const server = app.listen(config.port, async () => {
|
||||
console.log(`[ignis] Server running on http://localhost:${config.port}`);
|
||||
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 });
|
||||
bootstrapRoutes
|
||||
.warmUp()
|
||||
.catch((e) => console.warn("[bootstrap] warm-up error:", e.message));
|
||||
});
|
||||
|
||||
const wss = setupWebSocket(server, { getVaultPath: config.getVaultPath });
|
||||
wireDemoWebSocket(server);
|
||||
|
||||
async function gracefulShutdown(signal) {
|
||||
console.log(`\n[ignis] Received ${signal}, shutting down gracefully...`);
|
||||
|
||||
await flushAll();
|
||||
await shutdownPlugins();
|
||||
|
||||
server.close(() => {
|
||||
console.log("[ignis] Server closed");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
console.error("[ignis] Forced shutdown after timeout");
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
||||
30
apps/ignis-server/server/plugin-system/config-store.js
Normal file
30
apps/ignis-server/server/plugin-system/config-store.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
async function load(filePath) {
|
||||
try {
|
||||
const content = await fs.promises.readFile(filePath, "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function save(filePath, data) {
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.promises.writeFile(filePath, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
function getEnabledVaults(config, pluginId) {
|
||||
return config[pluginId]?.enabledVaults || [];
|
||||
}
|
||||
|
||||
function setEnabledVaults(config, pluginId, vaultIds) {
|
||||
if (!config[pluginId]) {
|
||||
config[pluginId] = {};
|
||||
}
|
||||
|
||||
config[pluginId].enabledVaults = vaultIds;
|
||||
}
|
||||
|
||||
module.exports = { load, save, getEnabledVaults, setEnabledVaults };
|
||||
74
apps/ignis-server/server/plugin-system/discovery.js
Normal file
74
apps/ignis-server/server/plugin-system/discovery.js
Normal file
@@ -0,0 +1,74 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function discoverPlugins(pluginsDir) {
|
||||
const discovered = new Map();
|
||||
|
||||
let entries;
|
||||
|
||||
try {
|
||||
entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return discovered;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pluginPath = path.join(pluginsDir, entry.name);
|
||||
const indexPath = path.join(pluginPath, "index.js");
|
||||
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let plugin;
|
||||
|
||||
try {
|
||||
plugin = require(indexPath);
|
||||
} catch (e) {
|
||||
console.warn(`[plugins] Failed to load ${entry.name}: ${e.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!plugin.id || !plugin.name || typeof plugin.register !== "function") {
|
||||
console.warn(
|
||||
`[plugins] Skipping ${entry.name}: missing id, name, or register`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let bundledPluginId = null;
|
||||
|
||||
if (plugin.obsidianPlugin) {
|
||||
try {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(plugin.obsidianPlugin, "manifest.json"),
|
||||
"utf-8",
|
||||
),
|
||||
);
|
||||
bundledPluginId = manifest.id;
|
||||
} catch {
|
||||
// No valid bundled plugin manifest
|
||||
}
|
||||
}
|
||||
|
||||
discovered.set(plugin.id, {
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
description: plugin.description || "",
|
||||
obsidianPlugin: plugin.obsidianPlugin || null,
|
||||
bundledPluginId,
|
||||
module: plugin,
|
||||
});
|
||||
|
||||
console.log(`[plugins] Discovered: ${plugin.name}`);
|
||||
}
|
||||
|
||||
return discovered;
|
||||
}
|
||||
|
||||
module.exports = { discoverPlugins };
|
||||
283
apps/ignis-server/server/plugin-system/manager.js
Normal file
283
apps/ignis-server/server/plugin-system/manager.js
Normal file
@@ -0,0 +1,283 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const express = require("express");
|
||||
const { discoverPlugins } = require("./discovery");
|
||||
const configStore = require("./config-store");
|
||||
const {
|
||||
installObsidianPlugin,
|
||||
removeObsidianPlugin,
|
||||
} = require("./obsidian-plugin");
|
||||
|
||||
let discoveredPlugins = new Map();
|
||||
const loadedPlugins = new Map();
|
||||
const pluginRouters = new Map();
|
||||
let pluginConfig = {};
|
||||
let configPath = "";
|
||||
let serverCtx = null;
|
||||
|
||||
async function initPlugins(ctx) {
|
||||
serverCtx = ctx;
|
||||
configPath = path.join(ctx.config.dataRoot, "plugin-config.json");
|
||||
|
||||
ctx.app.use("/api/ext/:pluginId", (req, res, next) => {
|
||||
const router = pluginRouters.get(req.params.pluginId);
|
||||
|
||||
if (router) {
|
||||
router(req, res, next);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
const pluginsDir = path.join(__dirname, "..", "plugins");
|
||||
discoveredPlugins = discoverPlugins(pluginsDir);
|
||||
pluginConfig = await configStore.load(configPath);
|
||||
|
||||
for (const [pluginId] of discoveredPlugins) {
|
||||
const enabledVaults = configStore.getEnabledVaults(pluginConfig, pluginId);
|
||||
|
||||
if (enabledVaults.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await loadPlugin(pluginId);
|
||||
|
||||
for (const vaultId of enabledVaults) {
|
||||
const vaultPath = ctx.config.getVaultPath(vaultId);
|
||||
|
||||
if (!vaultPath) {
|
||||
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) {
|
||||
await loaded.module.onVaultEnabled(vaultId, vaultPath);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[plugins] Failed to load ${pluginId}: ${e.message}`);
|
||||
console.error(e.stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function shutdownPlugins() {
|
||||
console.log("[plugins] Shutting down all plugins...");
|
||||
|
||||
for (const [pluginId, loaded] of loadedPlugins) {
|
||||
if (loaded.shutdown) {
|
||||
try {
|
||||
console.log(`[plugins] Shutting down: ${loaded.name}`);
|
||||
await loaded.shutdown();
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[plugins] Error shutting down ${loaded.name}: ${e.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadedPlugins.clear();
|
||||
pluginRouters.clear();
|
||||
console.log("[plugins] All plugins shut down");
|
||||
}
|
||||
|
||||
async function loadPlugin(pluginId) {
|
||||
if (loadedPlugins.has(pluginId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const discovered = discoveredPlugins.get(pluginId);
|
||||
|
||||
if (!discovered) {
|
||||
throw new Error(`Plugin not found: ${pluginId}`);
|
||||
}
|
||||
|
||||
const plugin = discovered.module;
|
||||
const dataDir = path.join(serverCtx.config.dataRoot, "plugins", pluginId);
|
||||
|
||||
await fs.promises.mkdir(dataDir, { recursive: true });
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const pluginCtx = {
|
||||
config: serverCtx.config,
|
||||
wss: serverCtx.wss,
|
||||
watcher: serverCtx.watcher,
|
||||
router,
|
||||
log: (msg) => console.log(`[plugin:${pluginId}] ${msg}`),
|
||||
dataDir,
|
||||
getEnabledVaults: () =>
|
||||
configStore.getEnabledVaults(pluginConfig, pluginId),
|
||||
};
|
||||
|
||||
await plugin.register(pluginCtx);
|
||||
|
||||
pluginRouters.set(pluginId, router);
|
||||
|
||||
loadedPlugins.set(pluginId, {
|
||||
id: pluginId,
|
||||
name: discovered.name,
|
||||
module: plugin,
|
||||
ctx: pluginCtx,
|
||||
shutdown: plugin.shutdown ? plugin.shutdown.bind(plugin) : null,
|
||||
});
|
||||
|
||||
console.log(`[plugins] Loaded: ${discovered.name}`);
|
||||
}
|
||||
|
||||
async function unloadPlugin(pluginId) {
|
||||
const loaded = loadedPlugins.get(pluginId);
|
||||
|
||||
if (!loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (loaded.shutdown) {
|
||||
console.log(`[plugins] Shutting down: ${loaded.name}`);
|
||||
await loaded.shutdown();
|
||||
}
|
||||
|
||||
pluginRouters.delete(pluginId);
|
||||
loadedPlugins.delete(pluginId);
|
||||
console.log(`[plugins] Unloaded: ${loaded.name}`);
|
||||
}
|
||||
|
||||
async function enablePluginForVault(pluginId, vaultId) {
|
||||
const discovered = discoveredPlugins.get(pluginId);
|
||||
|
||||
if (!discovered) {
|
||||
throw new Error(`Plugin not found: ${pluginId}`);
|
||||
}
|
||||
|
||||
const vaultPath = serverCtx.config.getVaultPath(vaultId);
|
||||
|
||||
if (!vaultPath) {
|
||||
throw new Error(`Vault not found: ${vaultId}`);
|
||||
}
|
||||
|
||||
const enabledVaults = configStore.getEnabledVaults(pluginConfig, pluginId);
|
||||
|
||||
if (!enabledVaults.includes(vaultId)) {
|
||||
enabledVaults.push(vaultId);
|
||||
configStore.setEnabledVaults(pluginConfig, pluginId, enabledVaults);
|
||||
await configStore.save(configPath, pluginConfig);
|
||||
}
|
||||
|
||||
if (!loadedPlugins.has(pluginId)) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function disablePluginForVault(pluginId, vaultId) {
|
||||
const discovered = discoveredPlugins.get(pluginId);
|
||||
|
||||
if (!discovered) {
|
||||
throw new Error(`Plugin not found: ${pluginId}`);
|
||||
}
|
||||
|
||||
const vaultPath = serverCtx.config.getVaultPath(vaultId);
|
||||
|
||||
if (!vaultPath) {
|
||||
throw new Error(`Vault not found: ${vaultId}`);
|
||||
}
|
||||
|
||||
const loaded = loadedPlugins.get(pluginId);
|
||||
|
||||
if (loaded?.module?.onVaultDisabled) {
|
||||
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);
|
||||
await configStore.save(configPath, pluginConfig);
|
||||
|
||||
if (updated.length === 0) {
|
||||
await unloadPlugin(pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
function getDiscoveredPlugins() {
|
||||
const result = [];
|
||||
|
||||
for (const [pluginId, discovered] of discoveredPlugins) {
|
||||
result.push({
|
||||
id: discovered.id,
|
||||
name: discovered.name,
|
||||
description: discovered.description,
|
||||
hasBundledPlugin: !!discovered.obsidianPlugin,
|
||||
bundledPluginId: discovered.bundledPluginId,
|
||||
enabledVaults: configStore.getEnabledVaults(pluginConfig, pluginId),
|
||||
loaded: loadedPlugins.has(pluginId),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initPlugins,
|
||||
shutdownPlugins,
|
||||
enablePluginForVault,
|
||||
disablePluginForVault,
|
||||
getDiscoveredPlugins,
|
||||
};
|
||||
110
apps/ignis-server/server/plugin-system/obsidian-plugin.js
Normal file
110
apps/ignis-server/server/plugin-system/obsidian-plugin.js
Normal file
@@ -0,0 +1,110 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
async function readManifestId(sourceDir) {
|
||||
const manifestPath = path.join(sourceDir, "manifest.json");
|
||||
const content = await fs.promises.readFile(manifestPath, "utf-8");
|
||||
const manifest = JSON.parse(content);
|
||||
|
||||
if (!manifest.id) {
|
||||
throw new Error(`No "id" in manifest.json at ${sourceDir}`);
|
||||
}
|
||||
|
||||
return manifest.id;
|
||||
}
|
||||
|
||||
async function installObsidianPlugin(sourceDir, vaultPath) {
|
||||
const pluginId = await readManifestId(sourceDir);
|
||||
|
||||
const obsidianDir = path.join(vaultPath, ".obsidian");
|
||||
|
||||
try {
|
||||
await fs.promises.access(obsidianDir);
|
||||
} catch {
|
||||
return { installed: false, pluginId };
|
||||
}
|
||||
|
||||
const targetDir = path.join(obsidianDir, "plugins", pluginId);
|
||||
await fs.promises.mkdir(targetDir, { recursive: true });
|
||||
|
||||
const files = await fs.promises.readdir(sourceDir);
|
||||
|
||||
for (const file of files) {
|
||||
const srcPath = path.join(sourceDir, file);
|
||||
const stat = await fs.promises.stat(srcPath);
|
||||
|
||||
if (stat.isFile()) {
|
||||
await fs.promises.copyFile(srcPath, path.join(targetDir, file));
|
||||
}
|
||||
}
|
||||
|
||||
const pluginsConfigFile = path.join(obsidianDir, "community-plugins.json");
|
||||
let plugins = [];
|
||||
|
||||
try {
|
||||
const content = await fs.promises.readFile(pluginsConfigFile, "utf-8");
|
||||
plugins = JSON.parse(content);
|
||||
} catch {
|
||||
plugins = [];
|
||||
}
|
||||
|
||||
if (!plugins.includes(pluginId)) {
|
||||
plugins.push(pluginId);
|
||||
await fs.promises.writeFile(pluginsConfigFile, JSON.stringify(plugins));
|
||||
}
|
||||
|
||||
return { installed: true, pluginId };
|
||||
}
|
||||
|
||||
async function removeObsidianPlugin(sourceDir, vaultPath) {
|
||||
const pluginId = await readManifestId(sourceDir);
|
||||
|
||||
const obsidianDir = path.join(vaultPath, ".obsidian");
|
||||
|
||||
try {
|
||||
await fs.promises.access(obsidianDir);
|
||||
} catch {
|
||||
return { removed: false, pluginId };
|
||||
}
|
||||
|
||||
const targetDir = path.join(obsidianDir, "plugins", pluginId);
|
||||
|
||||
try {
|
||||
await fs.promises.rm(targetDir, { recursive: true });
|
||||
} catch {
|
||||
// Already gone
|
||||
}
|
||||
|
||||
const pluginsConfigFile = path.join(obsidianDir, "community-plugins.json");
|
||||
|
||||
try {
|
||||
const content = await fs.promises.readFile(pluginsConfigFile, "utf-8");
|
||||
let plugins = JSON.parse(content);
|
||||
plugins = plugins.filter((id) => id !== pluginId);
|
||||
await fs.promises.writeFile(pluginsConfigFile, JSON.stringify(plugins));
|
||||
} catch {
|
||||
// No config file or parse error - nothing to remove from
|
||||
}
|
||||
|
||||
return { removed: true, pluginId };
|
||||
}
|
||||
|
||||
async function isObsidianPluginInstalled(pluginId, vaultPath) {
|
||||
const pluginDir = path.join(vaultPath, ".obsidian", "plugins", pluginId);
|
||||
const manifestPath = path.join(pluginDir, "manifest.json");
|
||||
const mainPath = path.join(pluginDir, "main.js");
|
||||
|
||||
try {
|
||||
await fs.promises.access(manifestPath);
|
||||
await fs.promises.access(mainPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
installObsidianPlugin,
|
||||
removeObsidianPlugin,
|
||||
isObsidianPluginInstalled,
|
||||
};
|
||||
0
apps/ignis-server/server/plugins/.gitkeep
Normal file
0
apps/ignis-server/server/plugins/.gitkeep
Normal file
133
apps/ignis-server/server/plugins/headless-sync/auth.js
Normal file
133
apps/ignis-server/server/plugins/headless-sync/auth.js
Normal file
@@ -0,0 +1,133 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { getObHome } = require("./ob-cli");
|
||||
|
||||
function getObAuthFile(dataDir) {
|
||||
return path.join(
|
||||
getObHome(dataDir),
|
||||
".config",
|
||||
"obsidian-headless",
|
||||
"auth_token",
|
||||
);
|
||||
}
|
||||
|
||||
function getInternalTokenFile(dataDir) {
|
||||
return path.join(dataDir, "auth-token.json");
|
||||
}
|
||||
|
||||
function loadToken(dataDir) {
|
||||
const internalFile = getInternalTokenFile(dataDir);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(internalFile)) {
|
||||
const data = JSON.parse(fs.readFileSync(internalFile, "utf-8"));
|
||||
|
||||
if (data && data.token) {
|
||||
syncToObCli(dataDir, data.token);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fall back to ob CLI's own auth file
|
||||
const obAuthFile = getObAuthFile(dataDir);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(obAuthFile)) {
|
||||
const token = fs.readFileSync(obAuthFile, "utf-8").trim();
|
||||
|
||||
if (token) {
|
||||
const data = { token };
|
||||
saveInternal(dataDir, data);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveToken(dataDir, tokenData) {
|
||||
saveInternal(dataDir, tokenData);
|
||||
syncToObCli(dataDir, tokenData.token);
|
||||
}
|
||||
|
||||
function clearToken(dataDir) {
|
||||
const internalFile = getInternalTokenFile(dataDir);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(internalFile)) {
|
||||
fs.unlinkSync(internalFile);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const obAuthFile = getObAuthFile(dataDir);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(obAuthFile)) {
|
||||
fs.unlinkSync(obAuthFile);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function isAuthenticated(dataDir) {
|
||||
const internalFile = getInternalTokenFile(dataDir);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(internalFile)) {
|
||||
const data = JSON.parse(fs.readFileSync(internalFile, "utf-8"));
|
||||
return !!(data && data.token);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function saveInternal(dataDir, tokenData) {
|
||||
const internalFile = getInternalTokenFile(dataDir);
|
||||
const dir = path.dirname(internalFile);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(internalFile, JSON.stringify(tokenData, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
function syncToObCli(dataDir, token) {
|
||||
const obAuthFile = getObAuthFile(dataDir);
|
||||
|
||||
try {
|
||||
const dir = path.dirname(obAuthFile);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(obAuthFile, token, "utf-8");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function getTokenInfo(dataDir) {
|
||||
const internalFile = getInternalTokenFile(dataDir);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(internalFile)) {
|
||||
const data = JSON.parse(fs.readFileSync(internalFile, "utf-8"));
|
||||
|
||||
if (data && data.token) {
|
||||
return { email: data.email || null, name: data.name || null };
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadToken,
|
||||
saveToken,
|
||||
clearToken,
|
||||
isAuthenticated,
|
||||
getTokenInfo,
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
const CHANNEL = "plugin:headless-sync";
|
||||
|
||||
class SyncBroadcaster {
|
||||
constructor(wss) {
|
||||
this._wss = wss;
|
||||
this._logSubscriptions = new Map();
|
||||
}
|
||||
|
||||
subscribeToLogs(vaultId) {
|
||||
this._logSubscriptions.set(vaultId, { expires: Date.now() + 10000 });
|
||||
}
|
||||
|
||||
broadcastLog(vaultId, line) {
|
||||
if (!this._wss?.clients) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sub = this._logSubscriptions.get(vaultId);
|
||||
|
||||
if (!sub || Date.now() > sub.expires) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._send({
|
||||
channel: CHANNEL,
|
||||
type: "sync-log",
|
||||
payload: { vaultId, line },
|
||||
});
|
||||
}
|
||||
|
||||
broadcastStatus(state) {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._send({
|
||||
channel: CHANNEL,
|
||||
type: "sync-status",
|
||||
payload: state,
|
||||
});
|
||||
}
|
||||
|
||||
_send(msg) {
|
||||
if (!this._wss?.clients) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.stringify(msg);
|
||||
|
||||
for (const client of this._wss.clients) {
|
||||
if (client.readyState === 1) {
|
||||
client.send(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SyncBroadcaster };
|
||||
124
apps/ignis-server/server/plugins/headless-sync/index.js
Normal file
124
apps/ignis-server/server/plugins/headless-sync/index.js
Normal file
@@ -0,0 +1,124 @@
|
||||
const path = require("path");
|
||||
const obCli = require("./ob-cli");
|
||||
const auth = require("./auth");
|
||||
const { SyncManager } = require("./sync-manager");
|
||||
const { SyncBroadcaster } = require("./broadcaster");
|
||||
|
||||
module.exports = {
|
||||
id: "headless-sync",
|
||||
name: "Headless Sync",
|
||||
description: "Server-side vault sync via obsidian-headless CLI",
|
||||
version: "0.3.0",
|
||||
//TODO: add server plugin manifest
|
||||
|
||||
obsidianPlugin: path.join(__dirname, "plugin"),
|
||||
|
||||
_ctx: null,
|
||||
_obStatus: null,
|
||||
_syncManager: null,
|
||||
_broadcaster: null,
|
||||
|
||||
async register(ctx) {
|
||||
this._ctx = ctx;
|
||||
|
||||
this._obStatus = obCli.checkInstalled();
|
||||
|
||||
if (this._obStatus.installed) {
|
||||
ctx.log(`ob CLI available (${this._obStatus.version})`);
|
||||
} else {
|
||||
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) {
|
||||
ctx.log("Auth token loaded");
|
||||
}
|
||||
|
||||
this._broadcaster = new SyncBroadcaster(ctx.wss);
|
||||
this._syncManager = new SyncManager(ctx, this._broadcaster);
|
||||
|
||||
// Load saved sync states for enabled vaults
|
||||
const enabledVaults = ctx.getEnabledVaults();
|
||||
const vaultMap = {};
|
||||
|
||||
for (const vaultId of enabledVaults) {
|
||||
const vaultPath = ctx.config.getVaultPath(vaultId);
|
||||
|
||||
if (vaultPath) {
|
||||
vaultMap[vaultId] = vaultPath;
|
||||
}
|
||||
}
|
||||
|
||||
this._syncManager.loadStates(vaultMap);
|
||||
|
||||
// Auto-start syncs that were running before shutdown
|
||||
if (this._obStatus.installed && auth.isAuthenticated(ctx.dataDir)) {
|
||||
this._syncManager.autoStartAll();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
this._ctx = null;
|
||||
},
|
||||
|
||||
async onVaultEnabled(vaultId, vaultPath) {
|
||||
if (this._ctx) {
|
||||
this._ctx.log(`Vault enabled: ${vaultId}`);
|
||||
}
|
||||
},
|
||||
|
||||
async onVaultDisabled(vaultId, vaultPath) {
|
||||
if (!this._ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._ctx.log(`Vault disabled: ${vaultId}`);
|
||||
|
||||
// Stop sync if running, but keep the config
|
||||
if (this._syncManager) {
|
||||
const state = this._syncManager.getState(vaultId);
|
||||
|
||||
if (state && state.status === "running") {
|
||||
this._syncManager.stopSync(vaultId);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getObStatus() {
|
||||
return this._obStatus;
|
||||
},
|
||||
|
||||
getCtx() {
|
||||
return this._ctx;
|
||||
},
|
||||
|
||||
getSyncManager() {
|
||||
return this._syncManager;
|
||||
},
|
||||
};
|
||||
91
apps/ignis-server/server/plugins/headless-sync/ob-cli.js
Normal file
91
apps/ignis-server/server/plugins/headless-sync/ob-cli.js
Normal file
@@ -0,0 +1,91 @@
|
||||
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 {
|
||||
const output = execSync("ob --version", {
|
||||
stdio: "pipe",
|
||||
windowsHide: true,
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
return { installed: true, version: output || "unknown" };
|
||||
} catch {
|
||||
return { installed: false, version: null };
|
||||
}
|
||||
}
|
||||
|
||||
function spawnOb(args, opts = {}) {
|
||||
const home = configuredDataDir
|
||||
? getObHome(configuredDataDir)
|
||||
: os.homedir();
|
||||
|
||||
return spawn("ob", args, {
|
||||
env: { ...process.env, HOME: home },
|
||||
shell: isWindows,
|
||||
windowsHide: true,
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
function runCommand(args, opts = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
const proc = spawnOb(args, opts);
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve({ stdout, stderr });
|
||||
} else {
|
||||
reject(
|
||||
new Error(`ob ${args[0]} failed (code ${code}): ${stderr || stdout}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkInstalled,
|
||||
spawnOb,
|
||||
runCommand,
|
||||
configure,
|
||||
getObHome,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "ignis-headless-sync",
|
||||
"name": "Ignis Headless Sync",
|
||||
"version": "0.3.0",
|
||||
"minAppVersion": "1.12.4",
|
||||
"description": "Client-side companion for server-side Obsidian Sync",
|
||||
"author": "Ignis",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
const BASE = "/api/ext/headless-sync";
|
||||
|
||||
async function fetchJson(path, opts = {}) {
|
||||
const res = await fetch(`${BASE}${path}`, opts);
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Request failed: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function post(path, body) {
|
||||
return fetchJson(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function getStatus() {
|
||||
return fetchJson("/status");
|
||||
}
|
||||
|
||||
function login(token, email, name) {
|
||||
return post("/login", { token, email, name });
|
||||
}
|
||||
|
||||
function logout() {
|
||||
return post("/logout", {});
|
||||
}
|
||||
|
||||
function getRemoteVaults() {
|
||||
return fetchJson("/remote-vaults");
|
||||
}
|
||||
|
||||
function setupSync(vaultId, remoteVault, opts = {}) {
|
||||
return post("/setup", { vaultId, remoteVault, ...opts });
|
||||
}
|
||||
|
||||
function createRemoteVault(name, encryption, password, region) {
|
||||
return post("/create-remote-vault", { name, encryption, password, region });
|
||||
}
|
||||
|
||||
function startSync(vaultId) {
|
||||
return post("/start", { vaultId });
|
||||
}
|
||||
|
||||
function stopSync(vaultId) {
|
||||
return post("/stop", { vaultId });
|
||||
}
|
||||
|
||||
function unlinkVault(vaultId) {
|
||||
return post("/unlink", { vaultId });
|
||||
}
|
||||
|
||||
function getVaults() {
|
||||
return fetchJson("/vaults");
|
||||
}
|
||||
|
||||
function getLogs(vaultId, limit = 100) {
|
||||
return fetchJson(`/logs?vaultId=${encodeURIComponent(vaultId)}&limit=${limit}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getStatus,
|
||||
login,
|
||||
logout,
|
||||
getRemoteVaults,
|
||||
setupSync,
|
||||
createRemoteVault,
|
||||
startSync,
|
||||
stopSync,
|
||||
unlinkVault,
|
||||
getVaults,
|
||||
getLogs,
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
const api = require("./api");
|
||||
|
||||
function getObsidianSyncToken() {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
|
||||
try {
|
||||
const val = JSON.parse(localStorage.getItem(key));
|
||||
|
||||
if (val?.token && val?.email && val?.name) {
|
||||
return val;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function triggerLogin(app) {
|
||||
const aboutTab = app.setting.settingTabs.find((t) => t.id === "about");
|
||||
|
||||
if (!aboutTab || !aboutTab.accountSetting) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const loginBtn = aboutTab.accountSetting.controlEl.querySelector("button");
|
||||
|
||||
if (!loginBtn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
loginBtn.click();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendTokenToServer(tokenData) {
|
||||
return api.login(tokenData.token, tokenData.email, tokenData.name);
|
||||
}
|
||||
|
||||
function waitForLogin(callback, timeoutMs = 60000) {
|
||||
const interval = 2000;
|
||||
let elapsed = 0;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
elapsed += interval;
|
||||
|
||||
const token = getObsidianSyncToken();
|
||||
|
||||
if (token) {
|
||||
clearInterval(timer);
|
||||
callback(token);
|
||||
return;
|
||||
}
|
||||
|
||||
if (elapsed >= timeoutMs) {
|
||||
clearInterval(timer);
|
||||
callback(null);
|
||||
}
|
||||
}, interval);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getObsidianSyncToken,
|
||||
triggerLogin,
|
||||
sendTokenToServer,
|
||||
waitForLogin,
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
const { Notice } = require("obsidian");
|
||||
const fs = require("fs");
|
||||
|
||||
const CORE_PLUGINS_PATH = ".obsidian/core-plugins.json";
|
||||
|
||||
// Reads core-plugins.json via the fs shim. When headless sync is active,
|
||||
// the shim patches sync: false, so this returns false. When the flag is
|
||||
// cleared (user action), this returns the real value.
|
||||
function isCoreSyncEnabled() {
|
||||
try {
|
||||
const data = fs.readFileSync(CORE_PLUGINS_PATH, "utf-8");
|
||||
const config = JSON.parse(data);
|
||||
return config.sync === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function showConflictWarning(title, message) {
|
||||
if (!window.IgnisUI?.MessageDialog) {
|
||||
new Notice(`${title}: ${message}`, 10000);
|
||||
return;
|
||||
}
|
||||
|
||||
const dialog = new window.IgnisUI.MessageDialog({
|
||||
target: document.body,
|
||||
props: { title, message },
|
||||
});
|
||||
|
||||
dialog.$on("confirm", () => {
|
||||
dialog.$destroy();
|
||||
});
|
||||
}
|
||||
|
||||
function startCoreSyncGuard(plugin, api, wsListener) {
|
||||
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.
|
||||
const syncPlugin = app.internalPlugins.getPluginById("sync");
|
||||
let origEnable = null;
|
||||
|
||||
if (syncPlugin) {
|
||||
origEnable = syncPlugin.enable.bind(syncPlugin);
|
||||
|
||||
syncPlugin.enable = function (...args) {
|
||||
window.__ignisHeadlessSyncActive = false;
|
||||
api.stopSync(vaultId).catch(() => {});
|
||||
return origEnable(...args);
|
||||
};
|
||||
}
|
||||
|
||||
// Watch for core-plugins.json changes via WebSocket.
|
||||
let wasEnabled = isCoreSyncEnabled();
|
||||
|
||||
const rawHandler = (msg) => {
|
||||
if (msg.type === "modified" && msg.path === CORE_PLUGINS_PATH) {
|
||||
handleCoreSyncChange();
|
||||
}
|
||||
};
|
||||
|
||||
wsListener.onRaw(rawHandler);
|
||||
|
||||
function handleCoreSyncChange() {
|
||||
const enabled = isCoreSyncEnabled();
|
||||
|
||||
if (enabled && !wasEnabled) {
|
||||
showConflictWarning(
|
||||
"Headless Sync Stopped",
|
||||
"Obsidian Sync has been enabled. Headless Sync has been automatically " +
|
||||
"stopped to avoid conflicts between the two sync methods.\n\n" +
|
||||
"To use Headless Sync again, disable Obsidian Sync in Core Plugins.",
|
||||
);
|
||||
}
|
||||
|
||||
wasEnabled = enabled;
|
||||
}
|
||||
|
||||
return {
|
||||
cleanup() {
|
||||
wsListener.offRaw();
|
||||
|
||||
if (syncPlugin && origEnable) {
|
||||
syncPlugin.enable = origEnable;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isCoreSyncEnabled,
|
||||
startCoreSyncGuard,
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
const api = require("./api");
|
||||
|
||||
async function renderLogViewer(containerEl, vaultId, wsListener) {
|
||||
const details = containerEl.createEl("details", {
|
||||
cls: "ignis-log-details",
|
||||
});
|
||||
|
||||
details.createEl("summary", { text: "Sync logs" });
|
||||
|
||||
const logBox = details.createEl("pre", { cls: "ignis-log-terminal" });
|
||||
const codeEl = logBox.createEl("code");
|
||||
|
||||
let logsData;
|
||||
|
||||
try {
|
||||
logsData = await api.getLogs(vaultId, 50);
|
||||
} catch (e) {
|
||||
codeEl.textContent = `Failed to load logs: ${e.message}`;
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (logsData.logs.length === 0) {
|
||||
codeEl.textContent = "No log entries yet.";
|
||||
} else {
|
||||
const lines = logsData.logs.map((entry) => {
|
||||
const time = new Date(entry.timestamp).toLocaleTimeString();
|
||||
return `[${time}] ${entry.line}`;
|
||||
});
|
||||
|
||||
codeEl.textContent = lines.join("\n");
|
||||
}
|
||||
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
|
||||
if (!wsListener) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
details.addEventListener("toggle", () => {
|
||||
if (details.open) {
|
||||
wsListener.subscribeLogs(vaultId);
|
||||
} else {
|
||||
wsListener.unsubscribeLogs();
|
||||
}
|
||||
});
|
||||
|
||||
const onLog = (payload) => {
|
||||
if (payload.vaultId !== vaultId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const time = new Date().toLocaleTimeString();
|
||||
const line = `[${time}] ${payload.line}`;
|
||||
|
||||
if (codeEl.textContent === "No log entries yet.") {
|
||||
codeEl.textContent = line;
|
||||
} else {
|
||||
codeEl.textContent += "\n" + line;
|
||||
}
|
||||
|
||||
const isNearBottom =
|
||||
logBox.scrollHeight - logBox.scrollTop - logBox.clientHeight < 50;
|
||||
|
||||
if (isNearBottom) {
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
wsListener.on("sync-log", onLog);
|
||||
|
||||
return () => {
|
||||
wsListener.off("sync-log", onLog);
|
||||
wsListener.unsubscribeLogs();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { renderLogViewer };
|
||||
@@ -0,0 +1,86 @@
|
||||
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");
|
||||
|
||||
class IgnisHeadlessSyncPlugin extends Plugin {
|
||||
async onload() {
|
||||
if (!window.__ignis) {
|
||||
console.log(
|
||||
"[ignis-headless-sync] Not running in Ignis - plugin is a no-op.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.wsListener = new WsListener();
|
||||
this.wsListener.start();
|
||||
|
||||
this._syncStatusBarCleanup = initSyncStatusBar(this, this.wsListener);
|
||||
|
||||
this.addSettingTab(new HeadlessSyncSettingTab(this.app, this));
|
||||
|
||||
this._coreSyncGuard = startCoreSyncGuard(this, api, this.wsListener);
|
||||
|
||||
this.addCommand({
|
||||
id: "start-sync",
|
||||
name: "Start server-side sync",
|
||||
callback: async () => {
|
||||
try {
|
||||
await api.startSync(this.app.vault.getName());
|
||||
} catch (e) {
|
||||
console.error("[ignis-headless-sync] Start failed:", e.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "stop-sync",
|
||||
name: "Stop server-side sync",
|
||||
callback: async () => {
|
||||
try {
|
||||
await api.stopSync(this.app.vault.getName());
|
||||
} catch (e) {
|
||||
console.error("[ignis-headless-sync] Stop failed:", e.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "show-status",
|
||||
name: "Show sync status",
|
||||
callback: () => {
|
||||
this.app.setting.open();
|
||||
this.app.setting.openTabById("ignis-headless-sync");
|
||||
},
|
||||
});
|
||||
|
||||
console.log("[ignis-headless-sync] Loaded");
|
||||
}
|
||||
|
||||
onunload() {
|
||||
if (!window.__ignis) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.__ignisHeadlessSyncActive = false;
|
||||
|
||||
if (this._coreSyncGuard) {
|
||||
this._coreSyncGuard.cleanup();
|
||||
this._coreSyncGuard = null;
|
||||
}
|
||||
|
||||
if (this._syncStatusBarCleanup) {
|
||||
this._syncStatusBarCleanup();
|
||||
this._syncStatusBarCleanup = null;
|
||||
}
|
||||
|
||||
if (this.wsListener) {
|
||||
this.wsListener.stop();
|
||||
this.wsListener = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = IgnisHeadlessSyncPlugin;
|
||||
@@ -0,0 +1,341 @@
|
||||
const { PluginSettingTab, Setting, Notice } = require("obsidian");
|
||||
const api = require("./api");
|
||||
const auth = require("./auth");
|
||||
const { isCoreSyncEnabled } = require("./core-sync-guard");
|
||||
const { renderLogViewer } = require("./log-viewer");
|
||||
|
||||
class HeadlessSyncSettingTab extends PluginSettingTab {
|
||||
constructor(app, plugin) {
|
||||
super(app, plugin);
|
||||
this._cancelWait = null;
|
||||
this._logCleanup = null;
|
||||
|
||||
// Persistent container refs
|
||||
this._authEl = null;
|
||||
this._syncEl = null;
|
||||
this._logsEl = null;
|
||||
this._logsRendered = false;
|
||||
}
|
||||
|
||||
async display() {
|
||||
// Clean up previous log listener before rebuilding
|
||||
if (this._logCleanup) {
|
||||
this._logCleanup();
|
||||
this._logCleanup = null;
|
||||
}
|
||||
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
|
||||
this._logsRendered = false;
|
||||
|
||||
if (isCoreSyncEnabled()) {
|
||||
const syncWarningSetting = new Setting(containerEl)
|
||||
.setName("Obsidian Sync is active");
|
||||
|
||||
syncWarningSetting.descEl.createEl("span", {
|
||||
text: "Headless Sync cannot run alongside Obsidian's built-in sync to avoid conflicts. Disable Obsidian Sync in Core Plugins to use Headless Sync instead.",
|
||||
cls: "mod-warning",
|
||||
});
|
||||
|
||||
syncWarningSetting
|
||||
.addButton((btn) => {
|
||||
btn.setButtonText("Open Core Plugins").onClick(() => {
|
||||
this.app.setting.openTabById("plugins");
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let serverStatus;
|
||||
|
||||
try {
|
||||
serverStatus = await api.getStatus();
|
||||
} catch (e) {
|
||||
containerEl.createEl("p", {
|
||||
text: "Failed to connect to Headless Sync server plugin.",
|
||||
cls: "mod-warning",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!serverStatus.installed) {
|
||||
containerEl.createEl("p", {
|
||||
text: "obsidian-headless (ob CLI) is not installed on the server. Install it to enable sync.",
|
||||
cls: "mod-warning",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this._authEl = containerEl.createDiv();
|
||||
this._syncEl = containerEl.createDiv();
|
||||
this._logsEl = containerEl.createDiv();
|
||||
|
||||
this.renderAuthSection(serverStatus);
|
||||
await this.renderSyncSection(serverStatus.authenticated);
|
||||
}
|
||||
|
||||
renderAuthSection(serverStatus) {
|
||||
this._authEl.empty();
|
||||
|
||||
const localToken = auth.getObsidianSyncToken();
|
||||
|
||||
if (serverStatus.authenticated) {
|
||||
new Setting(this._authEl)
|
||||
.setName("Obsidian Sync account")
|
||||
.setDesc(
|
||||
`Signed in as ${serverStatus.name || "unknown"} (${serverStatus.email || "unknown"})`,
|
||||
)
|
||||
.addButton((btn) => {
|
||||
btn.setButtonText("Disconnect");
|
||||
btn.buttonEl.addClass("mod-destructive");
|
||||
btn.onClick(async () => {
|
||||
try {
|
||||
await api.logout();
|
||||
new Notice("Disconnected from Headless Sync");
|
||||
const status = await api.getStatus();
|
||||
this.renderAuthSection(status);
|
||||
await this.renderSyncSection(status.authenticated);
|
||||
} catch (e) {
|
||||
new Notice(`Failed to disconnect: ${e.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else if (localToken) {
|
||||
new Setting(this._authEl)
|
||||
.setName("Obsidian Sync account detected")
|
||||
.setDesc(`${localToken.name} (${localToken.email})`)
|
||||
.addButton((btn) => {
|
||||
btn
|
||||
.setButtonText("Use this account for Headless Sync")
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
try {
|
||||
await auth.sendTokenToServer(localToken);
|
||||
new Notice("Connected to Headless Sync");
|
||||
const status = await api.getStatus();
|
||||
this.renderAuthSection(status);
|
||||
await this.renderSyncSection(status.authenticated);
|
||||
} catch (e) {
|
||||
new Notice(`Failed to connect: ${e.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
new Setting(this._authEl)
|
||||
.setName("Obsidian Sync account")
|
||||
.setDesc("Sign in to your Obsidian account to enable sync.")
|
||||
.addButton((btn) => {
|
||||
btn.setButtonText("Log in to Obsidian Sync").onClick(() => {
|
||||
const triggered = auth.triggerLogin(this.app);
|
||||
|
||||
if (!triggered) {
|
||||
new Notice(
|
||||
"Could not open login dialog. Try logging in from Settings > General.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this._cancelWait = auth.waitForLogin(async (token) => {
|
||||
this._cancelWait = null;
|
||||
|
||||
if (token) {
|
||||
new Notice(`Detected login: ${token.name}`);
|
||||
const status = await api.getStatus();
|
||||
this.renderAuthSection(status);
|
||||
await this.renderSyncSection(status.authenticated);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async renderSyncSection(authenticated) {
|
||||
this._syncEl.empty();
|
||||
|
||||
this._syncEl.createEl("h3", { text: "Vault sync" });
|
||||
|
||||
if (!authenticated) {
|
||||
new Setting(this._syncEl)
|
||||
.setName("Sync not configured")
|
||||
.setDesc("Sign in to your Obsidian Sync account to set up sync.")
|
||||
.addButton((btn) => {
|
||||
btn.setButtonText("Set up sync");
|
||||
btn.buttonEl.disabled = true;
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const vaultId = this.app.vault.getName();
|
||||
|
||||
let vaultsData;
|
||||
|
||||
try {
|
||||
vaultsData = await api.getVaults();
|
||||
} catch (e) {
|
||||
this._syncEl.createEl("p", {
|
||||
text: `Failed to load sync state: ${e.message}`,
|
||||
cls: "mod-warning",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const vaultState = vaultsData.vaults.find((v) => v.vaultId === vaultId);
|
||||
|
||||
if (!vaultState) {
|
||||
new Setting(this._syncEl)
|
||||
.setName("Sync not configured")
|
||||
.setDesc("This vault has not been linked to a remote vault yet.")
|
||||
.addButton((btn) => {
|
||||
btn
|
||||
.setButtonText("Set up sync")
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
const scope = this.app.setting.scope;
|
||||
const prevFocusContainer = scope.tabFocusContainerEl;
|
||||
scope.tabFocusContainerEl = null;
|
||||
|
||||
const cleanup = () => {
|
||||
scope.tabFocusContainerEl = prevFocusContainer;
|
||||
};
|
||||
|
||||
const modal = new window.IgnisUI.SyncSetupModal({
|
||||
target: document.body,
|
||||
props: {
|
||||
vaultId,
|
||||
onSuccess: async () => {
|
||||
cleanup();
|
||||
modal.$destroy();
|
||||
await this.renderSyncSection(true);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
modal.$on("close", () => {
|
||||
cleanup();
|
||||
modal.$destroy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Show current sync config
|
||||
new Setting(this._syncEl)
|
||||
.setName("Remote vault")
|
||||
.setDesc(
|
||||
vaultState.remoteVaultName || vaultState.remoteVault || "unknown",
|
||||
)
|
||||
.addButton((btn) => {
|
||||
btn.setButtonText("Unlink");
|
||||
btn.buttonEl.addClass("mod-destructive");
|
||||
btn.onClick(async () => {
|
||||
try {
|
||||
await api.unlinkVault(vaultId);
|
||||
new Notice("Vault unlinked");
|
||||
await this.renderSyncSection(true);
|
||||
} catch (e) {
|
||||
new Notice(`Failed to unlink: ${e.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(this._syncEl)
|
||||
.setName("Sync mode")
|
||||
.setDesc(vaultState.config?.mode || "bidirectional");
|
||||
|
||||
// Sync controls
|
||||
const controlsEl = this._syncEl.createDiv();
|
||||
this.renderSyncControls(controlsEl, vaultId, vaultState);
|
||||
|
||||
// Log viewer - only render once, persists across sync section rebuilds
|
||||
if (!this._logsRendered) {
|
||||
await this.renderLogs(this._logsEl, vaultId);
|
||||
this._logsRendered = true;
|
||||
}
|
||||
}
|
||||
|
||||
async renderSyncControls(containerEl, vaultId, vaultState) {
|
||||
containerEl.empty();
|
||||
|
||||
if (!vaultState) {
|
||||
try {
|
||||
const data = await api.getVaults();
|
||||
vaultState = (data.vaults || []).find((v) => v.vaultId === vaultId);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!vaultState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusText =
|
||||
vaultState.status === "running"
|
||||
? "Sync is running"
|
||||
: vaultState.status === "error"
|
||||
? `Error: ${vaultState.error}`
|
||||
: "Sync is stopped";
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Status")
|
||||
.setDesc(statusText)
|
||||
.addButton((btn) => {
|
||||
if (vaultState.status === "running") {
|
||||
btn.setButtonText("Stop sync");
|
||||
btn.buttonEl.addClass("mod-destructive");
|
||||
btn.onClick(async () => {
|
||||
try {
|
||||
await api.stopSync(vaultId);
|
||||
new Notice("Sync stopped");
|
||||
this.renderSyncControls(containerEl, vaultId);
|
||||
} catch (e) {
|
||||
new Notice(`Failed to stop: ${e.message}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
btn
|
||||
.setButtonText("Start sync")
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
try {
|
||||
await api.startSync(vaultId);
|
||||
new Notice("Sync started");
|
||||
this.renderSyncControls(containerEl, vaultId);
|
||||
} catch (e) {
|
||||
new Notice(`Failed to start: ${e.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async renderLogs(containerEl, vaultId) {
|
||||
this._logCleanup = await renderLogViewer(
|
||||
containerEl,
|
||||
vaultId,
|
||||
this.plugin.wsListener,
|
||||
);
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (this._cancelWait) {
|
||||
this._cancelWait();
|
||||
this._cancelWait = null;
|
||||
}
|
||||
|
||||
if (this._logCleanup) {
|
||||
this._logCleanup();
|
||||
this._logCleanup = null;
|
||||
}
|
||||
|
||||
super.hide();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { HeadlessSyncSettingTab };
|
||||
@@ -0,0 +1,283 @@
|
||||
const { setIcon } = require("obsidian");
|
||||
const api = require("./api");
|
||||
|
||||
const TOOLTIP_MAP = {
|
||||
running: "Syncing...",
|
||||
synced: "Synced",
|
||||
stopped: "Sync stopped",
|
||||
error: "Sync error",
|
||||
};
|
||||
|
||||
function initSyncStatusBar(plugin, wsListener) {
|
||||
const vaultId = plugin.app.vault.getName();
|
||||
const item = plugin.addStatusBarItem();
|
||||
item.addClass("ignis-sync-statusbar");
|
||||
item.style.display = "none";
|
||||
|
||||
const iconEl = item.createEl("span", { cls: "ignis-sync-icon" });
|
||||
setIcon(iconEl, "refresh-cw");
|
||||
|
||||
let popoverEl = null;
|
||||
let popoverOpen = false;
|
||||
let currentStatus = "stopped";
|
||||
let outsideClickHandler = null;
|
||||
|
||||
function updateState(status, error) {
|
||||
currentStatus = status;
|
||||
|
||||
iconEl.className = "ignis-sync-icon";
|
||||
|
||||
if (status === "running") {
|
||||
iconEl.addClass("ignis-sync-syncing");
|
||||
iconEl.addClass("ignis-sync-spinning");
|
||||
} else if (status === "error") {
|
||||
iconEl.addClass("ignis-sync-error");
|
||||
} else if (status === "stopped") {
|
||||
iconEl.addClass("ignis-sync-stopped");
|
||||
} else {
|
||||
iconEl.addClass("ignis-sync-synced");
|
||||
}
|
||||
|
||||
const tooltip = error || TOOLTIP_MAP[status] || status;
|
||||
item.setAttribute("aria-label", tooltip);
|
||||
item.setAttribute("data-tooltip-position", "top");
|
||||
}
|
||||
|
||||
function showPopover(text) {
|
||||
if (popoverEl) {
|
||||
const span = popoverEl.querySelector(".ignis-sync-popover-filename");
|
||||
|
||||
if (span) {
|
||||
span.textContent = text;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
popoverEl = item.createEl("div", { cls: "ignis-sync-popover" });
|
||||
popoverEl.createEl("span", {
|
||||
text: text,
|
||||
cls: "ignis-sync-popover-filename",
|
||||
});
|
||||
|
||||
popoverOpen = true;
|
||||
|
||||
wsListener.subscribeLogs(vaultId);
|
||||
|
||||
outsideClickHandler = (e) => {
|
||||
if (!item.contains(e.target)) {
|
||||
hidePopover();
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
document.addEventListener("click", outsideClickHandler, true);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function hidePopover() {
|
||||
if (popoverEl) {
|
||||
popoverEl.remove();
|
||||
popoverEl = null;
|
||||
}
|
||||
|
||||
if (outsideClickHandler) {
|
||||
document.removeEventListener("click", outsideClickHandler, true);
|
||||
outsideClickHandler = null;
|
||||
}
|
||||
|
||||
wsListener.unsubscribeLogs();
|
||||
popoverOpen = false;
|
||||
}
|
||||
|
||||
function truncatePath(path, maxLen) {
|
||||
if (path.length <= maxLen) {
|
||||
return path;
|
||||
}
|
||||
|
||||
return "\u2026" + path.slice(-(maxLen - 1));
|
||||
}
|
||||
|
||||
function formatPopoverText(prefix, path) {
|
||||
return `${prefix}: ${truncatePath(path, 46 - prefix.length)}`;
|
||||
}
|
||||
|
||||
function updatePopoverText(text) {
|
||||
if (!popoverOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const span = popoverEl?.querySelector(".ignis-sync-popover-filename");
|
||||
|
||||
if (span) {
|
||||
span.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
return { prefix: "Deleting", path: match[1].trim() };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isFullySynced(line) {
|
||||
return /Fully synced/i.test(line);
|
||||
}
|
||||
|
||||
// Click toggles popover
|
||||
item.addEventListener("click", () => {
|
||||
if (popoverOpen) {
|
||||
hidePopover();
|
||||
} else {
|
||||
showPopover(TOOLTIP_MAP[currentStatus] || currentStatus);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for status updates
|
||||
const onStatus = (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 {
|
||||
updateState(payload.status, payload.error);
|
||||
}
|
||||
};
|
||||
|
||||
wsListener.on("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() {
|
||||
if (syncedTimer) {
|
||||
clearTimeout(syncedTimer);
|
||||
}
|
||||
|
||||
syncedTimer = setTimeout(() => {
|
||||
syncedTimer = null;
|
||||
updateState("synced");
|
||||
updatePopoverText("Synced");
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function cancelDeferredSynced() {
|
||||
if (syncedTimer) {
|
||||
clearTimeout(syncedTimer);
|
||||
syncedTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for log lines
|
||||
const onLog = (payload) => {
|
||||
if (payload.vaultId !== vaultId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFullySynced(payload.line)) {
|
||||
deferSynced();
|
||||
return;
|
||||
}
|
||||
|
||||
const activity = extractFileActivity(payload.line);
|
||||
|
||||
if (activity) {
|
||||
cancelDeferredSynced();
|
||||
updateState("running");
|
||||
updatePopoverText(formatPopoverText(activity.prefix, activity.path));
|
||||
}
|
||||
};
|
||||
|
||||
wsListener.on("sync-log", onLog);
|
||||
|
||||
// Fetch initial state
|
||||
api
|
||||
.getVaults()
|
||||
.then((data) => {
|
||||
const vaults = data.vaults || [];
|
||||
const vault = vaults.find((v) => v.vaultId === vaultId);
|
||||
|
||||
if (vault) {
|
||||
item.style.display = "";
|
||||
updateState(vault.status, vault.error);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Poll WebSocket state to detect server disconnect/reconnect
|
||||
let wasDisconnected = false;
|
||||
|
||||
const wsCheckInterval = setInterval(() => {
|
||||
const disconnected = !wsListener.isConnected();
|
||||
|
||||
if (disconnected && currentStatus === "running") {
|
||||
updateState("error", "Server connection lost");
|
||||
wasDisconnected = true;
|
||||
} else if (!disconnected && wasDisconnected) {
|
||||
wasDisconnected = false;
|
||||
|
||||
api
|
||||
.getVaults()
|
||||
.then((data) => {
|
||||
const vaults = data.vaults || [];
|
||||
const vault = vaults.find((v) => v.vaultId === vaultId);
|
||||
|
||||
if (vault) {
|
||||
updateState(vault.status, vault.error);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
clearInterval(wsCheckInterval);
|
||||
cancelDeferredSynced();
|
||||
wsListener.off("sync-status", onStatus);
|
||||
wsListener.off("sync-log", onLog);
|
||||
hidePopover();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { initSyncStatusBar };
|
||||
@@ -0,0 +1,153 @@
|
||||
const CHANNEL = "plugin:headless-sync";
|
||||
const POLL_INTERVAL = 3000;
|
||||
const LOG_KEEPALIVE_INTERVAL = 7000;
|
||||
|
||||
class WsListener {
|
||||
constructor() {
|
||||
this._callbacks = new Map();
|
||||
this._handler = null;
|
||||
this._rawHandler = null;
|
||||
this._pollTimer = null;
|
||||
this._currentWs = null;
|
||||
this._logSubInterval = null;
|
||||
this._logSubVaultId = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
this._attachToWs();
|
||||
|
||||
this._pollTimer = setInterval(() => {
|
||||
this._attachToWs();
|
||||
}, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._pollTimer) {
|
||||
clearInterval(this._pollTimer);
|
||||
this._pollTimer = null;
|
||||
}
|
||||
|
||||
this.unsubscribeLogs();
|
||||
this._detachFromWs();
|
||||
}
|
||||
|
||||
isConnected() {
|
||||
const ws = window.__ignisWs;
|
||||
return ws && ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
on(type, callback) {
|
||||
if (!this._callbacks.has(type)) {
|
||||
this._callbacks.set(type, []);
|
||||
}
|
||||
|
||||
this._callbacks.get(type).push(callback);
|
||||
}
|
||||
|
||||
off(type, callback) {
|
||||
const list = this._callbacks.get(type);
|
||||
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = list.indexOf(callback);
|
||||
|
||||
if (idx !== -1) {
|
||||
list.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for raw WebSocket messages (not channel-filtered).
|
||||
// Used by core-sync-guard to watch for file changes.
|
||||
onRaw(callback) {
|
||||
this._rawHandler = callback;
|
||||
}
|
||||
|
||||
offRaw() {
|
||||
this._rawHandler = null;
|
||||
}
|
||||
|
||||
send(type, payload) {
|
||||
const ws = window.__ignisWs;
|
||||
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type, ...payload }));
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to server log broadcasts for a vault.
|
||||
// Sends the initial subscribe message and keeps the subscription alive.
|
||||
subscribeLogs(vaultId) {
|
||||
// If already subscribed to this vault, no-op.
|
||||
if (this._logSubVaultId === vaultId && this._logSubInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.unsubscribeLogs();
|
||||
this._logSubVaultId = vaultId;
|
||||
|
||||
this.send("subscribe-logs", { vaultId });
|
||||
|
||||
this._logSubInterval = setInterval(() => {
|
||||
this.send("subscribe-logs", { vaultId });
|
||||
}, LOG_KEEPALIVE_INTERVAL);
|
||||
}
|
||||
|
||||
// Stop the log subscription keepalive.
|
||||
unsubscribeLogs() {
|
||||
if (this._logSubInterval) {
|
||||
clearInterval(this._logSubInterval);
|
||||
this._logSubInterval = null;
|
||||
}
|
||||
|
||||
this._logSubVaultId = null;
|
||||
}
|
||||
|
||||
_attachToWs() {
|
||||
const ws = window.__ignisWs;
|
||||
|
||||
if (!ws || ws === this._currentWs) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._detachFromWs();
|
||||
this._currentWs = ws;
|
||||
|
||||
this._handler = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
// Dispatch raw messages (for non-channel listeners like file watchers)
|
||||
if (this._rawHandler) {
|
||||
this._rawHandler(msg);
|
||||
}
|
||||
|
||||
if (msg.channel !== CHANNEL) {
|
||||
return;
|
||||
}
|
||||
|
||||
const listeners = this._callbacks.get(msg.type);
|
||||
|
||||
if (listeners) {
|
||||
for (const cb of listeners) {
|
||||
cb(msg.payload);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
ws.addEventListener("message", this._handler);
|
||||
}
|
||||
|
||||
_detachFromWs() {
|
||||
if (this._currentWs && this._handler) {
|
||||
this._currentWs.removeEventListener("message", this._handler);
|
||||
}
|
||||
|
||||
this._currentWs = null;
|
||||
this._handler = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { WsListener };
|
||||
134
apps/ignis-server/server/plugins/headless-sync/plugin/styles.css
Normal file
134
apps/ignis-server/server/plugins/headless-sync/plugin/styles.css
Normal file
@@ -0,0 +1,134 @@
|
||||
.ignis-vault-list {
|
||||
margin: 8px 0 16px;
|
||||
}
|
||||
|
||||
.ignis-vault-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-s);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ignis-vault-row-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ignis-vault-row-name {
|
||||
font-weight: var(--font-semibold);
|
||||
font-size: var(--font-ui-medium);
|
||||
}
|
||||
|
||||
.ignis-vault-row-region {
|
||||
font-size: var(--font-ui-small);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ignis-vault-connect-options {
|
||||
padding: 8px 16px 16px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 var(--radius-s) var(--radius-s);
|
||||
margin-top: -8px;
|
||||
}
|
||||
|
||||
.ignis-vault-connect-options .setting-item {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.ignis-sync-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ignis-sync-icon svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.ignis-sync-spinning svg {
|
||||
animation: ignis-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ignis-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.ignis-sync-synced svg {
|
||||
color: var(--color-green);
|
||||
}
|
||||
|
||||
.ignis-sync-syncing svg {
|
||||
color: var(--interactive-accent);
|
||||
}
|
||||
|
||||
.ignis-sync-error svg {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
.ignis-sync-stopped svg {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ignis-sync-popover {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 4px;
|
||||
background: var(--background-modifier-message);
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-smaller);
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-s);
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--shadow-s);
|
||||
}
|
||||
|
||||
.ignis-log-details {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.ignis-log-details summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-small);
|
||||
padding: 4px 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ignis-log-details summary:hover {
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.ignis-log-terminal {
|
||||
background: #1a1a1a;
|
||||
color: #d4d4d4;
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-s);
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
margin-top: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.ignis-log-terminal code {
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
230
apps/ignis-server/server/plugins/headless-sync/routes.js
Normal file
230
apps/ignis-server/server/plugins/headless-sync/routes.js
Normal file
@@ -0,0 +1,230 @@
|
||||
const auth = require("./auth");
|
||||
const obCli = require("./ob-cli");
|
||||
|
||||
function mountRoutes(router, plugin) {
|
||||
router.get("/status", (req, res) => {
|
||||
const ctx = plugin.getCtx();
|
||||
const obStatus = plugin.getObStatus();
|
||||
|
||||
const tokenInfo = auth.getTokenInfo(ctx.dataDir);
|
||||
|
||||
res.json({
|
||||
installed: obStatus?.installed || false,
|
||||
version: obStatus?.version || null,
|
||||
authenticated: auth.isAuthenticated(ctx.dataDir),
|
||||
email: tokenInfo?.email || null,
|
||||
name: tokenInfo?.name || null,
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/login", (req, res) => {
|
||||
const ctx = plugin.getCtx();
|
||||
const { token, email, name } = req.body;
|
||||
|
||||
if (!token) {
|
||||
return res.status(400).json({ error: "Token is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
auth.saveToken(ctx.dataDir, { token, email: email || null, name: name || null });
|
||||
ctx.log(`Auth token saved${email ? ` for ${email}` : ""}`);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/logout", (req, res) => {
|
||||
const ctx = plugin.getCtx();
|
||||
|
||||
try {
|
||||
auth.clearToken(ctx.dataDir);
|
||||
ctx.log("Auth token cleared");
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/setup", async (req, res) => {
|
||||
const ctx = plugin.getCtx();
|
||||
const syncManager = plugin.getSyncManager();
|
||||
const { vaultId, remoteVault, remoteVaultName, vaultPassword, deviceName, mode } = req.body;
|
||||
|
||||
if (!vaultId || !remoteVault) {
|
||||
return res.status(400).json({ error: "vaultId and remoteVault are required" });
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated(ctx.dataDir)) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
const vaultPath = ctx.config.getVaultPath(vaultId);
|
||||
|
||||
if (!vaultPath) {
|
||||
return res.status(404).json({ error: "Vault not found" });
|
||||
}
|
||||
|
||||
try {
|
||||
const state = await syncManager.setupSync(vaultId, vaultPath, remoteVault, {
|
||||
remoteVaultName,
|
||||
vaultPassword,
|
||||
deviceName,
|
||||
mode,
|
||||
});
|
||||
|
||||
res.json({ success: true, state });
|
||||
} catch (e) {
|
||||
ctx.log(`Failed to setup sync: ${e.message}`);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/start", (req, res) => {
|
||||
const ctx = plugin.getCtx();
|
||||
const syncManager = plugin.getSyncManager();
|
||||
const { vaultId } = req.body;
|
||||
|
||||
if (!vaultId) {
|
||||
return res.status(400).json({ error: "vaultId is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const state = syncManager.startSync(vaultId);
|
||||
res.json({ success: true, state });
|
||||
} catch (e) {
|
||||
ctx.log(`Failed to start sync: ${e.message}`);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/stop", (req, res) => {
|
||||
const ctx = plugin.getCtx();
|
||||
const syncManager = plugin.getSyncManager();
|
||||
const { vaultId } = req.body;
|
||||
|
||||
if (!vaultId) {
|
||||
return res.status(400).json({ error: "vaultId is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const state = syncManager.stopSync(vaultId);
|
||||
res.json({ success: true, state });
|
||||
} catch (e) {
|
||||
ctx.log(`Failed to stop sync: ${e.message}`);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/unlink", async (req, res) => {
|
||||
const ctx = plugin.getCtx();
|
||||
const syncManager = plugin.getSyncManager();
|
||||
const { vaultId } = req.body;
|
||||
|
||||
if (!vaultId) {
|
||||
return res.status(400).json({ error: "vaultId is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
await syncManager.unlinkVault(vaultId);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
ctx.log(`Failed to unlink vault: ${e.message}`);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/logs", (req, res) => {
|
||||
const syncManager = plugin.getSyncManager();
|
||||
const { vaultId, limit } = req.query;
|
||||
|
||||
if (!vaultId) {
|
||||
return res.status(400).json({ error: "vaultId is required" });
|
||||
}
|
||||
|
||||
const logs = syncManager.getLogs(vaultId, limit ? parseInt(limit) : 100);
|
||||
res.json({ logs });
|
||||
});
|
||||
|
||||
router.get("/vaults", (req, res) => {
|
||||
const syncManager = plugin.getSyncManager();
|
||||
res.json({ vaults: syncManager.getAllStates() });
|
||||
});
|
||||
|
||||
router.post("/create-remote-vault", async (req, res) => {
|
||||
const ctx = plugin.getCtx();
|
||||
const { name, encryption, password, region } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: "name is required" });
|
||||
}
|
||||
|
||||
if (!auth.isAuthenticated(ctx.dataDir)) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
const args = ["sync-create-remote", "--name", name];
|
||||
|
||||
if (encryption) {
|
||||
args.push("--encryption", encryption);
|
||||
}
|
||||
|
||||
if (password) {
|
||||
args.push("--password", password);
|
||||
}
|
||||
|
||||
if (region) {
|
||||
args.push("--region", region);
|
||||
}
|
||||
|
||||
try {
|
||||
await obCli.runCommand(args);
|
||||
ctx.log(`Created remote vault: ${name}`);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
ctx.log(`Failed to create remote vault: ${e.message}`);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/remote-vaults", async (req, res) => {
|
||||
const ctx = plugin.getCtx();
|
||||
|
||||
if (!auth.isAuthenticated(ctx.dataDir)) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await obCli.runCommand(["sync-list-remote"]);
|
||||
const vaults = parseRemoteVaults(result.stdout);
|
||||
res.json({ vaults });
|
||||
} catch (e) {
|
||||
ctx.log(`Failed to list remote vaults: ${e.message}`);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseRemoteVaults(stdout) {
|
||||
const lines = stdout.trim().split("\n");
|
||||
const vaults = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed || trimmed.startsWith("Available")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Format: [vaultId] "[vaultName]" ([region])
|
||||
const match = trimmed.match(/^([a-f0-9]+)\s+"([^"]+)"\s+\(([^)]+)\)/);
|
||||
|
||||
if (match) {
|
||||
vaults.push({ id: match[1], name: match[2], region: match[3] });
|
||||
}
|
||||
}
|
||||
|
||||
return vaults;
|
||||
}
|
||||
|
||||
module.exports = { mountRoutes };
|
||||
371
apps/ignis-server/server/plugins/headless-sync/sync-manager.js
Normal file
371
apps/ignis-server/server/plugins/headless-sync/sync-manager.js
Normal file
@@ -0,0 +1,371 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
const { spawnOb, runCommand } = require("./ob-cli");
|
||||
|
||||
const MAX_LOG_ENTRIES = 200;
|
||||
|
||||
function killProcess(proc) {
|
||||
if (!proc) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
spawn("taskkill", ["/pid", String(proc.pid), "/t", "/f"]);
|
||||
} else {
|
||||
proc.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
|
||||
class SyncManager {
|
||||
constructor(ctx, broadcaster) {
|
||||
this.ctx = ctx;
|
||||
this.broadcaster = broadcaster;
|
||||
this.states = new Map();
|
||||
this.stateFile = path.join(ctx.dataDir, "sync-states.json");
|
||||
}
|
||||
|
||||
loadStates(vaults) {
|
||||
try {
|
||||
const saved = JSON.parse(fs.readFileSync(this.stateFile, "utf-8"));
|
||||
|
||||
for (const entry of saved) {
|
||||
const vaultPath = vaults[entry.vaultId];
|
||||
|
||||
if (!vaultPath) {
|
||||
this.ctx.log(`Skipping state for missing vault: ${entry.vaultId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.states.set(entry.vaultId, {
|
||||
vaultId: entry.vaultId,
|
||||
vaultPath,
|
||||
remoteVault: entry.remoteVault,
|
||||
remoteVaultName: entry.remoteVaultName || null,
|
||||
status: "stopped",
|
||||
pid: null,
|
||||
lastActivity: new Date().toISOString(),
|
||||
error: null,
|
||||
config: entry.config || {
|
||||
mode: "bidirectional",
|
||||
deviceName: "ignis-headless",
|
||||
},
|
||||
autoStart: entry.autoStart || false,
|
||||
logs: [],
|
||||
_process: null,
|
||||
});
|
||||
}
|
||||
|
||||
this.ctx.log(`Loaded ${saved.length} sync configurations`);
|
||||
} catch {
|
||||
this.ctx.log("No previous sync states found");
|
||||
}
|
||||
}
|
||||
|
||||
saveStates() {
|
||||
const data = [];
|
||||
|
||||
for (const [vaultId, state] of this.states) {
|
||||
data.push({
|
||||
vaultId: state.vaultId,
|
||||
vaultPath: state.vaultPath,
|
||||
remoteVault: state.remoteVault,
|
||||
remoteVaultName: state.remoteVaultName,
|
||||
config: state.config,
|
||||
autoStart: state.autoStart,
|
||||
});
|
||||
}
|
||||
|
||||
fs.writeFileSync(this.stateFile, JSON.stringify(data, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
async setupSync(vaultId, vaultPath, remoteVault, options = {}) {
|
||||
const args = ["sync-setup", "--vault", remoteVault, "--path", "."];
|
||||
|
||||
if (options.vaultPassword) {
|
||||
args.push("--password", options.vaultPassword);
|
||||
}
|
||||
|
||||
if (options.deviceName) {
|
||||
args.push("--device-name", options.deviceName);
|
||||
}
|
||||
|
||||
await runCommand(args, { cwd: vaultPath });
|
||||
|
||||
const state = {
|
||||
vaultId,
|
||||
vaultPath,
|
||||
remoteVault,
|
||||
remoteVaultName: options.remoteVaultName || null,
|
||||
status: "stopped",
|
||||
pid: null,
|
||||
lastActivity: new Date().toISOString(),
|
||||
error: null,
|
||||
config: {
|
||||
mode: options.mode || "bidirectional",
|
||||
deviceName: options.deviceName || "ignis-headless",
|
||||
},
|
||||
autoStart: false,
|
||||
logs: [],
|
||||
_process: null,
|
||||
};
|
||||
|
||||
this.states.set(vaultId, state);
|
||||
this.saveStates();
|
||||
this.ctx.log(`Sync setup complete for ${vaultId} -> ${remoteVault}`);
|
||||
|
||||
return this.getState(vaultId);
|
||||
}
|
||||
|
||||
startSync(vaultId) {
|
||||
const state = this.states.get(vaultId);
|
||||
|
||||
if (!state) {
|
||||
throw new Error(`No sync configuration for vault: ${vaultId}`);
|
||||
}
|
||||
|
||||
if (state.status === "running") {
|
||||
this.ctx.log(`Sync already running for ${vaultId}`);
|
||||
return this.getState(vaultId);
|
||||
}
|
||||
|
||||
const args = ["sync", "--continuous"];
|
||||
|
||||
if (state.config.mode === "pull-only") {
|
||||
args.push("--pull-only");
|
||||
} else if (state.config.mode === "mirror-remote") {
|
||||
args.push("--mirror-remote");
|
||||
}
|
||||
|
||||
const proc = spawnOb(args, { cwd: state.vaultPath });
|
||||
|
||||
state.status = "running";
|
||||
state.pid = proc.pid;
|
||||
state.error = null;
|
||||
state.autoStart = true;
|
||||
state._process = proc;
|
||||
|
||||
this.addLog(state, `Sync started (pid: ${proc.pid})`);
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
const lines = data.toString().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
this.addLog(state, line.trim());
|
||||
state.lastActivity = new Date().toISOString();
|
||||
this.broadcaster.broadcastLog(vaultId, line.trim());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
const lines = data.toString().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
this.addLog(state, `[stderr] ${line.trim()}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
// If the user explicitly stopped sync, don't overwrite the clean
|
||||
// "stopped" state with an error from the non-zero exit code.
|
||||
if (state._userStopped) {
|
||||
state._userStopped = false;
|
||||
return;
|
||||
}
|
||||
|
||||
state.status = code === 0 ? "stopped" : "error";
|
||||
state.pid = null;
|
||||
state._process = null;
|
||||
|
||||
if (code !== 0) {
|
||||
state.error = `Process exited with code ${code}`;
|
||||
this.addLog(state, `Sync exited with code ${code}`);
|
||||
} else {
|
||||
this.addLog(state, "Sync stopped");
|
||||
}
|
||||
|
||||
this.ctx.log(`Sync stopped for ${vaultId} (code: ${code})`);
|
||||
this.broadcaster.broadcastStatus(this.getState(vaultId));
|
||||
this.saveStates();
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
state.status = "error";
|
||||
state.error = err.message;
|
||||
state.pid = null;
|
||||
state._process = null;
|
||||
|
||||
this.addLog(state, `Error: ${err.message}`);
|
||||
this.ctx.log(`Sync error for ${vaultId}: ${err.message}`);
|
||||
this.broadcaster.broadcastStatus(this.getState(vaultId));
|
||||
this.saveStates();
|
||||
});
|
||||
|
||||
this.broadcaster.broadcastStatus(this.getState(vaultId));
|
||||
this.ctx.log(`Started sync for ${vaultId} (pid: ${proc.pid})`);
|
||||
this.saveStates();
|
||||
|
||||
return this.getState(vaultId);
|
||||
}
|
||||
|
||||
stopSync(vaultId) {
|
||||
const state = this.states.get(vaultId);
|
||||
|
||||
if (!state || !state._process) {
|
||||
throw new Error(`No active sync for vault: ${vaultId}`);
|
||||
}
|
||||
|
||||
state._userStopped = true;
|
||||
killProcess(state._process);
|
||||
state.status = "stopped";
|
||||
state.pid = null;
|
||||
state.autoStart = false;
|
||||
state._process = null;
|
||||
|
||||
this.addLog(state, "Sync stopped by user");
|
||||
this.ctx.log(`Stopped sync for ${vaultId}`);
|
||||
this.broadcaster.broadcastStatus(this.getState(vaultId));
|
||||
this.saveStates();
|
||||
|
||||
return this.getState(vaultId);
|
||||
}
|
||||
|
||||
async unlinkVault(vaultId) {
|
||||
const state = this.states.get(vaultId);
|
||||
|
||||
if (!state) {
|
||||
throw new Error(`No sync configuration for vault: ${vaultId}`);
|
||||
}
|
||||
|
||||
if (state._process) {
|
||||
state._userStopped = true;
|
||||
killProcess(state._process);
|
||||
}
|
||||
|
||||
// Tell ob to disconnect from the remote vault and clear its stored config
|
||||
try {
|
||||
await runCommand(["sync-unlink", "--path", state.vaultPath]);
|
||||
this.ctx.log(`ob sync-unlink completed for ${vaultId}`);
|
||||
} catch (e) {
|
||||
this.ctx.log(`ob sync-unlink failed for ${vaultId}: ${e.message}`);
|
||||
}
|
||||
|
||||
this.states.delete(vaultId);
|
||||
this.saveStates();
|
||||
this.ctx.log(`Unlinked vault ${vaultId}`);
|
||||
}
|
||||
|
||||
getState(vaultId) {
|
||||
const state = this.states.get(vaultId);
|
||||
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
vaultId: state.vaultId,
|
||||
remoteVault: state.remoteVault,
|
||||
remoteVaultName: state.remoteVaultName,
|
||||
status: state.status,
|
||||
pid: state.pid,
|
||||
lastActivity: state.lastActivity,
|
||||
error: state.error,
|
||||
config: state.config,
|
||||
autoStart: state.autoStart,
|
||||
};
|
||||
}
|
||||
|
||||
getAllStates() {
|
||||
const result = [];
|
||||
|
||||
for (const [vaultId] of this.states) {
|
||||
result.push(this.getState(vaultId));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
getLogs(vaultId, limit = 100) {
|
||||
const state = this.states.get(vaultId);
|
||||
|
||||
if (!state) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return state.logs.slice(-limit);
|
||||
}
|
||||
|
||||
addLog(state, line) {
|
||||
state.logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
line,
|
||||
});
|
||||
|
||||
if (state.logs.length > MAX_LOG_ENTRIES) {
|
||||
state.logs = state.logs.slice(-MAX_LOG_ENTRIES);
|
||||
}
|
||||
}
|
||||
|
||||
autoStartAll() {
|
||||
let started = 0;
|
||||
|
||||
for (const [vaultId, state] of this.states) {
|
||||
if (state.autoStart && state.status === "stopped") {
|
||||
try {
|
||||
this.startSync(vaultId);
|
||||
started++;
|
||||
} catch (e) {
|
||||
this.ctx.log(`Auto-start failed for ${vaultId}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (started > 0) {
|
||||
this.ctx.log(`Auto-started sync for ${started} vault(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
this.ctx.log("Shutting down sync manager...");
|
||||
|
||||
const waitPromises = [];
|
||||
|
||||
for (const [vaultId, state] of this.states) {
|
||||
if (state._process) {
|
||||
this.ctx.log(`Stopping sync for ${vaultId}...`);
|
||||
state._userStopped = true;
|
||||
|
||||
const proc = state._process;
|
||||
|
||||
waitPromises.push(
|
||||
new Promise((resolve) => {
|
||||
const timeout = setTimeout(resolve, 5000);
|
||||
|
||||
proc.on("close", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
killProcess(proc);
|
||||
} catch (e) {
|
||||
this.ctx.log(`Error stopping sync for ${vaultId}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (waitPromises.length > 0) {
|
||||
await Promise.all(waitPromises);
|
||||
}
|
||||
|
||||
this.saveStates();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SyncManager };
|
||||
259
apps/ignis-server/server/routes/bootstrap.js
vendored
Normal file
259
apps/ignis-server/server/routes/bootstrap.js
vendored
Normal file
@@ -0,0 +1,259 @@
|
||||
// Bootstrap endpoint for cold start.
|
||||
//
|
||||
// Combines vault info, vault list, metadata tree, and plugin list into a single pre-compressed response.
|
||||
// Cache is per-vault and invalidated by directory mtime check + explicit invalidateVault() calls from the write/delete routes.
|
||||
|
||||
const express = require("express");
|
||||
const fs = require("fs");
|
||||
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 router = express.Router();
|
||||
|
||||
// vaultId -> { response, dirMtimes, compressed: { br, gz } }
|
||||
const cache = new Map();
|
||||
|
||||
// vaultId -> Promise<entry> (in-flight build dedup)
|
||||
const pendingBuilds = new Map();
|
||||
|
||||
function preCompress(buf) {
|
||||
return Promise.all([
|
||||
new Promise((resolve, reject) => {
|
||||
zlib.brotliCompress(
|
||||
buf,
|
||||
{ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } },
|
||||
(err, result) => (err ? reject(err) : resolve(result)),
|
||||
);
|
||||
}),
|
||||
new Promise((resolve, reject) => {
|
||||
zlib.gzip(buf, { level: 6 }, (err, result) =>
|
||||
err ? reject(err) : resolve(result),
|
||||
);
|
||||
}),
|
||||
]).then(([br, gz]) => ({ br, gz }));
|
||||
}
|
||||
|
||||
async function walkTree(rootPath) {
|
||||
const tree = {};
|
||||
const dirMtimes = {};
|
||||
|
||||
async function walk(dir, prefix) {
|
||||
const stat = await fsp.stat(dir);
|
||||
dirMtimes[prefix] = stat.mtimeMs;
|
||||
|
||||
const entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const rel = prefix ? prefix + "/" + entry.name : entry.name;
|
||||
const full = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
tree[rel] = { type: "directory" };
|
||||
await walk(full, rel);
|
||||
} else {
|
||||
try {
|
||||
const s = await fsp.stat(full);
|
||||
|
||||
tree[rel] = {
|
||||
type: "file",
|
||||
size: s.size,
|
||||
mtime: s.mtimeMs,
|
||||
ctime: s.ctimeMs,
|
||||
};
|
||||
} catch {
|
||||
tree[rel] = { type: "file" };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(rootPath, "");
|
||||
|
||||
return { tree, dirMtimes };
|
||||
}
|
||||
|
||||
async function buildVaultInfo(vaultId, vaultPath) {
|
||||
const pluginInstalled = await isBridgePluginInstalled(vaultPath);
|
||||
const ignisMeta = await getIgnisMeta(vaultPath);
|
||||
|
||||
return {
|
||||
id: vaultId,
|
||||
name: vaultId,
|
||||
path: vaultPath,
|
||||
platform: process.platform,
|
||||
version: config.obsidianVersion,
|
||||
ignisPlugin: {
|
||||
installed: pluginInstalled,
|
||||
prompted: ignisMeta.pluginPrompted || false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildVaultList() {
|
||||
return Object.entries(config.vaults).map(([id, vaultPath]) => ({
|
||||
id,
|
||||
name: id,
|
||||
path: vaultPath,
|
||||
}));
|
||||
}
|
||||
|
||||
async function dirMtimesUnchanged(vaultPath, dirMtimes) {
|
||||
const checks = await Promise.all(
|
||||
Object.entries(dirMtimes).map(async ([relDir, oldMtime]) => {
|
||||
const absDir = relDir
|
||||
? path.join(vaultPath, relDir.split("/").join(path.sep))
|
||||
: vaultPath;
|
||||
|
||||
try {
|
||||
const s = await fsp.stat(absDir);
|
||||
return s.mtimeMs === oldMtime;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return checks.every(Boolean);
|
||||
}
|
||||
|
||||
async function buildEntry(vaultId) {
|
||||
const vaultPath = config.getVaultPath(vaultId);
|
||||
|
||||
if (!vaultPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = cache.get(vaultId);
|
||||
|
||||
if (cached && (await dirMtimesUnchanged(vaultPath, cached.dirMtimes))) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const t0 = Date.now();
|
||||
const [vault, { tree, dirMtimes }] = await Promise.all([
|
||||
buildVaultInfo(vaultId, vaultPath),
|
||||
walkTree(vaultPath),
|
||||
]);
|
||||
|
||||
const response = {
|
||||
vault,
|
||||
vaultList: buildVaultList(),
|
||||
tree,
|
||||
// In demo mode, hide server-side plugins from the client.
|
||||
plugins: config.demoMode ? [] : getDiscoveredPlugins(),
|
||||
};
|
||||
|
||||
const jsonBuf = Buffer.from(JSON.stringify(response));
|
||||
let compressed = {};
|
||||
|
||||
try {
|
||||
compressed = await preCompress(jsonBuf);
|
||||
} catch (e) {
|
||||
console.warn("[bootstrap] precompression failed:", e.message);
|
||||
}
|
||||
|
||||
const entry = { response, dirMtimes, compressed };
|
||||
cache.set(vaultId, entry);
|
||||
|
||||
const ms = Date.now() - t0;
|
||||
const fileCount = Object.keys(tree).filter(
|
||||
(k) => tree[k].type === "file",
|
||||
).length;
|
||||
const dirCount = Object.keys(dirMtimes).length;
|
||||
|
||||
console.log(
|
||||
`[bootstrap] vault=${vaultId} build files=${fileCount} dirs=${dirCount} time=${ms}ms`,
|
||||
);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function getOrBuild(vaultId) {
|
||||
if (pendingBuilds.has(vaultId)) {
|
||||
return pendingBuilds.get(vaultId);
|
||||
}
|
||||
|
||||
const promise = buildEntry(vaultId).finally(() => {
|
||||
pendingBuilds.delete(vaultId);
|
||||
});
|
||||
|
||||
pendingBuilds.set(vaultId, promise);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
function invalidateVault(vaultId) {
|
||||
cache.delete(vaultId);
|
||||
}
|
||||
|
||||
async function warmUp() {
|
||||
const ids = Object.keys(config.vaults);
|
||||
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await buildEntry(id);
|
||||
} catch (e) {
|
||||
console.warn(`[bootstrap] warm-up failed for vault ${id}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
router.get("/", async (req, res) => {
|
||||
const vaultId = req.query.vault || config.defaultVaultId;
|
||||
|
||||
if (!vaultId || !config.getVaultPath(vaultId)) {
|
||||
return res.status(404).json({ error: "Vault not found", id: vaultId });
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = await getOrBuild(vaultId);
|
||||
|
||||
if (!entry) {
|
||||
return res.status(404).json({ error: "Vault not found" });
|
||||
}
|
||||
|
||||
// In demo mode, route through res.json so the demo middleware can translate vault names per-session.
|
||||
// The pre-compressed buffer path bakes the storage prefix in and would bypass the response wrapper.
|
||||
// Deep-clone so the demo translator's in-place mutation doesn't pollute the cached response object.
|
||||
if (req._demoSessionId) {
|
||||
return res.json(JSON.parse(JSON.stringify(entry.response)));
|
||||
}
|
||||
|
||||
const ae = req.headers["accept-encoding"] || "";
|
||||
const { compressed } = entry;
|
||||
let buf, encoding;
|
||||
|
||||
if (ae.includes("br") && compressed.br) {
|
||||
buf = compressed.br;
|
||||
encoding = "br";
|
||||
} else if (
|
||||
(ae.includes("gzip") || ae.includes("deflate")) &&
|
||||
compressed.gz
|
||||
) {
|
||||
buf = compressed.gz;
|
||||
encoding = "gzip";
|
||||
}
|
||||
|
||||
if (buf) {
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.setHeader("Content-Encoding", encoding);
|
||||
res.setHeader("Content-Length", buf.length);
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
|
||||
return res.status(200).end(buf);
|
||||
}
|
||||
|
||||
res.json(entry.response);
|
||||
} catch (e) {
|
||||
console.error("[bootstrap] error:", e);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.invalidateVault = invalidateVault;
|
||||
module.exports.warmUp = warmUp;
|
||||
598
apps/ignis-server/server/routes/fs.js
Normal file
598
apps/ignis-server/server/routes/fs.js
Normal file
@@ -0,0 +1,598 @@
|
||||
const express = require("express");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const archiver = require("archiver");
|
||||
const config = require("../config");
|
||||
const {
|
||||
writeCoalescer,
|
||||
encodeContentDispositionFilename,
|
||||
resolveVaultPath,
|
||||
} = require("@ignis/server-core");
|
||||
const { writeCoalesced, getPending } = writeCoalescer;
|
||||
const bootstrapRoutes = require("./bootstrap");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 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;
|
||||
const vaultPath = config.getVaultPath(vaultId);
|
||||
|
||||
if (!vaultPath) {
|
||||
res.status(404).json({ error: "Vault not found", id: vaultId });
|
||||
return null;
|
||||
}
|
||||
|
||||
req._vaultId = vaultId;
|
||||
return vaultPath;
|
||||
}
|
||||
|
||||
function invalidateBootstrap(req) {
|
||||
if (req._vaultId) {
|
||||
bootstrapRoutes.invalidateVault(req._vaultId);
|
||||
}
|
||||
}
|
||||
|
||||
function guardPath(req, res, source = "query") {
|
||||
const vaultRoot = getVaultRoot(req, res);
|
||||
|
||||
if (!vaultRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const p = source === "body" ? req.body?.path : req.query.path;
|
||||
|
||||
if (p === undefined || p === null) {
|
||||
res.status(400).json({ error: "Missing path parameter" });
|
||||
return null;
|
||||
}
|
||||
|
||||
// Empty string = vault root, which is valid
|
||||
const resolved = resolveVaultPath(vaultRoot, p);
|
||||
|
||||
if (!resolved) {
|
||||
res.status(403).json({ error: "Path traversal rejected" });
|
||||
return null;
|
||||
}
|
||||
|
||||
req._vaultRoot = vaultRoot;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// GET /api/fs/stat?path=...
|
||||
router.get("/stat", async (req, res) => {
|
||||
const resolved = guardPath(req, res);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// If a coalesced write is pending, report its size instead of stale disk data
|
||||
const buffered = getPending(resolved);
|
||||
|
||||
if (buffered) {
|
||||
const diskStat = await fs.promises.stat(resolved).catch(() => null);
|
||||
const size = Buffer.isBuffer(buffered.data)
|
||||
? buffered.data.length
|
||||
: Buffer.byteLength(buffered.data, buffered.encoding || "utf-8");
|
||||
|
||||
res.json({
|
||||
type: "file",
|
||||
size,
|
||||
mtime: Date.now(),
|
||||
ctime: diskStat ? diskStat.ctimeMs : Date.now(),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const stat = await fs.promises.stat(resolved);
|
||||
|
||||
res.json({
|
||||
type: stat.isDirectory() ? "directory" : "file",
|
||||
size: stat.size,
|
||||
mtime: stat.mtimeMs,
|
||||
ctime: stat.ctimeMs,
|
||||
});
|
||||
} catch (e) {
|
||||
res
|
||||
.status(e.code === "ENOENT" ? 404 : 500)
|
||||
.json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/fs/readdir?path=...
|
||||
router.get("/readdir", async (req, res) => {
|
||||
const resolved = guardPath(req, res);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if path is a file. return ENOTDIR instead of crashing
|
||||
const stat = await fs.promises.stat(resolved);
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "ENOTDIR: not a directory", code: "ENOTDIR" });
|
||||
}
|
||||
|
||||
const entries = await fs.promises.readdir(resolved, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
res.json(
|
||||
entries.map((e) => ({
|
||||
name: e.name,
|
||||
type: e.isDirectory() ? "directory" : "file",
|
||||
})),
|
||||
);
|
||||
} catch (e) {
|
||||
res
|
||||
.status(e.code === "ENOENT" ? 404 : 500)
|
||||
.json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/fs/readFile?path=...&encoding=...
|
||||
router.get("/readFile", async (req, res) => {
|
||||
const resolved = guardPath(req, res);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(resolved);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return res.status(400).json({
|
||||
error: "EISDIR: illegal operation on a directory",
|
||||
code: "EISDIR",
|
||||
});
|
||||
}
|
||||
|
||||
// Serve buffered content if a coalesced write is pending for this path
|
||||
const buffered = getPending(resolved);
|
||||
|
||||
if (buffered) {
|
||||
const encoding = req.query.encoding;
|
||||
|
||||
if (encoding === "utf8" || encoding === "utf-8") {
|
||||
res.type("text/plain").send(buffered.data);
|
||||
} else {
|
||||
res.type("application/octet-stream").send(buffered.data);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const encoding = req.query.encoding;
|
||||
|
||||
if (encoding === "utf8" || encoding === "utf-8") {
|
||||
const data = await fs.promises.readFile(resolved, "utf-8");
|
||||
|
||||
res.type("text/plain").send(data);
|
||||
} else {
|
||||
const data = await fs.promises.readFile(resolved);
|
||||
|
||||
res.type("application/octet-stream").send(data);
|
||||
}
|
||||
} catch (e) {
|
||||
res
|
||||
.status(e.code === "ENOENT" ? 404 : 500)
|
||||
.json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/fs/writeFile { path, content, encoding?, vault? }
|
||||
router.post("/writeFile", async (req, res) => {
|
||||
const resolved = guardPath(req, res, "body");
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure parent directory exists
|
||||
const dir = path.dirname(resolved);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
|
||||
const encoding = req.body.encoding || "utf-8";
|
||||
let data = req.body.content;
|
||||
|
||||
if (req.body.base64) {
|
||||
data = Buffer.from(req.body.content, "base64");
|
||||
}
|
||||
|
||||
const result = await writeCoalesced(resolved, data, encoding);
|
||||
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true, mtime: result.mtime, size: result.size });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/fs/appendFile { path, content, vault? }
|
||||
router.post("/appendFile", async (req, res) => {
|
||||
const resolved = guardPath(req, res, "body");
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.appendFile(resolved, req.body.content, "utf-8");
|
||||
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/fs/mkdir { path, recursive?, vault? }
|
||||
router.post("/mkdir", async (req, res) => {
|
||||
const resolved = guardPath(req, res, "body");
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(resolved, {
|
||||
recursive: !!req.body.recursive,
|
||||
});
|
||||
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/fs/rename { oldPath, newPath, vault? }
|
||||
router.post("/rename", async (req, res) => {
|
||||
const vaultRoot = getVaultRoot(req, res);
|
||||
|
||||
if (!vaultRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.rename(oldResolved, newResolved);
|
||||
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/fs/copyFile { src, dest, vault? }
|
||||
router.post("/copyFile", async (req, res) => {
|
||||
const vaultRoot = getVaultRoot(req, res);
|
||||
|
||||
if (!vaultRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
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" });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.copyFile(srcResolved, destResolved);
|
||||
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/fs/unlink?path=...
|
||||
router.delete("/unlink", async (req, res) => {
|
||||
const resolved = guardPath(req, res);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.unlink(resolved);
|
||||
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") {
|
||||
// File already gone - desired outcome achieved
|
||||
res.json({ ok: true });
|
||||
} else {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/fs/rmdir?path=...
|
||||
router.delete("/rmdir", async (req, res) => {
|
||||
const resolved = guardPath(req, res);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.rmdir(resolved);
|
||||
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/fs/rm?path=...&recursive=true
|
||||
router.delete("/rm", async (req, res) => {
|
||||
const resolved = guardPath(req, res);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.rm(resolved, {
|
||||
recursive: req.query.recursive === "true",
|
||||
});
|
||||
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/access", async (req, res) => {
|
||||
const resolved = guardPath(req, res);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.access(resolved);
|
||||
|
||||
res.json({ ok: true });
|
||||
} 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 });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/fs/utimes { path, atime, mtime, vault? }
|
||||
router.post("/utimes", async (req, res) => {
|
||||
const resolved = guardPath(req, res, "body");
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.utimes(
|
||||
resolved,
|
||||
req.body.atime / 1000,
|
||||
req.body.mtime / 1000,
|
||||
);
|
||||
|
||||
invalidateBootstrap(req);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/fs/batch-read { paths, vault } - bulk read text file contents
|
||||
// Used by the indexer pre-fetcher to avoid N round trips during startup.
|
||||
router.post("/batch-read", async (req, res) => {
|
||||
const vaultRoot = getVaultRoot(req, res);
|
||||
|
||||
if (!vaultRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paths = Array.isArray(req.body?.paths) ? req.body.paths : [];
|
||||
|
||||
if (paths.length === 0) {
|
||||
return res.json({ files: {} });
|
||||
}
|
||||
|
||||
const files = {};
|
||||
|
||||
await Promise.all(
|
||||
paths.map(async (relPath) => {
|
||||
const resolved = resolveVaultPath(vaultRoot, relPath);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const buffered = getPending(resolved);
|
||||
|
||||
if (buffered) {
|
||||
if (typeof buffered.data === "string") {
|
||||
files[relPath] = buffered.data;
|
||||
} else if (
|
||||
buffered.encoding === "utf8" ||
|
||||
buffered.encoding === "utf-8"
|
||||
) {
|
||||
files[relPath] = buffered.data.toString("utf-8");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await fs.promises.readFile(resolved, "utf-8");
|
||||
files[relPath] = data;
|
||||
} catch {
|
||||
// Skip unreadable files silently. The client falls back to a
|
||||
// normal readFile when a path isn't in the response.
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
res.json({ files });
|
||||
});
|
||||
|
||||
// GET /api/fs/tree?path=...&vault=... returns full recursive file tree with metadata
|
||||
router.get("/tree", async (req, res) => {
|
||||
const vaultRoot = getVaultRoot(req, res);
|
||||
|
||||
if (!vaultRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootPath = req.query.path
|
||||
? resolveVaultPath(vaultRoot, req.query.path)
|
||||
: vaultRoot;
|
||||
|
||||
if (!rootPath) {
|
||||
return res.status(403).json({ error: "Invalid path" });
|
||||
}
|
||||
|
||||
try {
|
||||
const tree = {};
|
||||
|
||||
async function walk(dir, prefix) {
|
||||
const entries = await fs.promises.readdir(dir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
const rel = prefix ? prefix + "/" + entry.name : entry.name;
|
||||
const full = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
tree[rel] = { type: "directory" };
|
||||
|
||||
await walk(full, rel);
|
||||
} else {
|
||||
const stat = await fs.promises.stat(full);
|
||||
|
||||
tree[rel] = {
|
||||
type: "file",
|
||||
size: stat.size,
|
||||
mtime: stat.mtimeMs,
|
||||
ctime: stat.ctimeMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(rootPath, "");
|
||||
|
||||
res.json(tree);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/fs/download?path=...&vault=...
|
||||
router.get("/download", async (req, res) => {
|
||||
const resolved = guardPath(req, res);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(resolved);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Use /download-zip for directories" });
|
||||
}
|
||||
|
||||
const filename = path.basename(resolved);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
encodeContentDispositionFilename(filename),
|
||||
);
|
||||
res.sendFile(resolved);
|
||||
} catch (e) {
|
||||
res
|
||||
.status(e.code === "ENOENT" ? 404 : 500)
|
||||
.json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/fs/download-zip?path=...&vault=...
|
||||
router.get("/download-zip", async (req, res) => {
|
||||
const resolved = guardPath(req, res);
|
||||
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(resolved);
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
return res.status(400).json({ error: "Not a directory" });
|
||||
}
|
||||
|
||||
const folderName = path.basename(resolved);
|
||||
res.setHeader("Content-Type", "application/zip");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
encodeContentDispositionFilename(folderName + ".zip"),
|
||||
);
|
||||
|
||||
const archive = archiver("zip", { zlib: { level: 5 } });
|
||||
|
||||
archive.on("error", (err) => {
|
||||
res.status(500).end();
|
||||
});
|
||||
|
||||
archive.pipe(res);
|
||||
archive.directory(resolved, folderName);
|
||||
archive.finalize();
|
||||
} catch (e) {
|
||||
res
|
||||
.status(e.code === "ENOENT" ? 404 : 500)
|
||||
.json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
109
apps/ignis-server/server/routes/fs.test.mjs
Normal file
109
apps/ignis-server/server/routes/fs.test.mjs
Normal file
@@ -0,0 +1,109 @@
|
||||
import path from "path";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
resolveVaultPath,
|
||||
encodeContentDispositionFilename,
|
||||
} = require("@ignis/server-core");
|
||||
|
||||
// -- encodeContentDispositionFilename --------------------------------
|
||||
|
||||
describe("encodeContentDispositionFilename", () => {
|
||||
it("handles a plain ASCII filename", () => {
|
||||
expect(encodeContentDispositionFilename("report.pdf")).toBe(
|
||||
'attachment; filename="report.pdf"',
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves spaces in quotes", () => {
|
||||
expect(encodeContentDispositionFilename("my report.pdf")).toBe(
|
||||
'attachment; filename="my report.pdf"',
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes double quotes", () => {
|
||||
const result = encodeContentDispositionFilename('file"name.txt');
|
||||
expect(result).toBe('attachment; filename="file\\"name.txt"');
|
||||
});
|
||||
|
||||
it("escapes backslashes", () => {
|
||||
const result = encodeContentDispositionFilename("path\\to\\file.txt");
|
||||
expect(result).toBe('attachment; filename="path\\\\to\\\\file.txt"');
|
||||
});
|
||||
|
||||
it("produces ASCII fallback and filename* for unicode", () => {
|
||||
const result = encodeContentDispositionFilename(
|
||||
"\u65E5\u672C\u8A9Enotes.md",
|
||||
);
|
||||
expect(result).toContain('filename="___notes.md"');
|
||||
expect(result).toContain("filename*=UTF-8''");
|
||||
expect(result).toContain("%E6%97%A5");
|
||||
});
|
||||
|
||||
it("replaces only non-ASCII in the fallback for mixed filenames", () => {
|
||||
const result = encodeContentDispositionFilename("report_2024\u5E74.pdf");
|
||||
expect(result).toContain('filename="report_2024_.pdf"');
|
||||
expect(result).toContain("filename*=UTF-8''");
|
||||
});
|
||||
|
||||
it("strips control characters", () => {
|
||||
const result = encodeContentDispositionFilename("bad\x00file\x1F.txt");
|
||||
expect(result).toBe('attachment; filename="badfile.txt"');
|
||||
});
|
||||
|
||||
it("does not crash on empty string", () => {
|
||||
const result = encodeContentDispositionFilename("");
|
||||
expect(result).toBe('attachment; filename=""');
|
||||
});
|
||||
});
|
||||
|
||||
// -- resolveVaultPath ------------------------------------------------
|
||||
|
||||
describe("resolveVaultPath", () => {
|
||||
const root = "/vaults/test";
|
||||
|
||||
it("resolves a simple relative path", () => {
|
||||
const result = resolveVaultPath(root, "notes/daily.md");
|
||||
expect(result).toBe(path.resolve(root, "notes/daily.md"));
|
||||
});
|
||||
|
||||
it("resolves empty string to vault root", () => {
|
||||
expect(resolveVaultPath(root, "")).toBe(path.resolve(root));
|
||||
});
|
||||
|
||||
it("allows a path that equals the vault root exactly", () => {
|
||||
expect(resolveVaultPath(root, "")).toBe(path.resolve(root));
|
||||
});
|
||||
|
||||
it("treats null input as vault root", () => {
|
||||
expect(resolveVaultPath(root, null)).toBe(path.resolve(root));
|
||||
});
|
||||
|
||||
it("treats undefined input as vault root", () => {
|
||||
expect(resolveVaultPath(root, undefined)).toBe(path.resolve(root));
|
||||
});
|
||||
|
||||
it("strips leading slashes", () => {
|
||||
const result = resolveVaultPath(root, "///notes/daily.md");
|
||||
expect(result).toBe(path.resolve(root, "notes/daily.md"));
|
||||
});
|
||||
|
||||
it("resolves ./ segments correctly", () => {
|
||||
const result = resolveVaultPath(root, "./notes/../notes/daily.md");
|
||||
expect(result).toBe(path.resolve(root, "notes/daily.md"));
|
||||
});
|
||||
|
||||
it("rejects ../ that escapes vault root", () => {
|
||||
expect(resolveVaultPath(root, "../")).toBe(null);
|
||||
});
|
||||
|
||||
it("rejects deep traversal", () => {
|
||||
expect(resolveVaultPath(root, "a/b/c/../../../../etc/passwd")).toBe(null);
|
||||
});
|
||||
|
||||
it("rejects traversal to a sibling vault with a shared prefix", () => {
|
||||
expect(resolveVaultPath(root, "../testing/foo")).toBe(null);
|
||||
});
|
||||
});
|
||||
44
apps/ignis-server/server/routes/plugins.js
Normal file
44
apps/ignis-server/server/routes/plugins.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const express = require("express");
|
||||
const {
|
||||
getDiscoveredPlugins,
|
||||
enablePluginForVault,
|
||||
disablePluginForVault,
|
||||
} = require("../plugin-system/manager");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
res.json(getDiscoveredPlugins());
|
||||
});
|
||||
|
||||
router.post("/:pluginId/enable", async (req, res) => {
|
||||
const vaultId = req.body?.vault;
|
||||
|
||||
if (!vaultId) {
|
||||
return res.status(400).json({ error: "Missing vault ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
await enablePluginForVault(req.params.pluginId, vaultId);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:pluginId/disable", async (req, res) => {
|
||||
const vaultId = req.body?.vault;
|
||||
|
||||
if (!vaultId) {
|
||||
return res.status(400).json({ error: "Missing vault ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
await disablePluginForVault(req.params.pluginId, vaultId);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
56
apps/ignis-server/server/routes/proxy.js
Normal file
56
apps/ignis-server/server/routes/proxy.js
Normal file
@@ -0,0 +1,56 @@
|
||||
const express = require("express");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// POST /api/proxy - forward a request to an external URL to bypass CORS
|
||||
// Used by the requestUrl shim for plugin installation, etc.
|
||||
router.post("/", async (req, res) => {
|
||||
const { url, method, headers, body, binary } = req.body;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: "Missing url" });
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchOpts = {
|
||||
method: method || "GET",
|
||||
headers: headers || {},
|
||||
};
|
||||
|
||||
if (body && method !== "GET" && method !== "HEAD") {
|
||||
if (binary && typeof body === "string") {
|
||||
fetchOpts.body = Buffer.from(body, "base64");
|
||||
} else {
|
||||
fetchOpts.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
const upstream = await fetch(url, fetchOpts);
|
||||
const respBody = Buffer.from(await upstream.arrayBuffer());
|
||||
|
||||
// Forward response headers, stripping hop-by-hop / encoding headers
|
||||
// since the body is already decompressed by Node's fetch
|
||||
const skipHeaders = new Set([
|
||||
"content-encoding",
|
||||
"transfer-encoding",
|
||||
"content-length",
|
||||
"connection",
|
||||
]);
|
||||
const respHeaders = {};
|
||||
upstream.headers.forEach((val, key) => {
|
||||
if (!skipHeaders.has(key)) {
|
||||
respHeaders[key] = val;
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: upstream.status,
|
||||
headers: respHeaders,
|
||||
body: respBody.toString("base64"),
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
179
apps/ignis-server/server/routes/vault.js
Normal file
179
apps/ignis-server/server/routes/vault.js
Normal file
@@ -0,0 +1,179 @@
|
||||
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();
|
||||
|
||||
// GET /api/vault/list - returns all discovered vaults (re-scans on each call)
|
||||
router.get("/list", (req, res) => {
|
||||
config.refreshVaults();
|
||||
|
||||
const list = Object.entries(config.vaults).map(([id, vaultPath]) => ({
|
||||
id,
|
||||
name: id,
|
||||
path: vaultPath,
|
||||
}));
|
||||
|
||||
res.json(list);
|
||||
});
|
||||
|
||||
// GET /api/vault/info?vault=<id> - returns info for a specific vault
|
||||
router.get("/info", async (req, res) => {
|
||||
const vaultId = req.query.vault || config.defaultVaultId;
|
||||
const vaultPath = config.getVaultPath(vaultId);
|
||||
|
||||
if (!vaultPath) {
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/vault/create { name } - create a new vault in VAULT_ROOT
|
||||
router.post("/create", async (req, res) => {
|
||||
const name = req.body?.name;
|
||||
|
||||
if (!name || /[\/\\:*?"<>|]/.test(name)) {
|
||||
return res.status(400).json({ error: "Invalid vault name" });
|
||||
}
|
||||
|
||||
const vaultPath = path.join(config.vaultRoot, name);
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(vaultPath, { recursive: false });
|
||||
await fs.promises.mkdir(path.join(vaultPath, ".obsidian"), {
|
||||
recursive: false,
|
||||
});
|
||||
|
||||
await installBridgePlugin(vaultPath);
|
||||
|
||||
config.refreshVaults();
|
||||
bootstrapRoutes.invalidateVault(name);
|
||||
|
||||
res.json({ ok: true, id: name, path: vaultPath });
|
||||
} catch (e) {
|
||||
if (e.code === "EEXIST") {
|
||||
return res.status(409).json({ error: "Vault already exists" });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/vault/rename { vault, name } - rename a vault
|
||||
router.post("/rename", async (req, res) => {
|
||||
const vaultId = req.body?.vault;
|
||||
const newName = req.body?.name;
|
||||
|
||||
if (!newName || /[\/\\:*?"<>|]/.test(newName)) {
|
||||
return res.status(400).json({ error: "Invalid vault name" });
|
||||
}
|
||||
|
||||
const vaultPath = config.getVaultPath(vaultId);
|
||||
|
||||
if (!vaultPath) {
|
||||
return res.status(404).json({ error: "Vault not found" });
|
||||
}
|
||||
|
||||
const newPath = path.join(config.vaultRoot, newName);
|
||||
|
||||
try {
|
||||
await fs.promises.rename(vaultPath, newPath);
|
||||
|
||||
config.refreshVaults();
|
||||
bootstrapRoutes.invalidateVault(vaultId);
|
||||
bootstrapRoutes.invalidateVault(newName);
|
||||
|
||||
res.json({ ok: true, id: newName, path: newPath });
|
||||
} catch (e) {
|
||||
if (e.code === "ENOTEMPTY" || e.code === "EEXIST") {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "A vault with that name already exists" });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: e.message, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/vault/remove?vault=<id> - remove a vault from disk
|
||||
router.delete("/remove", async (req, res) => {
|
||||
const vaultId = req.query.vault;
|
||||
const vaultPath = config.getVaultPath(vaultId);
|
||||
|
||||
if (!vaultPath) {
|
||||
return res.status(404).json({ error: "Vault not found" });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.rm(vaultPath, { recursive: true });
|
||||
|
||||
config.refreshVaults();
|
||||
bootstrapRoutes.invalidateVault(vaultId);
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
17
apps/ignis-server/server/routes/version.js
Normal file
17
apps/ignis-server/server/routes/version.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const express = require("express");
|
||||
const { getVersion } = require("../version");
|
||||
const config = require("../config");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/", (req, res) => {
|
||||
const pkg = require("../../package.json");
|
||||
|
||||
res.json({
|
||||
version: getVersion(),
|
||||
semver: pkg.version,
|
||||
obsidianVersion: config.obsidianVersion,
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
23
apps/ignis-server/server/version.js
Normal file
23
apps/ignis-server/server/version.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { execSync } = require("child_process");
|
||||
|
||||
function getVersion() {
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8"),
|
||||
);
|
||||
const semver = pkg.version;
|
||||
|
||||
let hash;
|
||||
try {
|
||||
hash = execSync("git rev-parse --short=7 HEAD", {
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
} catch (e) {
|
||||
hash = Date.now().toString(36).slice(-7);
|
||||
}
|
||||
|
||||
return `${semver}-${hash}`;
|
||||
}
|
||||
|
||||
module.exports = { getVersion };
|
||||
Reference in New Issue
Block a user