diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..20c9eaf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +vendor/ +node_modules/ +.git/ +.tmp/ +docs/ +.codenomad/ +.DS_Store +.idea/ +.vscode/ +*.log +npm-debug.log* +nginx.htpasswd diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6a2bcbb --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# Copy this file to .env and set the values you need. +# .env is gitignored — never commit real secrets to the repo. + +# ── Auth (TOTP / Google Authenticator) ──────────────────────────────────────── +# Generate a secret (run once from src/server/): +# node -e "const {authenticator}=require('otplib');console.log(authenticator.generateSecret())" +# Paste the output here, then visit /__totp-setup?token=YOUR_SECRET to scan the QR code. +# TOTP_SECRET= + +# ── Port ─────────────────────────────────────────────────────────────────────── +# Direct access port (default 3000). nginx always uses 80, no conflict. +# PORT=3000 + +# ── Vault ────────────────────────────────────────────────────────────────────── +# Point USER_DATA at your vault folder on the host. Everything inside +# .obsidian/ (plugins, themes, snippets, fonts, settings) comes along for free. +# USER_DATA=/path/to/your/vault +# VAULT_PATH=user-data/demo-vault # right-hand side of the USER_DATA mount + +# Synology NAS example: +# USER_DATA=/volume1/Docker/obsidian/data +# VAULT_PATH=user-data +# WATCH_POLLING=true + +# ── Granular folder overrides ────────────────────────────────────────────────── +# Only needed if you want a shared folder used across multiple vaults. +# OBSIDIAN_PLUGINS=/path/to/.obsidian/plugins +# OBSIDIAN_THEMES=/path/to/.obsidian/themes +# OBSIDIAN_SNIPPETS=/path/to/.obsidian/snippets +# OBSIDIAN_FONTS=/path/to/.obsidian/fonts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6650a0a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +# Normalize all text files to LF in the repo and working tree. +# Prevents CRLF conversion churn (whole-file diffs) on Windows checkouts. +* text=auto eol=lf + +# Windows-specific scripts keep CRLF +*.bat text eol=crlf +*.ps1 text eol=crlf + +# Binary files — never touch line endings +*.png binary +*.ico binary +*.jpg binary +*.woff binary +*.woff2 binary diff --git a/.gitignore b/.gitignore index ae885b8..2378298 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Vendor — extracted Obsidian bundles (regeneratable via scripts/update-obsidian.js) vendor/ +# Local environment — copy .env.example to .env, never commit real secrets +.env + # Node node_modules/ npm-debug.log* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8756699 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,127 @@ +# obsidian-web — developer context for Claude + +## What this project is + +obsidian-web loads Obsidian's original renderer (`app.js`) unmodified inside a +standard browser. Every Node.js / Electron dependency is replaced with HTTP +shims. The Node.js server provides a REST + WebSocket API that the browser-side +shims call. There is no bundling step — the browser loads `app.js` directly from +the server. + +## Key files + +| Path | Role | +|------|------| +| `src/client/boot.js` | Runs first. Installs polyfills (`crypto.subtle`, `crypto.randomUUID`), then fetches the bootstrap cache and injects Obsidian's scripts. | +| `src/client/shims/original-fs.js` | Replaces Node's `fs` module. Async ops use `fetch`; sync ops use synchronous XHR via `__owSyncRequest`. | +| `src/client/shims/sync-http.js` | Implements `__owSyncRequest` / `__owSyncJson` using synchronous `XMLHttpRequest`. | +| `src/server/index.js` | Express + HTTP server. Registers all API routers. | +| `src/server/api/fs.js` | File-system REST API (`/api/fs/*`). Includes write coalescing and `mkdirRepair` for ENOTDIR vault corruption. | +| `src/server/api/pbkdf2.js` | `POST /api/pbkdf2` — offloads PBKDF2 key derivation to Node's native crypto (100k iterations in pure JS would freeze the browser for ~10 s). | +| `src/server/api/keytar.js` | `safeStorage` shim — stores plugin secrets server-side (Electron's `safeStorage` is unavailable in the browser). | +| `src/server/api/localstorage.js` | Server-backed `window.localStorage` store (`user-data/.localstorage.json`). Pairs with `src/client/shims/remote-localstorage.js`. | +| `src/client/shims/remote-localstorage.js` | Replaces `window.localStorage` with the server-backed store before app.js runs, so safeStorage tokens and app state roam across devices/origins. Keys prefixed `obsidian-web:` stay device-local. | +| `src/server/api/bootstrap.js` | `/api/bootstrap` — serves the entire vault's file tree and metadata in one compressed response so Obsidian's sync `statSync`/`readFileSync` calls hit an in-memory cache. | +| `src/server/middleware/auth.js` | Optional TOTP authentication middleware. Enabled by setting `TOTP_SECRET`. | + +## Running locally (Node.js) + +```bash +node scripts/update-obsidian.js # download Obsidian's renderer into vendor/ +cd src/server && npm install +npm run dev # auto-reload; open http://127.0.0.1:3000 +``` + +## Docker / NAS deployment + +```bash +docker compose up -d +``` + +### Vault path + +Inside the container, the working root is `/app/user-data/`. The +`docker-compose.yml` mounts the host directory `${USER_DATA:-./user-data}` to +that path. **Vaults must live under this mount.** Place your vault at +`${USER_DATA}//` on the host; it appears at +`/app/user-data//` inside the container. Set `VAULT_PATH=user-data/` +to open it on boot. + +Example `.env` for a Synology NAS: +``` +USER_DATA=/volume1/obsidian +VAULT_PATH=user-data/BrainTrust +TOTP_SECRET=JBSWY3DPEHPK3PXP +PORT=3005 +WATCH_POLLING=true +``` + +## Plain-HTTP polyfills (`src/client/boot.js`) + +Browsers restrict `crypto.subtle` (SubtleCrypto) to HTTPS/localhost. On plain +HTTP (common on LAN NAS setups) the entire `crypto.subtle` object is `undefined`. +`boot.js` polyfills it in pure JS: + +- **SHA-256** — pure-JS implementation used by `crypto.subtle.digest('SHA-256', ...)` +- **SHA-1** — pure-JS implementation used by `crypto.subtle.digest('SHA-1', ...)` (ion-sync `getSHA`) +- **AES-256 + AES-GCM** — forward S-box, key expansion, CTR mode, GHASH auth tag +- **PBKDF2** — offloaded to `/api/pbkdf2` (Node's native `crypto.pbkdf2`) to avoid ~10 s browser freeze for 100k iterations +- **`crypto.randomUUID`** — polyfilled using `crypto.getRandomValues` + +The polyfill only activates when `crypto.subtle` is absent (`!crypto.subtle`), +so HTTPS deployments use the native browser implementation. + +## Binary write fix (`src/client/shims/original-fs.js`) + +`fetch(url, { body: ArrayBuffer })` sends no `Content-Type` header. Express's +`body-parser` (used by `express.raw({ type: '*/*' })`) calls `type-is` to match +the content type; `type-is` returns `false` for requests with **no** Content-Type +even when the wildcard `*/*` is specified, so the body is left unparsed and +`req.body` defaults to `{}`. Node's `fs.writeFile` then throws: + +> TypeError: The 'data' argument must be … Received an instance of Object + +**Fix:** `writeFileAsync` and `syncRequest` always set +`Content-Type: application/octet-stream` on PUT requests so body-parser +always parses. + +## ENOTDIR self-repair (`src/server/api/fs.js` — `mkdirRepair`) + +Sync plugins can leave a regular file at a path that should be a directory +(e.g. a previous partial sync writes `Atlas/Books` as a file before the +directory structure is created). Later, `fs.stat('Atlas/Books/note.md')` +throws `ENOTDIR`. + +Two mitigations: +1. `handleError` remaps `ENOTDIR` → `ENOENT` in the JSON `code` field so + callers treat it as "not found" and attempt a write. +2. `mkdirRepair` walks the target directory path, unlinks the first non-directory + component it finds, then retries `mkdir -p`. The write then succeeds and the + correct directory structure is created. + +## ion-sync specifics + +ion-sync is a community sync plugin using AES-GCM-256 with PBKDF2 key derivation. +On plain HTTP all of the following must be polyfilled / shimmed: + +| Operation | Where handled | +|-----------|---------------| +| `crypto.subtle.importKey('raw', pw, 'PBKDF2', ...)` | boot.js polyfill | +| `crypto.subtle.deriveKey({ name: 'PBKDF2', ... })` | boot.js → `/api/pbkdf2` | +| `crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data)` | boot.js polyfill | +| `crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, data)` | boot.js polyfill | +| `crypto.subtle.digest('SHA-256', data)` | boot.js polyfill | +| `crypto.subtle.digest('SHA-1', data)` | boot.js polyfill | +| `safeStorage.encryptString` / `decryptString` | `/api/keytar` | +| `crypto.randomUUID()` | boot.js polyfill | + +## Secrets / auth + +- **Never commit a real `TOTP_SECRET`** — `.env` is gitignored. +- Auth is disabled when `TOTP_SECRET` is empty. +- Generate a secret: `node -e "const {authenticator}=require('otplib');console.log(authenticator.generateSecret())"` +- Scan QR code: visit `/__totp-setup?token=YOUR_SECRET` after starting the server. +- Sessions are random per-login tokens persisted in `user-data/.sessions.json` + (7-day expiry). Failed TOTP attempts are rate-limited to 5 per IP per 15 min. +- `/api/vaults/open` only accepts paths under `VAULTS_ROOT` (default + `user-data/`). Set `VAULTS_ROOT=*` to disable the restriction. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5db6a96 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM node:20-alpine +WORKDIR /app + +# unzip is required by scripts/update-obsidian-mobile.js (extracts APK assets) +RUN apk add --no-cache unzip + +# App source, scripts, and default vault +COPY src/ src/ +COPY scripts/ scripts/ +COPY user-data/ user-data/ +COPY entrypoint.sh /entrypoint.sh + +# Static assets served from the project root (favicon, touch icon) +COPY favicon.ico favicon.svg apple-touch-icon.png ./ +RUN chmod +x /entrypoint.sh + +# Install server dependencies (production only) +RUN cd src/server && npm ci --omit=dev + +EXPOSE 3000 + +# HOST must be 0.0.0.0 inside a container +ENV HOST=0.0.0.0 +ENV PORT=3000 +ENV VAULT_PATH=user-data/demo-vault + +# Obsidian renderer bundles are downloaded at first start into the +# obsidian_vendor volume (see docker-compose.yml). Network is available +# at runtime but not during `docker build`, so we can't download here. +ENTRYPOINT ["/entrypoint.sh"] +CMD ["node", "src/server/index.js"] diff --git a/README.md b/README.md index cf013d1..0d4e655 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,50 @@ Open `http://127.0.0.1:3000`. Open `http://127.0.0.1:3000/starter` to manage recent vaults and add a server folder path as a vault. +## Docker (self-hosted / NAS) + +The repo ships a `docker-compose.yml` for running obsidian-web on a NAS or any Docker host. + +```bash +docker compose up -d +``` + +### Vault location + +Inside the container, the server's working root is `/app/user-data/`. The `docker-compose.yml` mounts a host directory there: + +``` +${USER_DATA:-./user-data} → /app/user-data +``` + +**Your vault folder must live under that mount point.** For example, if your `.env` has: + +``` +USER_DATA=/volume1/obsidian +``` + +then place your vault at `/volume1/obsidian//` on the host. Inside the container it appears at `/app/user-data//`. The `VAULT_PATH` env var (default `user-data/demo-vault`) tells the server which subdirectory to open on boot — set it to `user-data/` to open your vault automatically. + +### Configuration (`.env`) + +Copy `.env.example` to `.env` and fill in your values: + +| Variable | Default | Description | +|----------|---------|-------------| +| `USER_DATA` | `./user-data` | Host path mounted to `/app/user-data` — put your vault here | +| `VAULT_PATH` | `user-data/demo-vault` | Relative path inside the container to the vault to open on boot | +| `PORT` | `3000` | Host port | +| `TOTP_SECRET` | _(empty — auth disabled)_ | Base-32 TOTP secret; set to enable auth. Generate with `node -e "const {authenticator}=require('otplib');console.log(authenticator.generateSecret())"` then visit `/__totp-setup?token=YOUR_SECRET` to scan the QR code. | +| `WATCH_POLLING` | `false` | Set to `true` on network filesystems (NFS, SMB, rclone) that don't support inotify | + +### Notes + +- The Obsidian renderer is downloaded into a named Docker volume (`obsidian_vendor`) on first start — no manual step needed. +- Run behind a reverse proxy (nginx, Caddy, Cloudflare Tunnel) for HTTPS. Without HTTPS, the Web Crypto API (`crypto.subtle`) is unavailable; the project includes a pure-JS polyfill so plugins like ion-sync that rely on AES-GCM/PBKDF2 still work on plain HTTP. +- If you use a third-party sync plugin (e.g. ion-sync) that previously synced to the vault on another device, the initial sync may encounter ENOTDIR errors where old sync artifacts left files where directories should be. The server auto-repairs these by removing the blocking file and recreating the correct directory structure. + +--- + ## Obsidian Version `vendor/obsidian/` is generated from the official `obsidianmd/obsidian-releases` GitHub releases and is intentionally ignored by Git. diff --git a/apple-touch-icon.png b/apple-touch-icon.png new file mode 100644 index 0000000..4c113e5 Binary files /dev/null and b/apple-touch-icon.png differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1c29fa7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,58 @@ +services: + obsidian-web: + build: . + ports: + - "${PORT:-3000}:3000" + volumes: + # Set these in .env (or Dockhand's env editor) to point at folders on + # your host. Defaults keep everything self-contained in the repo. + + # Your vault — notes, and anything inside .obsidian/ (plugins, themes, + # snippets, fonts, settings). Point USER_DATA at your real vault folder. + - ${USER_DATA:-./user-data}:/app/user-data + + # Granular overrides — only needed if you want to share a single + # plugins/themes/snippets/fonts folder across multiple vaults. + # - ${OBSIDIAN_PLUGINS:-./user-data/demo-vault/.obsidian/plugins}:/app/user-data/demo-vault/.obsidian/plugins + # - ${OBSIDIAN_THEMES:-./user-data/demo-vault/.obsidian/themes}:/app/user-data/demo-vault/.obsidian/themes + # - ${OBSIDIAN_SNIPPETS:-./user-data/demo-vault/.obsidian/snippets}:/app/user-data/demo-vault/.obsidian/snippets + # - ${OBSIDIAN_FONTS:-./user-data/demo-vault/.obsidian/fonts}:/app/user-data/demo-vault/.obsidian/fonts + + # Obsidian renderer bundles (downloaded on first start) + - obsidian_vendor:/app/vendor + + environment: + HOST: "0.0.0.0" + PORT: "3000" + VAULT_PATH: "${VAULT_PATH:-user-data/demo-vault}" + WATCH_POLLING: "${WATCH_POLLING:-false}" + # VAULT_REGISTRY: "user-data/registry.json" + + # Auth — generate a secret with: + # node -e "const {authenticator}=require('otplib');console.log(authenticator.generateSecret())" + # Then set TOTP_SECRET in Dockhand's env editor or in a .env file. + # Visit /__totp-setup?token=YOUR_SECRET to scan the QR code. + TOTP_SECRET: "${TOTP_SECRET:-}" + env_file: + - path: .env + required: false + restart: unless-stopped + + # Auth Option 2: HTTP Basic Auth via nginx + # nginx listens on port 80 and proxies to obsidian-web internally. + # Setup: htpasswd -c nginx.htpasswd + # Start: docker compose --profile auth-nginx up + nginx: + profiles: ["auth-nginx"] + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./nginx.htpasswd:/etc/nginx/htpasswd:ro + depends_on: + - obsidian-web + restart: unless-stopped + +volumes: + obsidian_vendor: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..0d1bdd2 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -e + +# The update scripts extract files to /app/.tmp/ then atomically rename() them +# into /app/vendor/. rename() across different filesystems raises EXDEV, and +# /app/.tmp (container layer) vs /app/vendor (Docker volume) are different +# devices. Fix: redirect .tmp into the vendor volume so both paths share the +# same filesystem and rename() succeeds. +mkdir -p /app/vendor/.tmp +rm -rf /app/.tmp +ln -sf /app/vendor/.tmp /app/.tmp + +if [ ! -f /app/vendor/obsidian/app.js ]; then + echo "[docker] Downloading Obsidian desktop renderer (first run — this takes ~30s)..." + node scripts/update-obsidian.js +fi + +if [ ! -f /app/vendor/obsidian-mobile/app.js ]; then + echo "[docker] Downloading Obsidian mobile renderer (first run — this takes ~30s)..." + node scripts/update-obsidian-mobile.js +fi + +exec "$@" diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..8aad7e4 Binary files /dev/null and b/favicon.ico differ diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000..c266291 --- /dev/null +++ b/favicon.svg @@ -0,0 +1,6 @@ + + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..8e0fbe1 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,29 @@ +server { + listen 80; + server_name _; + + # HTTP Basic Auth — generate nginx.htpasswd with: + # htpasswd -c nginx.htpasswd + auth_basic "Obsidian Web"; + auth_basic_user_file /etc/nginx/htpasswd; + + # WebSocket endpoint — must set Upgrade + Connection headers + location /api/watch { + proxy_pass http://obsidian-web:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + } + + # Everything else + location / { + proxy_pass http://obsidian-web:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 100M; + } +} diff --git a/src/client-mobile/boot.js b/src/client-mobile/boot.js index 8a9d906..6745a23 100644 --- a/src/client-mobile/boot.js +++ b/src/client-mobile/boot.js @@ -215,12 +215,24 @@ const MOBILE_SCRIPTS = [ setStatus('Verifying vault...'); // אמת שה-vault קיים על השרת (stat על ה-root) - fetch('/api/fs/stat?vault=' + encodeURIComponent(VAULT_ID) + '&path=') - .then(function(res) { - if (!res.ok) throw new Error('Vault not found (HTTP ' + res.status + ')'); - return res.json(); - }) - .then(function(stat) { + // אמת vault + טען localStorage מהשרת במקביל — שניהם חייבים להיות מוכנים + // לפני הזרקת הסקריפטים של Obsidian (app.js קורא localStorage באתחול). + // כשל ב-localStorage אינו פטאלי — נשארים עם האחסון המקומי של הדפדפן. + Promise.all([ + fetch('/api/fs/stat?vault=' + encodeURIComponent(VAULT_ID) + '&path=') + .then(function(res) { + if (!res.ok) throw new Error('Vault not found (HTTP ' + res.status + ')'); + return res.json(); + }), + (window.__owInstallRemoteLocalStorage + ? window.__owInstallRemoteLocalStorage() + : Promise.resolve() + ).catch(function(e) { + console.warn('[obsidian-web] remote localStorage unavailable, staying device-local:', e && e.message); + }), + ]) + .then(function(results) { + var stat = results[0]; if (!stat || (!stat.isDirectory && stat.type !== 'directory')) throw new Error('Vault path is not a directory'); setStatus('Loading Obsidian mobile...'); diff --git a/src/client-mobile/index.html b/src/client-mobile/index.html index fdbfc9e..89fb09a 100644 --- a/src/client-mobile/index.html +++ b/src/client-mobile/index.html @@ -6,6 +6,14 @@ Obsidian Web + + +
+

🔐 Obsidian Web

+

Enter the 6-digit code from your authenticator app

+
+ + +
+ + + + + + +
+

+ +
+
+ + +`; + +async function buildSetupPage(secret) { + const otpauth = authenticator.keyuri('obsidian-web', 'Obsidian Web', secret); + const qrSvg = await QRCode.toString(otpauth, { type: 'svg', width: 220, margin: 2 }); + return ` + + + + + Obsidian Web — Authenticator Setup + + + +
+

🔐 Authenticator Setup

+

Scan the QR code with your authenticator app, or enter the secret manually.

+ +
${qrSvg}
+ +

Manual entry code

+
${secret}
+ +

Compatible apps

+ + +
+ Keep this page private.
+ Once you've scanned or copied the code, close this tab. + The secret is stored in your TOTP_SECRET environment variable. +
+
+ +`; +} + +// ── Middleware factory ───────────────────────────────────────────────────────── + +function createAuthMiddleware(appConfig = {}) { + if (!TOTP_SECRET) return null; + + const sessionsFile = appConfig.userDataPath + ? path.join(appConfig.userDataPath, '.sessions.json') + : null; + const sessions = new SessionStore(sessionsFile); + const limiter = new RateLimiter(); + + function isAuthenticated(req) { + return sessions.has(parseCookies(req)[COOKIE]); + } + + return async function authMiddleware(req, res, next) { + + // ── Setup page ── requires ?token=TOTP_SECRET so only the owner can view it + if (req.path === '/__totp-setup') { + if ((req.query?.token || '') !== TOTP_SECRET) { + return res.status(403).end('Forbidden — add ?token=YOUR_TOTP_SECRET to the URL'); + } + const html = await buildSetupPage(TOTP_SECRET); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + return res.end(html); + } + + // ── Auth endpoint ── verify code, set cookie + if (req.path === '/__auth') { + if (limiter.isBlocked(req)) { + return res.status(429) + .end('Too many failed attempts — try again in a few minutes.'); + } + const code = String(req.query?.code || '').replace(/\D/g, ''); + const dest = req.query?.next || '/'; + if (code.length === 6 && authenticator.verify({ token: code, secret: TOTP_SECRET })) { + limiter.recordSuccess(req); + const token = sessions.create(); + // Secure flag when the request arrived over HTTPS (directly or via + // a proxy/tunnel like cloudflared). Omitted on plain-HTTP LAN + // setups, where it would make the cookie unusable. + const secure = (req.secure || req.headers['x-forwarded-proto'] === 'https') + ? '; Secure' : ''; + res.setHeader('Set-Cookie', + `${COOKIE}=${token}; Path=/; Max-Age=${MAX_AGE}; HttpOnly; SameSite=Strict${secure}`); + return res.redirect(dest); + } + limiter.recordFailure(req); + return res.redirect(`/__login?error=1&next=${encodeURIComponent(dest)}`); + } + + // ── Login page ── always accessible (no point hiding it) + if (req.path === '/__login') { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + return res.end(LOGIN_HTML); + } + + // ── Authenticated ── + if (isAuthenticated(req)) return next(); + + // ── Unauthenticated ── + const wantsJson = req.path.startsWith('/api/') || + (req.headers.accept || '').includes('application/json'); + if (wantsJson) { + return res.status(401).json({ error: 'Unauthorized — valid TOTP session required' }); + } + res.redirect(`/__login?next=${encodeURIComponent(req.originalUrl)}`); + }; +} + +module.exports = { createAuthMiddleware }; diff --git a/src/server/package-lock.json b/src/server/package-lock.json index 29c4a64..930e5e3 100644 --- a/src/server/package-lock.json +++ b/src/server/package-lock.json @@ -11,9 +11,61 @@ "chokidar": "^3.6.0", "compression": "^1.8.1", "express": "^4.21.0", + "otplib": "^12.0.1", + "qrcode": "^1.5.4", "ws": "^8.18.0" } }, + "node_modules/@otplib/core": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", + "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==", + "license": "MIT" + }, + "node_modules/@otplib/plugin-crypto": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz", + "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1" + } + }, + "node_modules/@otplib/plugin-thirty-two": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz", + "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "thirty-two": "^1.0.2" + } + }, + "node_modules/@otplib/preset-default": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz", + "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==", + "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, + "node_modules/@otplib/preset-v11": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz", + "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -27,6 +79,30 @@ "node": ">= 0.6" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -147,6 +223,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -171,6 +256,35 @@ "fsevents": "~2.3.2" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/compressible": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", @@ -255,6 +369,15 @@ "ms": "2.0.0" } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -274,6 +397,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -294,6 +423,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -424,6 +559,19 @@ "node": ">= 0.8" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -465,6 +613,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -618,6 +775,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -639,6 +805,18 @@ "node": ">=0.12.0" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -765,6 +943,53 @@ "node": ">= 0.8" } }, + "node_modules/otplib": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz", + "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/preset-default": "^12.0.1", + "@otplib/preset-v11": "^12.0.1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -774,6 +999,15 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -792,6 +1026,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -805,6 +1048,23 @@ "node": ">= 0.10" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -856,6 +1116,21 @@ "node": ">=8.10.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -927,6 +1202,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -1014,6 +1295,40 @@ "node": ">= 0.8" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/thirty-two": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz", + "integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==", + "engines": { + "node": ">=0.2.6" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -1075,6 +1390,26 @@ "node": ">= 0.8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", @@ -1095,6 +1430,47 @@ "optional": true } } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } } } } diff --git a/src/server/package.json b/src/server/package.json index b14ae32..a52a4b9 100644 --- a/src/server/package.json +++ b/src/server/package.json @@ -13,6 +13,8 @@ "chokidar": "^3.6.0", "compression": "^1.8.1", "express": "^4.21.0", + "otplib": "^12.0.1", + "qrcode": "^1.5.4", "ws": "^8.18.0" } } diff --git a/src/server/test/bootstrap-cache.test.js b/src/server/test/bootstrap-cache.test.js index c735310..2623928 100644 --- a/src/server/test/bootstrap-cache.test.js +++ b/src/server/test/bootstrap-cache.test.js @@ -22,7 +22,15 @@ const { createApp } = require('../index'); const { serverCache } = require('../api/bootstrap'); async function startTestServer(config) { - const app = createApp(config); + // The mobile runtime paths were added after these tests were written; + // default them so each test doesn't have to spell them out. + const app = createApp({ + clientMobilePath: config.clientPath, + obsidianMobilePath: config.obsidianPath, + userDataPath: path.dirname(config.registryPath), + projectRoot: path.dirname(config.clientPath), + ...config, + }); const server = http.createServer(app); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const { port } = server.address(); diff --git a/src/server/test/vaults-api.test.js b/src/server/test/vaults-api.test.js index 6b079e7..df9166d 100644 --- a/src/server/test/vaults-api.test.js +++ b/src/server/test/vaults-api.test.js @@ -9,7 +9,15 @@ const test = require('node:test'); const { createApp } = require('../index'); async function startTestServer(config) { - const app = createApp(config); + // The mobile runtime paths were added after these tests were written; + // default them so each test doesn't have to spell them out. + const app = createApp({ + clientMobilePath: config.clientPath, + obsidianMobilePath: config.obsidianPath, + userDataPath: path.dirname(config.registryPath), + projectRoot: path.dirname(config.clientPath), + ...config, + }); const server = http.createServer(app); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const { port } = server.address(); @@ -284,3 +292,111 @@ test('starter route serves the wrapped Obsidian starter entry', async (t) => { assert.equal(response.status, 200); assert.match(await response.text(), /starter/); }); + +test('vaultsRoot restricts open to paths under the root', async (t) => { + const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'obsidian-web-')); + t.after(() => fsp.rm(tmp, { recursive: true, force: true })); + + const vaultsRoot = path.join(tmp, 'vaults'); + const insidePath = path.join(vaultsRoot, 'good-vault'); + const outsidePath = path.join(tmp, 'evil-vault'); + await fsp.mkdir(insidePath, { recursive: true }); + await fsp.mkdir(outsidePath); + + const bootVault = path.join(vaultsRoot, 'boot'); + await fsp.mkdir(bootVault); + const server = await startTestServer({ + clientPath: path.join(tmp, 'client'), + obsidianPath: path.join(tmp, 'obsidian'), + registryPath: path.join(tmp, 'vaults.json'), + vaultPath: bootVault, + vaultsRoot, + }); + t.after(server.close); + + // Inside the root → allowed. + const okRes = await fetch(server.baseUrl + '/api/vaults/open', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: insidePath, create: false }), + }); + assert.equal(okRes.status, 200); + assert.equal((await okRes.json()).ok, true); + + // Outside the root → rejected, even though the directory exists. + const badRes = await fetch(server.baseUrl + '/api/vaults/open', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: outsidePath, create: false }), + }); + assert.equal(badRes.status, 400); + const bad = await badRes.json(); + assert.equal(bad.ok, false); + assert.match(bad.error, /outside the allowed vaults root/); + + // Path traversal out of the root → rejected. + const traversalRes = await fetch(server.baseUrl + '/api/vaults/open', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: path.join(vaultsRoot, '..', 'evil-vault'), create: false }), + }); + assert.equal(traversalRes.status, 400); + + // The configured boot vault is allowed even when it equals the allowlist entry. + const bootRes = await fetch(server.baseUrl + '/api/vaults/open', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: bootVault, create: false }), + }); + assert.equal(bootRes.status, 200); +}); + +test('localstorage API stores, merges, and deletes keys', async (t) => { + const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'obsidian-web-')); + t.after(() => fsp.rm(tmp, { recursive: true, force: true })); + + const server = await startTestServer({ + clientPath: path.join(tmp, 'client'), + obsidianPath: path.join(tmp, 'obsidian'), + registryPath: path.join(tmp, 'vaults.json'), + userDataPath: tmp, + vaultPath: tmp, + }); + t.after(server.close); + + // Empty store initially. + let res = await fetch(server.baseUrl + '/api/localstorage'); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), {}); + + // Batch PUT sets keys. + res = await fetch(server.baseUrl + '/api/localstorage', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ entries: { alpha: '1', beta: 'two' } }), + }); + assert.equal(res.status, 200); + + // Second PUT merges and deletes (null). + res = await fetch(server.baseUrl + '/api/localstorage', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ entries: { beta: null, gamma: '3' } }), + }); + assert.equal(res.status, 200); + + res = await fetch(server.baseUrl + '/api/localstorage'); + assert.deepEqual(await res.json(), { alpha: '1', gamma: '3' }); + + // Malformed body rejected. + res = await fetch(server.baseUrl + '/api/localstorage', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nope: true }), + }); + assert.equal(res.status, 400); + + // Persisted to disk in userDataPath. + const onDisk = JSON.parse(await fsp.readFile(path.join(tmp, '.localstorage.json'), 'utf8')); + assert.deepEqual(onDisk, { alpha: '1', gamma: '3' }); +}); diff --git a/src/server/vault-registry.js b/src/server/vault-registry.js index 95aa003..9b741d7 100644 --- a/src/server/vault-registry.js +++ b/src/server/vault-registry.js @@ -3,11 +3,32 @@ const fs = require('fs'); const path = require('path'); class VaultRegistry { - constructor(registryPath) { + /** + * @param {string} registryPath JSON file tracking known vaults. + * @param {object} [opts] + * @param {string|null} [opts.vaultsRoot] If set, open()/move() only accept + * paths under this root. null/undefined = unrestricted (tests, VAULTS_ROOT='*'). + * @param {string[]} [opts.allowPaths] Extra paths allowed outside the root + * (e.g. the VAULT_PATH boot vault, which may live anywhere). + */ + constructor(registryPath, opts = {}) { this.registryPath = registryPath; + this.vaultsRoot = opts.vaultsRoot ? path.resolve(opts.vaultsRoot) : null; + this.allowPaths = (opts.allowPaths || []).filter(Boolean).map(p => path.resolve(p)); this.vaults = this.load(); } + // A path is allowed if it IS the root / an allowlisted path, or lives + // under one of them. Paths already in the registry are always allowed + // (the admin registered them via config or an earlier policy). + isPathAllowed(resolved) { + if (!this.vaultsRoot) return true; + const roots = [this.vaultsRoot, ...this.allowPaths]; + return roots.some(root => + resolved === root || resolved.startsWith(root + path.sep) + ); + } + load() { try { return JSON.parse(fs.readFileSync(this.registryPath, 'utf8')) || {}; @@ -51,6 +72,9 @@ class VaultRegistry { } const resolved = path.resolve(vaultPath); + if (!this.isPathAllowed(resolved) && !this.findIdByPath(resolved)) { + return { ok: false, error: 'path is outside the allowed vaults root' }; + } if (create) { fs.mkdirSync(resolved, { recursive: true }); } @@ -97,6 +121,9 @@ class VaultRegistry { if (!id) return { ok: false, notFound: true }; const resolvedNewPath = path.resolve(newPath); + if (!this.isPathAllowed(resolvedNewPath)) { + return { ok: false, error: 'destination is outside the allowed vaults root' }; + } try { fs.renameSync(this.vaults[id].path, resolvedNewPath); } catch (err) { diff --git a/user-data/.gitignore b/user-data/.gitignore index 55ae369..7656ecd 100644 --- a/user-data/.gitignore +++ b/user-data/.gitignore @@ -1,5 +1,10 @@ # Runtime — contains local absolute paths to user's vaults registry.json +# Runtime — secrets and per-server state, never commit +.keychain.json +.localstorage.json +.sessions.json + # Demo vault — track content, ignore Obsidian's local state demo-vault/.obsidian/