From ca225135c1aee246288e5e64e142b5e21f02ba6a Mon Sep 17 00:00:00 2001 From: Sean Date: Tue, 9 Jun 2026 16:05:17 -0700 Subject: [PATCH 01/56] docker: add Docker support with two auth options - Dockerfile: multi-stage build (downloads obsidian + obsidian-mobile renderer bundles in stage 1, installs server deps in stage 2) - docker-compose.yml: base setup, vault persisted via volume - docker-compose.auth-key.yml: API-key auth overlay (AUTH_KEY env var) - docker-compose.auth-basic.yml: nginx HTTP Basic Auth overlay - nginx.conf: nginx reverse proxy with WebSocket support for /api/watch - .dockerignore: excludes vendor/, node_modules/, .git/, etc. - src/server/middleware/auth.js: Express middleware for API-key auth (login page, cookie session, Bearer token support) - src/server/index.js: load auth middleware when AUTH_KEY is set --- .dockerignore | 12 ++++ Dockerfile | 33 +++++++++ docker-compose.auth-basic.yml | 26 ++++++++ docker-compose.auth-key.yml | 15 +++++ docker-compose.yml | 23 +++++++ nginx.conf | 29 ++++++++ src/server/index.js | 9 +++ src/server/middleware/auth.js | 122 ++++++++++++++++++++++++++++++++++ 8 files changed, 269 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.auth-basic.yml create mode 100644 docker-compose.auth-key.yml create mode 100644 docker-compose.yml create mode 100644 nginx.conf create mode 100644 src/server/middleware/auth.js 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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8396138 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# ── Stage 1: download Obsidian renderer files ───────────────────────────────── +# Both scripts use only Node built-ins (https, zlib, crypto, fs) — no npm install needed. +FROM node:20-alpine AS obsidian-dl +WORKDIR /app +COPY scripts/ scripts/ +RUN node scripts/update-obsidian.js \ + && node scripts/update-obsidian-mobile.js + +# ── Stage 2: production image ────────────────────────────────────────────────── +FROM node:20-alpine +WORKDIR /app + +# App source and default vault +COPY src/ src/ +COPY user-data/ user-data/ + +# Pre-downloaded Obsidian renderer bundles (not in git, built in stage 1) +COPY --from=obsidian-dl /app/vendor/ vendor/ + +# Install server dependencies (production only, no devDeps) +RUN cd src/server && npm ci --omit=dev + +EXPOSE 3000 + +# HOST must be 0.0.0.0 inside a container — 127.0.0.1 would refuse outside connections +ENV HOST=0.0.0.0 +ENV PORT=3000 +ENV VAULT_PATH=user-data/demo-vault +# Optional auth (see docker-compose.auth-key.yml): +# ENV AUTH_KEY=change-me + +# Run from repo root so PROJECT_ROOT resolves correctly via __dirname +CMD ["node", "src/server/index.js"] diff --git a/docker-compose.auth-basic.yml b/docker-compose.auth-basic.yml new file mode 100644 index 0000000..6ee82b9 --- /dev/null +++ b/docker-compose.auth-basic.yml @@ -0,0 +1,26 @@ +# Auth Option 2 — HTTP Basic Auth via nginx +# ───────────────────────────────────────────────────────────────────────────── +# nginx sits in front of the app and enforces HTTP Basic Auth. +# Handles WebSocket upgrade for /api/watch automatically. +# +# Setup (one time): +# htpasswd -c nginx.htpasswd # creates the password file +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.auth-basic.yml up + +services: + obsidian-web: + # Remove direct host port — nginx is the only public entrypoint + ports: [] + + nginx: + image: nginx:alpine + ports: + - "${PORT:-3000}:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./nginx.htpasswd:/etc/nginx/htpasswd:ro + depends_on: + - obsidian-web + restart: unless-stopped diff --git a/docker-compose.auth-key.yml b/docker-compose.auth-key.yml new file mode 100644 index 0000000..53fcb56 --- /dev/null +++ b/docker-compose.auth-key.yml @@ -0,0 +1,15 @@ +# Auth Option 1 — API Key +# ───────────────────────────────────────────────────────────────────────────── +# The server checks AUTH_KEY on every request. +# Unauthenticated browsers get a login page; API calls get a 401. +# +# Usage: +# AUTH_KEY=your-secret docker compose -f docker-compose.yml -f docker-compose.auth-key.yml up +# +# Or set AUTH_KEY in a .env file at the repo root and run: +# docker compose -f docker-compose.yml -f docker-compose.auth-key.yml up + +services: + obsidian-web: + environment: + AUTH_KEY: "${AUTH_KEY:?AUTH_KEY must be set (e.g. export AUTH_KEY=your-secret)}" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..503647d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + obsidian-web: + build: . + ports: + - "${PORT:-3000}:3000" + volumes: + # Vault and registry persist on the host — safe across rebuilds + - ./user-data:/app/user-data + environment: + HOST: "0.0.0.0" + PORT: "3000" + VAULT_PATH: "user-data/demo-vault" + # VAULT_REGISTRY: "user-data/registry.json" + # WATCH_POLLING: "true" # enable for NFS / SMB / FUSE-mounted vaults + restart: unless-stopped + +# ── Auth options ─────────────────────────────────────────────────────────────── +# Option 1 — API Key (built into the server, no extra container): +# docker compose -f docker-compose.yml -f docker-compose.auth-key.yml up +# +# Option 2 — HTTP Basic Auth via nginx (requires nginx.htpasswd): +# htpasswd -c nginx.htpasswd +# docker compose -f docker-compose.yml -f docker-compose.auth-basic.yml up 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/server/index.js b/src/server/index.js index 0c1c3f6..98f50a2 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -24,6 +24,7 @@ const { warmUpBootstrapCache } = require('./api/bootstrap'); const createProxyRouter = require('./api/proxy'); const attachWatchServer = require('./api/watch'); const VaultRegistry = require('./vault-registry'); +const { createAuthMiddleware } = require('./middleware/auth'); function createApp(appConfig = config) { const app = express(); @@ -34,6 +35,14 @@ function createApp(appConfig = config) { // on Accept-Encoding: browsers get brotli, curl/other tools get gzip. app.use(compression({ level: 6 })); + // Optional API-key auth — enabled by setting AUTH_KEY env var. + // See docker-compose.auth-key.yml for usage. + const authMiddleware = createAuthMiddleware(); + if (authMiddleware) { + app.use(authMiddleware); + console.log('[auth] API-key authentication enabled'); + } + // Request logging - very chatty, but invaluable while we are still // figuring out what Obsidian asks for during boot. app.use((req, res, next) => { diff --git a/src/server/middleware/auth.js b/src/server/middleware/auth.js new file mode 100644 index 0000000..2b1795a --- /dev/null +++ b/src/server/middleware/auth.js @@ -0,0 +1,122 @@ +'use strict'; + +/** + * Optional API-key authentication middleware. + * Activated when AUTH_KEY env var is set. + * + * The key can be supplied via: + * - Cookie: obsidian-web-key= + * - Query param + login: /__auth?key= + * - Authorization header: Bearer (for API clients) + * + * Browser requests go to a login page; /api/* gets a 401 JSON response. + */ + +const AUTH_KEY = process.env.AUTH_KEY || ''; +const COOKIE = 'obsidian-web-key'; +const MAX_AGE = 7 * 24 * 60 * 60; // 7 days (seconds) + +function parseCookies(req) { + return Object.fromEntries( + (req.headers.cookie || '').split(';') + .map(c => c.trim().split('=')) + .filter(p => p.length >= 2) + .map(([k, ...v]) => [k.trim(), v.join('=')]) + ); +} + +function isAuthenticated(req) { + if (parseCookies(req)[COOKIE] === AUTH_KEY) return true; + if (req.query?.key === AUTH_KEY) return true; + const auth = req.headers.authorization || ''; + if (auth.startsWith('Bearer ') && auth.slice(7) === AUTH_KEY) return true; + return false; +} + +const LOGIN_HTML = ` + + + + + Obsidian Web — Sign in + + + +
+

🔒 Obsidian Web

+

Invalid key — try again.

+
+ + + +
+
+ + +`; + +/** + * Returns an Express middleware if AUTH_KEY is set, otherwise null. + */ +function createAuthMiddleware() { + if (!AUTH_KEY) return null; + + return function authMiddleware(req, res, next) { + // ── Auth callback: verify key, set cookie, redirect ── + if (req.path === '/__auth') { + const key = req.query?.key || ''; + const dest = req.query?.next || '/'; + if (key === AUTH_KEY) { + res.setHeader('Set-Cookie', + `${COOKIE}=${AUTH_KEY}; Path=/; Max-Age=${MAX_AGE}; HttpOnly; SameSite=Strict`); + return res.redirect(dest); + } + return res.redirect(`/__login?error=1&next=${encodeURIComponent(dest)}`); + } + + // ── Login page: always accessible ── + if (req.path === '/__login') { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + return res.end(LOGIN_HTML); + } + + // ── Authenticated: pass through ── + 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 — provide a valid Bearer token or cookie' }); + } + res.redirect(`/__login?next=${encodeURIComponent(req.originalUrl)}`); + }; +} + +module.exports = { createAuthMiddleware }; From ad8333a1de92ffdc82039b2f7fd1654e10766c43 Mon Sep 17 00:00:00 2001 From: Sean Date: Tue, 9 Jun 2026 16:33:47 -0700 Subject: [PATCH 02/56] docker: consolidate auth options into single docker-compose.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both auth options are now in one file — easier for Dockhand and other Docker Compose users to work with a single file: - Option 1 (API Key): uncomment AUTH_KEY in the env section, or set it in Dockhand's stack environment editor - Option 2 (Basic Auth): docker compose --profile auth-nginx up (requires nginx.htpasswd generated with htpasswd) Removes docker-compose.auth-key.yml and docker-compose.auth-basic.yml. --- docker-compose.auth-basic.yml | 26 ------------------------ docker-compose.auth-key.yml | 15 -------------- docker-compose.yml | 37 +++++++++++++++++++++++++++-------- 3 files changed, 29 insertions(+), 49 deletions(-) delete mode 100644 docker-compose.auth-basic.yml delete mode 100644 docker-compose.auth-key.yml diff --git a/docker-compose.auth-basic.yml b/docker-compose.auth-basic.yml deleted file mode 100644 index 6ee82b9..0000000 --- a/docker-compose.auth-basic.yml +++ /dev/null @@ -1,26 +0,0 @@ -# Auth Option 2 — HTTP Basic Auth via nginx -# ───────────────────────────────────────────────────────────────────────────── -# nginx sits in front of the app and enforces HTTP Basic Auth. -# Handles WebSocket upgrade for /api/watch automatically. -# -# Setup (one time): -# htpasswd -c nginx.htpasswd # creates the password file -# -# Usage: -# docker compose -f docker-compose.yml -f docker-compose.auth-basic.yml up - -services: - obsidian-web: - # Remove direct host port — nginx is the only public entrypoint - ports: [] - - nginx: - image: nginx:alpine - ports: - - "${PORT:-3000}:80" - volumes: - - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro - - ./nginx.htpasswd:/etc/nginx/htpasswd:ro - depends_on: - - obsidian-web - restart: unless-stopped diff --git a/docker-compose.auth-key.yml b/docker-compose.auth-key.yml deleted file mode 100644 index 53fcb56..0000000 --- a/docker-compose.auth-key.yml +++ /dev/null @@ -1,15 +0,0 @@ -# Auth Option 1 — API Key -# ───────────────────────────────────────────────────────────────────────────── -# The server checks AUTH_KEY on every request. -# Unauthenticated browsers get a login page; API calls get a 401. -# -# Usage: -# AUTH_KEY=your-secret docker compose -f docker-compose.yml -f docker-compose.auth-key.yml up -# -# Or set AUTH_KEY in a .env file at the repo root and run: -# docker compose -f docker-compose.yml -f docker-compose.auth-key.yml up - -services: - obsidian-web: - environment: - AUTH_KEY: "${AUTH_KEY:?AUTH_KEY must be set (e.g. export AUTH_KEY=your-secret)}" diff --git a/docker-compose.yml b/docker-compose.yml index 503647d..78f39a8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: ports: - "${PORT:-3000}:3000" volumes: - # Vault and registry persist on the host — safe across rebuilds + # Vault and registry persist on the host — safe across container rebuilds - ./user-data:/app/user-data environment: HOST: "0.0.0.0" @@ -12,12 +12,33 @@ services: VAULT_PATH: "user-data/demo-vault" # VAULT_REGISTRY: "user-data/registry.json" # WATCH_POLLING: "true" # enable for NFS / SMB / FUSE-mounted vaults + + # ── Auth Option 1: API Key ───────────────────────────────────────────── + # Uncomment and set AUTH_KEY to enable a login page + cookie session. + # In Dockhand you can set this in the stack's environment editor instead. + # AUTH_KEY: "change-me" restart: unless-stopped -# ── Auth options ─────────────────────────────────────────────────────────────── -# Option 1 — API Key (built into the server, no extra container): -# docker compose -f docker-compose.yml -f docker-compose.auth-key.yml up -# -# Option 2 — HTTP Basic Auth via nginx (requires nginx.htpasswd): -# htpasswd -c nginx.htpasswd -# docker compose -f docker-compose.yml -f docker-compose.auth-basic.yml up + # ── Auth Option 2: HTTP Basic Auth via nginx ──────────────────────────────── + # Puts nginx in front of the app with username/password protection. + # Also handles WebSocket upgrade for real-time vault sync (/api/watch). + # + # Setup (run once on the host): + # htpasswd -c nginx.htpasswd + # + # To start with this option: + # docker compose --profile auth-nginx up + # + # When using this profile, comment out the `ports` on obsidian-web above + # so the app is only reachable through nginx, not directly. + nginx: + profiles: ["auth-nginx"] + image: nginx:alpine + ports: + - "${PORT:-3000}:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./nginx.htpasswd:/etc/nginx/htpasswd:ro + depends_on: + - obsidian-web + restart: unless-stopped From c2c4e9be0cfa6ebc6c5784b671f7674a2769c572 Mon Sep 17 00:00:00 2001 From: Sean Date: Tue, 9 Jun 2026 16:39:48 -0700 Subject: [PATCH 03/56] docker: add unzip to build stage for obsidian-mobile script --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index 8396138..792816c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,8 @@ # Both scripts use only Node built-ins (https, zlib, crypto, fs) — no npm install needed. FROM node:20-alpine AS obsidian-dl WORKDIR /app +# unzip is required by update-obsidian-mobile.js (extracts assets from the APK) +RUN apk add --no-cache unzip COPY scripts/ scripts/ RUN node scripts/update-obsidian.js \ && node scripts/update-obsidian-mobile.js From 9d08213156129f2fb5136b6919f5459a02f7df54 Mon Sep 17 00:00:00 2001 From: Sean Date: Tue, 9 Jun 2026 16:41:55 -0700 Subject: [PATCH 04/56] docker: move vendor download to entrypoint (fixes build-time network error) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker build environments have no internet access, so downloading Obsidian renderer files at build time (EAI_AGAIN / DNS failure) doesn't work. Instead: - entrypoint.sh checks for vendor/obsidian/app.js on startup and runs the download scripts only if missing (~30s on first start) - obsidian_vendor Docker volume caches the result — subsequent starts are instant - Dockerfile becomes a single stage with no network dependency at build time --- Dockerfile | 33 +++++++++++++-------------------- docker-compose.yml | 7 +++++++ entrypoint.sh | 18 ++++++++++++++++++ 3 files changed, 38 insertions(+), 20 deletions(-) create mode 100644 entrypoint.sh diff --git a/Dockerfile b/Dockerfile index 792816c..37209b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,35 +1,28 @@ -# ── Stage 1: download Obsidian renderer files ───────────────────────────────── -# Both scripts use only Node built-ins (https, zlib, crypto, fs) — no npm install needed. -FROM node:20-alpine AS obsidian-dl -WORKDIR /app -# unzip is required by update-obsidian-mobile.js (extracts assets from the APK) -RUN apk add --no-cache unzip -COPY scripts/ scripts/ -RUN node scripts/update-obsidian.js \ - && node scripts/update-obsidian-mobile.js - -# ── Stage 2: production image ────────────────────────────────────────────────── FROM node:20-alpine WORKDIR /app -# App source and default vault +# 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 +RUN chmod +x /entrypoint.sh -# Pre-downloaded Obsidian renderer bundles (not in git, built in stage 1) -COPY --from=obsidian-dl /app/vendor/ vendor/ - -# Install server dependencies (production only, no devDeps) +# 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 — 127.0.0.1 would refuse outside connections +# 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 -# Optional auth (see docker-compose.auth-key.yml): -# ENV AUTH_KEY=change-me -# Run from repo root so PROJECT_ROOT resolves correctly via __dirname +# 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/docker-compose.yml b/docker-compose.yml index 78f39a8..b508b99 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ services: volumes: # Vault and registry persist on the host — safe across container rebuilds - ./user-data:/app/user-data + # Obsidian renderer bundles — downloaded on first start, cached across restarts + - obsidian_vendor:/app/vendor environment: HOST: "0.0.0.0" PORT: "3000" @@ -19,6 +21,11 @@ services: # AUTH_KEY: "change-me" restart: unless-stopped +volumes: + # Named volume so the downloaded Obsidian bundles survive container rebuilds. + # Delete with `docker volume rm obsidian_vendor` to force a fresh download. + obsidian_vendor: + # ── Auth Option 2: HTTP Basic Auth via nginx ──────────────────────────────── # Puts nginx in front of the app with username/password protection. # Also handles WebSocket upgrade for real-time vault sync (/api/watch). diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..3874859 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +# Download Obsidian renderer bundles on first start (or if the volume is empty). +# Results are cached in the `obsidian_vendor` Docker volume — subsequent starts +# are instant because the files are already there. + +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 "$@" From 1191ee0816716357f5ec78eaf74b95a5320c461c Mon Sep 17 00:00:00 2001 From: Sean Date: Tue, 9 Jun 2026 16:43:00 -0700 Subject: [PATCH 05/56] docker: fix docker-compose.yml structure (nginx inside services, not volumes) --- docker-compose.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index b508b99..c844e20 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,11 +21,6 @@ services: # AUTH_KEY: "change-me" restart: unless-stopped -volumes: - # Named volume so the downloaded Obsidian bundles survive container rebuilds. - # Delete with `docker volume rm obsidian_vendor` to force a fresh download. - obsidian_vendor: - # ── Auth Option 2: HTTP Basic Auth via nginx ──────────────────────────────── # Puts nginx in front of the app with username/password protection. # Also handles WebSocket upgrade for real-time vault sync (/api/watch). @@ -49,3 +44,8 @@ volumes: depends_on: - obsidian-web restart: unless-stopped + +volumes: + # Named volume so the downloaded Obsidian bundles survive container rebuilds. + # Delete with `docker volume rm obsidian_vendor` to force a fresh download. + obsidian_vendor: From b939fb90d8d69c83cc59b3a3042875461406376a Mon Sep 17 00:00:00 2001 From: Sean Date: Tue, 9 Jun 2026 16:48:44 -0700 Subject: [PATCH 06/56] docker: fix EXDEV rename error by symlinking .tmp into vendor volume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The update scripts extract to /app/.tmp/ then rename() into /app/vendor/. rename() fails with EXDEV when src and dst are on different filesystems — which they are in Docker (.tmp = container layer, vendor = named volume). Fix: at startup, mkdir /app/vendor/.tmp and symlink /app/.tmp -> /app/vendor/.tmp so both paths live on the same volume filesystem. --- entrypoint.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/entrypoint.sh b/entrypoint.sh index 3874859..0d1bdd2 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,9 +1,14 @@ #!/bin/sh set -e -# Download Obsidian renderer bundles on first start (or if the volume is empty). -# Results are cached in the `obsidian_vendor` Docker volume — subsequent starts -# are instant because the files are already there. +# 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)..." From 9d2b7c275f55ff2dd51b5799eb4c8a1832592009 Mon Sep 17 00:00:00 2001 From: Sean Date: Tue, 9 Jun 2026 17:11:49 -0700 Subject: [PATCH 07/56] feat: custom vault/plugins mount, .env auth key, mobile safe-area fix docker-compose.yml: - commented examples for mounting your own vault folder and plugins dir - AUTH_KEY now loaded from .env file (gitignored) or Dockhand env editor - env_file with required:false so .env is optional auth: - .env.example shows how to configure AUTH_KEY locally - .gitignore: add .env so real keys are never committed mobile: - index.html: add env(safe-area-inset-top) padding on html element so content is not hidden under the status bar / notch on iOS and Android --- .env.example | 9 ++++++++ .gitignore | 3 +++ docker-compose.yml | 45 +++++++++++++++++++++--------------- src/client-mobile/index.html | 7 ++++++ 4 files changed, 46 insertions(+), 18 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..96e1833 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Copy this file to .env and fill in your values. +# .env is gitignored — never commit real secrets to the repo. + +# API key auth — set this to enable the login page. +# Can also be set directly in Dockhand's environment editor. +AUTH_KEY=change-me + +# Optional: override the default port +# PORT=3000 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/docker-compose.yml b/docker-compose.yml index c844e20..262a40d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,35 +4,46 @@ services: ports: - "${PORT:-3000}:3000" volumes: - # Vault and registry persist on the host — safe across container rebuilds + # ── Vault ────────────────────────────────────────────────────────────── + # Option A (default): use the built-in demo vault - ./user-data:/app/user-data - # Obsidian renderer bundles — downloaded on first start, cached across restarts + + # Option B: mount YOUR OWN vault folder from the host. + # 1. Uncomment the line below, replacing the left side with your path. + # 2. Set VAULT_PATH (in environment below) to match the right side. + # - /path/to/your/vault:/app/vaults/my-vault + + # ── Plugins ──────────────────────────────────────────────────────────── + # Mount a folder of extra Obsidian plugins into the container. + # Uncomment and point at your local .obsidian/plugins directory. + # - /path/to/your/.obsidian/plugins:/app/user-data/demo-vault/.obsidian/plugins + + # ── Obsidian renderer bundles (downloaded on first start) ─────────────── - obsidian_vendor:/app/vendor + environment: HOST: "0.0.0.0" PORT: "3000" + + # Path to the active vault — relative to the project root (/app inside + # the container), or absolute. Change this if you use Option B above. VAULT_PATH: "user-data/demo-vault" + # VAULT_REGISTRY: "user-data/registry.json" # WATCH_POLLING: "true" # enable for NFS / SMB / FUSE-mounted vaults # ── Auth Option 1: API Key ───────────────────────────────────────────── - # Uncomment and set AUTH_KEY to enable a login page + cookie session. - # In Dockhand you can set this in the stack's environment editor instead. - # AUTH_KEY: "change-me" + # Set AUTH_KEY via a .env file or Dockhand's environment editor. + # Never commit a real key here — this file is public. + # AUTH_KEY: "${AUTH_KEY}" + env_file: + - path: .env + required: false # silently ignored if .env doesn't exist restart: unless-stopped # ── Auth Option 2: HTTP Basic Auth via nginx ──────────────────────────────── - # Puts nginx in front of the app with username/password protection. - # Also handles WebSocket upgrade for real-time vault sync (/api/watch). - # - # Setup (run once on the host): - # htpasswd -c nginx.htpasswd - # - # To start with this option: - # docker compose --profile auth-nginx up - # - # When using this profile, comment out the `ports` on obsidian-web above - # so the app is only reachable through nginx, not directly. + # Setup: htpasswd -c nginx.htpasswd + # Start: docker compose --profile auth-nginx up nginx: profiles: ["auth-nginx"] image: nginx:alpine @@ -46,6 +57,4 @@ services: restart: unless-stopped volumes: - # Named volume so the downloaded Obsidian bundles survive container rebuilds. - # Delete with `docker volume rm obsidian_vendor` to force a fresh download. obsidian_vendor: diff --git a/src/client-mobile/index.html b/src/client-mobile/index.html index fdbfc9e..28d6c59 100644 --- a/src/client-mobile/index.html +++ b/src/client-mobile/index.html @@ -6,6 +6,13 @@ Obsidian Web
-

🔒 Obsidian Web

-

Invalid key — try again.

-
- - - +

🔐 Obsidian Web

+

Enter the 6-digit code from your authenticator app

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

+
`; -/** - * Returns an Express middleware if AUTH_KEY is set, otherwise null. - */ +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() { - if (!AUTH_KEY) return null; + if (!TOTP_SECRET) return null; + + 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); + } - return function authMiddleware(req, res, next) { - // ── Auth callback: verify key, set cookie, redirect ── + // ── Auth endpoint ── verify code, set cookie if (req.path === '/__auth') { - const key = req.query?.key || ''; + const code = String(req.query?.code || '').replace(/\D/g, ''); const dest = req.query?.next || '/'; - if (key === AUTH_KEY) { + if (code.length === 6 && authenticator.verify({ token: code, secret: TOTP_SECRET })) { res.setHeader('Set-Cookie', - `${COOKIE}=${AUTH_KEY}; Path=/; Max-Age=${MAX_AGE}; HttpOnly; SameSite=Strict`); + `${COOKIE}=${SESSION_TOKEN}; Path=/; Max-Age=${MAX_AGE}; HttpOnly; SameSite=Strict`); return res.redirect(dest); } return res.redirect(`/__login?error=1&next=${encodeURIComponent(dest)}`); } - // ── Login page: always accessible ── + // ── 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: pass through ── + // ── 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 — provide a valid Bearer token or cookie' }); + return res.status(401).json({ error: 'Unauthorized — valid TOTP session required' }); } res.redirect(`/__login?next=${encodeURIComponent(req.originalUrl)}`); }; diff --git a/src/server/package-lock.json b/src/server/package-lock.json index 29c4a64..60f3e90 100644 --- a/src/server/package-lock.json +++ b/src/server/package-lock.json @@ -1,1100 +1,1476 @@ -{ - "name": "obsidian-web-server", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "obsidian-web-server", - "version": "0.1.0", - "dependencies": { - "chokidar": "^3.6.0", - "compression": "^1.8.1", - "express": "^4.21.0", - "ws": "^8.18.0" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "license": "MIT", - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/compression/node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.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", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - } - } -} +{ + "name": "obsidian-web-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "obsidian-web-server", + "version": "0.1.0", + "dependencies": { + "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", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "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", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "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", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "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", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "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", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "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", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "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", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "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", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "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", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "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", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "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", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "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", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "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", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "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", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "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", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "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", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "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", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "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", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "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", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "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..6eb4049 100644 --- a/src/server/package.json +++ b/src/server/package.json @@ -1,18 +1,20 @@ -{ - "name": "obsidian-web-server", - "version": "0.1.0", - "description": "HTTP/WebSocket backend for Obsidian web wrapper", - "main": "index.js", - "type": "commonjs", - "scripts": { - "start": "node index.js", - "dev": "node --watch index.js", - "test": "node --test" - }, - "dependencies": { - "chokidar": "^3.6.0", - "compression": "^1.8.1", - "express": "^4.21.0", - "ws": "^8.18.0" - } -} +{ + "name": "obsidian-web-server", + "version": "0.1.0", + "description": "HTTP/WebSocket backend for Obsidian web wrapper", + "main": "index.js", + "type": "commonjs", + "scripts": { + "start": "node index.js", + "dev": "node --watch index.js", + "test": "node --test" + }, + "dependencies": { + "chokidar": "^3.6.0", + "compression": "^1.8.1", + "express": "^4.21.0", + "otplib": "^12.0.1", + "qrcode": "^1.5.4", + "ws": "^8.18.0" + } +} From e610b73877c2b5cc85b4f46bd7284b60e79f4867 Mon Sep 17 00:00:00 2001 From: s39n Date: Tue, 9 Jun 2026 19:55:34 -0700 Subject: [PATCH 15/56] fix: open-url recursion + proxy redirect following --- src/client/shims/electron.js | 14 ++++++++++++-- src/server/api/proxy.js | 12 +++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/client/shims/electron.js b/src/client/shims/electron.js index 899807a..67a0960 100644 --- a/src/client/shims/electron.js +++ b/src/client/shims/electron.js @@ -18,6 +18,13 @@ * are stubs that log so we can spot uses we missed. */ (function (global) { + // Save a reference to the real window.open BEFORE Obsidian patches it. + // Obsidian overrides window.open to route URLs through its own link handler, + // which calls ipcRenderer.send('open-url', ...) — so if our 'open-url' + // handler calls window.open, we get infinite recursion. Using this saved + // reference bypasses Obsidian's patch and opens a real new tab. + const _nativeWindowOpen = window.open.bind(window); + function warnUnimplemented(name) { return function () { console.warn('[obsidian-web] electron.' + name + ' called but not implemented:', arguments); @@ -360,8 +367,11 @@ } // 'open-url' opens in a new tab. + // Use _nativeWindowOpen (saved before Obsidian patches window.open) + // to avoid the infinite recursion: Obsidian's patched window.open + // routes back through ipcRenderer.send('open-url', ...). if (channel === 'open-url' && args[0]) { - window.open(args[0], '_blank', 'noopener'); + _nativeWindowOpen(args[0], '_blank', 'noopener'); return; } // Application-menu IPC channels - ignored on web. Obsidian renders @@ -408,7 +418,7 @@ const remote = { shell: { showItemInFolder: warnUnimplemented('shell.showItemInFolder'), - openExternal: (url) => { window.open(url, '_blank', 'noopener'); return Promise.resolve(); }, + openExternal: (url) => { _nativeWindowOpen(url, '_blank', 'noopener'); return Promise.resolve(); }, openPath: warnUnimplemented('shell.openPath'), }, dialog: { diff --git a/src/server/api/proxy.js b/src/server/api/proxy.js index bfb306f..fa4f400 100644 --- a/src/server/api/proxy.js +++ b/src/server/api/proxy.js @@ -48,7 +48,7 @@ function isAllowed(urlStr) { } } -function fetchUrl(urlStr, method, reqHeaders, body) { +function fetchUrl(urlStr, method, reqHeaders, body, redirectsLeft = 5) { return new Promise((resolve, reject) => { let parsed; try { parsed = new URL(urlStr); } catch (e) { return reject(e); } @@ -63,6 +63,16 @@ function fetchUrl(urlStr, method, reqHeaders, body) { }; const req = lib.request(options, (res) => { + // Follow redirects (GitHub releases redirect to CDN) + if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) { + res.resume(); // drain the response + if (redirectsLeft <= 0) return reject(new Error('too many redirects')); + const next = new URL(res.headers.location, urlStr).toString(); + // Don't check allow-list for redirect targets — caller already validated origin + fetchUrl(next, res.statusCode === 303 ? 'GET' : method, reqHeaders, body, redirectsLeft - 1) + .then(resolve).catch(reject); + return; + } const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { From 86aa2c8ecc1c8bc04413f2713e934933862dd4ef Mon Sep 17 00:00:00 2001 From: s39n Date: Tue, 9 Jun 2026 20:03:20 -0700 Subject: [PATCH 16/56] fix: open-url recursion, proxy redirects, crypto.randomUUID polyfill --- src/client/boot.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/client/boot.js b/src/client/boot.js index ac8fea0..8b59edd 100644 --- a/src/client/boot.js +++ b/src/client/boot.js @@ -19,6 +19,16 @@ * the browser can download them in parallel but executes them in order. */ +// Polyfill crypto.randomUUID for non-secure contexts (plain HTTP on LAN). +// Browsers restrict this API to HTTPS/localhost; plugins like ion-sync need it. +if (typeof crypto !== 'undefined' && !crypto.randomUUID) { + crypto.randomUUID = function () { + return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) + ); + }; +} + // Ordered list of Obsidian's renderer scripts — mirrors the old + diff --git a/src/client/boot.js b/src/client/boot.js index e21fbdd..1d85e94 100644 --- a/src/client/boot.js +++ b/src/client/boot.js @@ -777,12 +777,25 @@ const OBSIDIAN_SCRIPTS = [ if (pollTimer) clearInterval(pollTimer); } - fetch('/api/bootstrap?vault=' + vaultParam + '&full=1') - .then(function (res) { - if (!res.ok) throw new Error('HTTP ' + res.status); - return res.json(); - }) - .then(function (data) { + // Bootstrap cache and the server-backed localStorage load in parallel. + // Both must be ready BEFORE Obsidian's scripts are injected: app.js + // reads localStorage (safeStorage tokens, app state) synchronously at + // startup. A localStorage failure is non-fatal — native storage stays. + Promise.all([ + fetch('/api/bootstrap?vault=' + vaultParam + '&full=1') + .then(function (res) { + if (!res.ok) throw new Error('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 data = results[0]; stopPolling(); var vault = data.electron && data.electron['vault']; if (!vault || !vault.id) { diff --git a/src/client/index.html b/src/client/index.html index c79f122..56024be 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -54,6 +54,7 @@ together whenever any client file changes and needs to reach the browser past its HTTP cache. One global version for all client files. --> + diff --git a/src/client/shims/remote-localstorage.js b/src/client/shims/remote-localstorage.js new file mode 100644 index 0000000..ad64b3d --- /dev/null +++ b/src/client/shims/remote-localstorage.js @@ -0,0 +1,178 @@ +'use strict'; + +/** + * Server-backed window.localStorage replacement. + * + * Why: Obsidian and its plugins store state in localStorage — most + * importantly the safeStorage keychain tokens (see electron.js shim). + * Browser localStorage is per-device AND per-origin, so secrets entered on + * one PC don't roam to another, and http://nas:3005 vs the cloudflared URL + * don't even share state on the same PC. This shim makes localStorage live + * server-side (user-data/.localstorage.json) so it follows the vault, + * matching real Electron semantics (one install = one shared store). + * + * Keys prefixed 'obsidian-web:' (layout mode, last vault id) are deliberately + * device-local and keep using the native localStorage. + * + * Install contract: boot.js calls window.__owInstallRemoteLocalStorage() + * BEFORE injecting Obsidian's scripts and waits for the returned promise. + * On failure the native localStorage stays in place (old behavior). + */ +(function () { + var LOCAL_PREFIX = 'obsidian-web:'; // stays per-device + var FLUSH_DELAY_MS = 300; + + window.__owInstallRemoteLocalStorage = function () { + var native = window.localStorage; + + return fetch('/api/localstorage') + .then(function (res) { + if (!res.ok) throw new Error('HTTP ' + res.status); + return res.json(); + }) + .then(function (serverData) { + var mem = {}; + Object.keys(serverData).forEach(function (k) { mem[k] = String(serverData[k]); }); + + // One-time migration: keys already in this device's native storage + // but missing on the server get uploaded, so the first PC that runs + // this seeds the server store with its existing tokens/state. + var seed = {}; + var seeded = 0; + for (var i = 0; i < native.length; i++) { + var k = native.key(i); + if (k.indexOf(LOCAL_PREFIX) === 0) continue; + if (!(k in mem)) { + var v = native.getItem(k); + mem[k] = v; + seed[k] = v; + seeded++; + } + } + + // ── Debounced write-through ────────────────────────────────────── + var pending = {}; // key → value|null + var hasPending = false; + var timer = null; + + function queue(key, value) { + pending[key] = value; + hasPending = true; + if (timer) clearTimeout(timer); + timer = setTimeout(flush, FLUSH_DELAY_MS); + } + + function flush() { + if (!hasPending) return; + var entries = pending; + pending = {}; + hasPending = false; + timer = null; + fetch('/api/localstorage', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ entries: entries }), + }).catch(function (e) { + console.warn('[obsidian-web] localStorage flush failed:', e && e.message); + }); + } + + // Last-chance flush when the tab closes. keepalive lets the PUT + // survive page unload (sendBeacon can't be used — it only POSTs). + window.addEventListener('pagehide', function () { + if (!hasPending) return; + var entries = pending; + pending = {}; + hasPending = false; + try { + fetch('/api/localstorage', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ entries: entries }), + keepalive: true, + }); + } catch (_) {} + }); + + // ── Storage-compatible facade ──────────────────────────────────── + var storageShim = { + getItem: function (key) { + key = String(key); + if (key.indexOf(LOCAL_PREFIX) === 0) return native.getItem(key); + return Object.prototype.hasOwnProperty.call(mem, key) ? mem[key] : null; + }, + setItem: function (key, value) { + key = String(key); value = String(value); + if (key.indexOf(LOCAL_PREFIX) === 0) return native.setItem(key, value); + mem[key] = value; + queue(key, value); + }, + removeItem: function (key) { + key = String(key); + if (key.indexOf(LOCAL_PREFIX) === 0) return native.removeItem(key); + if (Object.prototype.hasOwnProperty.call(mem, key)) { + delete mem[key]; + queue(key, null); + } + }, + clear: function () { + Object.keys(mem).forEach(function (k) { queue(k, null); }); + mem = {}; + // Native obsidian-web: keys are intentionally kept. + }, + key: function (n) { + var keys = Object.keys(mem); + return n >= 0 && n < keys.length ? keys[n] : null; + }, + }; + Object.defineProperty(storageShim, 'length', { + get: function () { return Object.keys(mem).length; }, + }); + + // Proxy catches direct property access (localStorage.foo = 'x' / + // localStorage['foo']) which some plugins use instead of setItem. + var proxy = new Proxy(storageShim, { + get: function (target, prop) { + if (prop in target) { + var v = target[prop]; + return typeof v === 'function' ? v.bind(target) : v; + } + if (typeof prop === 'symbol') return undefined; + var item = target.getItem(prop); + return item === null ? undefined : item; + }, + set: function (target, prop, value) { + if (typeof prop !== 'symbol') target.setItem(prop, value); + return true; + }, + deleteProperty: function (target, prop) { + if (typeof prop !== 'symbol') target.removeItem(prop); + return true; + }, + has: function (target, prop) { + return prop in target || target.getItem(prop) !== null; + }, + ownKeys: function () { + return Object.keys(mem); + }, + getOwnPropertyDescriptor: function (target, prop) { + if (typeof prop === 'symbol' || !(prop in mem)) return undefined; + return { value: mem[prop], writable: true, enumerable: true, configurable: true }; + }, + }); + + Object.defineProperty(window, 'localStorage', { + value: proxy, + configurable: true, + }); + + // Upload the migration seed after install so it can't race the map. + if (seeded > 0) { + Object.keys(seed).forEach(function (k) { queue(k, seed[k]); }); + console.log('[obsidian-web] remote localStorage: seeded ' + seeded + ' local keys to server'); + } + + console.log('[obsidian-web] remote localStorage installed (' + Object.keys(mem).length + ' keys)'); + }); + }; +})(); diff --git a/src/server/api/localstorage.js b/src/server/api/localstorage.js new file mode 100644 index 0000000..2641e15 --- /dev/null +++ b/src/server/api/localstorage.js @@ -0,0 +1,76 @@ +'use strict'; + +/** + * Server-backed localStorage store. + * + * Obsidian (and its plugins) keep state in window.localStorage — including + * the safeStorage keychain *tokens* that point at secrets in .keychain.json. + * Browser localStorage is scoped per device AND per origin, so credentials + * entered on one PC (or via one URL, e.g. LAN vs cloudflared tunnel) don't + * roam. The client shim (client/shims/remote-localstorage.js) replaces + * window.localStorage with a copy of this server-side store, making that + * state follow the server instead of the browser. + * + * This mirrors real Electron semantics: one Obsidian install = one shared + * localStorage across all vaults, persisted next to the rest of user-data. + * + * API: + * GET /api/localstorage → { key: value, ... } (full map) + * PUT /api/localstorage ← { entries: { key: value | null } } + * null deletes the key. Batched by the + * client's debounced write-through. + */ + +const express = require('express'); +const fsp = require('fs/promises'); +const path = require('path'); + +function createLocalStorageRouter(userDataPath) { + const router = express.Router(); + const storeFile = path.join(userDataPath, '.localstorage.json'); + + // Serialize writes so concurrent PUTs don't interleave load/save. + let writeChain = Promise.resolve(); + + async function load() { + try { + return JSON.parse(await fsp.readFile(storeFile, 'utf8')) || {}; + } catch (_) { + return {}; + } + } + + async function save(data) { + await fsp.mkdir(path.dirname(storeFile), { recursive: true }); + // Atomic: temp file + rename, same pattern as vault-registry. + const tmp = storeFile + '.tmp'; + await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf8'); + await fsp.rename(tmp, storeFile); + } + + router.get('/', async (req, res) => { + res.json(await load()); + }); + + router.put('/', express.json({ limit: '5mb' }), (req, res) => { + const entries = req.body && req.body.entries; + if (!entries || typeof entries !== 'object' || Array.isArray(entries)) { + return res.status(400).json({ error: 'entries object required' }); + } + writeChain = writeChain.then(async () => { + const data = await load(); + for (const [key, value] of Object.entries(entries)) { + if (value === null) delete data[key]; + else data[key] = String(value); + } + await save(data); + res.json({ ok: true }); + }).catch((err) => { + res.status(500).json({ error: err.message }); + }); + }); + + return router; +} + +module.exports = createLocalStorageRouter; diff --git a/src/server/index.js b/src/server/index.js index 53c3939..ec4a032 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -23,6 +23,7 @@ const createBootstrapRouter = require('./api/bootstrap'); const { warmUpBootstrapCache } = require('./api/bootstrap'); const createProxyRouter = require('./api/proxy'); const createKeytarRouter = require('./api/keytar'); +const createLocalStorageRouter = require('./api/localstorage'); const createPbkdf2Router = require('./api/pbkdf2'); const attachWatchServer = require('./api/watch'); const VaultRegistry = require('./vault-registry'); @@ -30,7 +31,13 @@ const { createAuthMiddleware } = require('./middleware/auth'); function createApp(appConfig = config) { const app = express(); - const vaultRegistry = new VaultRegistry(appConfig.registryPath); + const vaultRegistry = new VaultRegistry(appConfig.registryPath, { + // Restrict /api/vaults/open to paths under vaultsRoot (VAULTS_ROOT env, + // default user-data/). The configured boot vault is always allowed even + // if it lives elsewhere. Tests pass no vaultsRoot → unrestricted. + vaultsRoot: appConfig.vaultsRoot, + allowPaths: [appConfig.vaultPath], + }); // Compression — critical for /api/bootstrap (38MB uncompressed → ~6MB). // Brotli gives ~84% reduction, gzip ~79%. The middleware auto-selects based @@ -40,7 +47,7 @@ function createApp(appConfig = config) { // Optional TOTP auth — enabled by setting TOTP_SECRET env var. // Generate a secret: node -e "const {authenticator}=require('otplib');console.log(authenticator.generateSecret())" // Then visit /__totp-setup?token=YOUR_SECRET to scan the QR code. - const authMiddleware = createAuthMiddleware(); + const authMiddleware = createAuthMiddleware(appConfig); if (authMiddleware) { app.use(authMiddleware); console.log('[auth] TOTP authentication enabled — visit /__totp-setup?token=YOUR_SECRET to configure your authenticator app'); @@ -146,6 +153,7 @@ function createApp(appConfig = config) { // API routes. app.use('/api/keytar', createKeytarRouter(appConfig.userDataPath)); + app.use('/api/localstorage', createLocalStorageRouter(appConfig.userDataPath)); app.use('/api/pbkdf2', createPbkdf2Router()); app.use('/api/bootstrap', createBootstrapRouter(vaultRegistry, appConfig.vaultPath)); app.use('/api/proxy-request', createProxyRouter()); diff --git a/src/server/test/vaults-api.test.js b/src/server/test/vaults-api.test.js index 5550913..df9166d 100644 --- a/src/server/test/vaults-api.test.js +++ b/src/server/test/vaults-api.test.js @@ -292,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/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/ From dab0c97ad808cfb248ec340d4195b80208e5e54e Mon Sep 17 00:00:00 2001 From: s39n Date: Fri, 12 Jun 2026 14:07:52 +0000 Subject: [PATCH 42/56] feat: add Service Worker bootstrap cache (stale-while-revalidate) Repeat visits now hit a CacheStorage entry instead of waiting for the server to return /api/bootstrap. The SW fires a background revalidation in parallel, keeping the cache fresh for the NEXT visit. - src/client/sw.js: new SW; intercepts GET /api/bootstrap (not /status), serves from cache immediately then updates in background; skipWaiting + clients.claim() for immediate takeover on install. - src/server/index.js: add GET /sw.js route with Service-Worker-Allowed: / and Cache-Control: no-cache so the SW can control the full origin and always receives updates. - src/client/boot.js: register /sw.js at the top of the IIFE, before the bootstrap fetch, so the SW is in place for subsequent navigations. --- src/client/boot.js | 11 ++++++ src/client/sw.js | 88 +++++++++++++++++++++++++++++++++++++++++++++ src/server/index.js | 31 +++++++--------- 3 files changed, 111 insertions(+), 19 deletions(-) create mode 100644 src/client/sw.js diff --git a/src/client/boot.js b/src/client/boot.js index 1d85e94..8f90d81 100644 --- a/src/client/boot.js +++ b/src/client/boot.js @@ -333,6 +333,17 @@ const OBSIDIAN_SCRIPTS = [ window.global = window; } + // Register the Service Worker for bootstrap caching (stale-while-revalidate). + // On repeat visits the SW intercepts /api/bootstrap and returns a cached + // response instantly, then revalidates in the background — no server + // round-trip needed. The SW file is served at /sw.js with + // Service-Worker-Allowed: / so it can intercept requests on the full origin. + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(err => { + console.warn('[obsidian-web] service worker registration failed:', err.message); + }); + } + const VAULT_BASE = '/vault'; const params = new URLSearchParams(location.search); let VAULT_ID = params.get('vault') || localStorage.getItem('obsidian-web:lastVaultId') || ''; diff --git a/src/client/sw.js b/src/client/sw.js new file mode 100644 index 0000000..ab55362 --- /dev/null +++ b/src/client/sw.js @@ -0,0 +1,88 @@ +/** + * Service Worker — bootstrap cache (stale-while-revalidate) + * + * Intercepts GET /api/bootstrap requests and serves from CacheStorage, + * firing a background revalidation in parallel. On repeat visits this + * eliminates the ~200–800 ms server round-trip with a <5 ms cache hit. + * + * Cache is keyed by full URL (includes ?vault=...&full=1) so each vault + * gets its own entry. Bump CACHE_NAME to evict all entries on SW update. + */ + +const CACHE_NAME = 'ow-bootstrap-v1'; + +// ── Lifecycle ──────────────────────────────────────────────────────────────── + +self.addEventListener('install', () => { + // Skip the waiting phase so this SW takes over immediately on install, + // without needing a second page load. + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + // Evict any cache entries from older SW versions. + caches.keys() + .then(keys => Promise.all( + keys + .filter(k => k.startsWith('ow-bootstrap-') && k !== CACHE_NAME) + .map(k => { + console.log('[ow-sw] evicting old cache:', k); + return caches.delete(k); + }), + )) + // Take control of all open tabs immediately so the cache is used + // on the very next fetch, even on the page that registered us. + .then(() => self.clients.claim()), + ); +}); + +// ── Fetch interception ─────────────────────────────────────────────────────── + +self.addEventListener('fetch', (event) => { + const { request } = event; + if (request.method !== 'GET') return; + + const url = new URL(request.url); + + // Only cache /api/bootstrap — skip the /status progress-polling sub-route. + if (!url.pathname.startsWith('/api/bootstrap')) return; + if (url.pathname.includes('/status')) return; + + event.respondWith(staleWhileRevalidate(request)); +}); + +// ── Strategy ───────────────────────────────────────────────────────────────── + +async function staleWhileRevalidate(request) { + const cache = await caches.open(CACHE_NAME); + const cached = await cache.match(request); + + // Always kick off a background network fetch to keep the cache fresh. + // Don't await it — we return the cached response immediately. + const networkFetch = fetch(request) + .then(response => { + if (response.ok) { + cache.put(request, response.clone()); + console.log('[ow-sw] bootstrap cache updated:', new URL(request.url).search); + } + return response; + }) + .catch(err => { + console.warn('[ow-sw] bootstrap revalidation failed:', err.message); + return null; + }); + + if (cached) { + console.log('[ow-sw] bootstrap cache HIT:', new URL(request.url).search); + return cached; + } + + // Cache miss (first visit) — wait for the network. + console.log('[ow-sw] bootstrap cache MISS:', new URL(request.url).search); + const response = await networkFetch; + if (!response) { + throw new Error('[ow-sw] bootstrap fetch failed with no cached fallback'); + } + return response; +} diff --git a/src/server/index.js b/src/server/index.js index ec4a032..46313f3 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -134,6 +134,17 @@ function createApp(appConfig = config) { })); } + // Service Worker — served from the root path so its scope covers the whole + // origin. Service-Worker-Allowed: / is required because the file lives + // under /client/ but must control pages at /. Cache-Control: no-cache + // ensures browsers always re-fetch it so SW updates propagate promptly. + app.get('/sw.js', (req, res) => { + res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Service-Worker-Allowed', '/'); + res.sendFile(path.join(appConfig.clientPath, 'sw.js')); + }); + // Worker scripts. Obsidian creates `new Worker("worker.js")` which under // Electron resolves to /Resources/obsidian/worker.js, but in a browser // it resolves relative to the document URL. Serve them at the root. @@ -180,22 +191,4 @@ function startServer(appConfig = config) { console.log('=========================================='); console.log(' Vault: ' + appConfig.vaultPath); console.log(' Obsidian: ' + appConfig.obsidianPath); - console.log(' Listening on http://' + appConfig.host + ':' + appConfig.port); - console.log('=========================================='); - - // Pre-build the bootstrap cache in the background so the first browser - // request is a cache HIT instead of a cold build. - setImmediate(() => { - warmUpBootstrapCache(app.locals.vaultRegistry, appConfig.vaultPath) - .catch((err) => console.warn('[bootstrap] warm-up error:', err.message)); - }); - }); - - return server; -} - -if (require.main === module) { - startServer(); -} - -module.exports = { createApp, startServer }; + console.log(' \ No newline at end of file From ccb44fca47446dc8702227ace0f731bcfc6e16b5 Mon Sep 17 00:00:00 2001 From: s39n Date: Mon, 15 Jun 2026 11:56:00 -0700 Subject: [PATCH 43/56] fix: complete truncated index.js (syntax error in Docker) --- src/server/index.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/server/index.js b/src/server/index.js index 46313f3..1e8ff75 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -191,4 +191,22 @@ function startServer(appConfig = config) { console.log('=========================================='); console.log(' Vault: ' + appConfig.vaultPath); console.log(' Obsidian: ' + appConfig.obsidianPath); - console.log(' \ No newline at end of file + console.log(' Listening on http://' + appConfig.host + ':' + appConfig.port); + console.log('=========================================='); + + // Pre-build the bootstrap cache in the background so the first browser + // request is a cache HIT instead of a cold build. + setImmediate(() => { + warmUpBootstrapCache(app.locals.vaultRegistry, appConfig.vaultPath) + .catch((err) => console.warn('[bootstrap] warm-up error:', err.message)); + }); + }); + + return server; +} + +if (require.main === module) { + startServer(); +} + +module.exports = { createApp, startServer }; From af07246e94eae435cb126fe6915f17989c57dbe4 Mon Sep 17 00:00:00 2001 From: s39n Date: Mon, 15 Jun 2026 14:52:22 -0700 Subject: [PATCH 44/56] perf: stale-while-revalidate for bootstrap server cache Serve existing cache immediately when dirs have changed, rebuild in background. Prevents 30-60s blocking vault scan on every refresh for large vaults. --- src/server/api/bootstrap.js | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/server/api/bootstrap.js b/src/server/api/bootstrap.js index 9f77fe0..241cbec 100644 --- a/src/server/api/bootstrap.js +++ b/src/server/api/bootstrap.js @@ -425,16 +425,28 @@ function createBootstrapRouter(vaultRegistry, fallbackVaultRoot) { const vault = vaultId ? vaultRegistry.get(vaultId) : null; const vaultRoot = vault ? vault.path : fallbackVaultRoot; - // If a full=1 build is needed but we only have a partial cache, serve - // the partial result immediately and kick off the full build in the background. + // Stale-while-revalidate: if ANY cached entry exists, serve it immediately + // and rebuild in the background. This prevents the browser from blocking on + // a full vault rescan (which can take 30-60s for large vaults) when the + // cache was merely invalidated by a dir mtime change (e.g. ion-sync activity). + // + // Only block on a true cold start (no entry at all). On the next request + // after the background build finishes, the client will get fresh data. const existing = serverCache.get(vaultId); let entry; - if (full && existing && !existing.isFull) { - console.log(`[bootstrap] vault=${vaultId.slice(0, 8)}… serving partial while full build runs in background`); - buildCacheEntry(vaultId, vaultRoot, vaultRegistry, true) - .catch((err) => console.warn('[bootstrap] background full build error:', err.message)); + if (existing) { + const needsFull = full && !existing.isFull; + if (needsFull) { + console.log(`[bootstrap] vault=${vaultId.slice(0, 8)}… serving partial/stale while full build runs in background`); + } else { + console.log(`[bootstrap] vault=${vaultId.slice(0, 8)}… serving stale-while-revalidate`); + } + // Fire background refresh (deduplicated via pendingBuilds — no-op if already running). + buildCacheEntry(vaultId, vaultRoot, vaultRegistry, full) + .catch((err) => console.warn('[bootstrap] background revalidation error:', err.message)); entry = existing; } else { + // Cold start — no cached entry at all. Must wait for the initial build. entry = await buildCacheEntry(vaultId, vaultRoot, vaultRegistry, full); } From 736a3d33b0b36796d7ed7f0a73edd7be546981a2 Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 17 Jun 2026 15:47:32 +0000 Subject: [PATCH 45/56] feat: notify on available Obsidian renderer updates at startup Add scripts/check-obsidian-version.js, a dependency-free, offline-safe check comparing the installed renderer version (vendor/obsidian/package.json, falling back to config APP_VERSION) against the latest obsidianmd/ obsidian-releases tag. The server runs it in the background at boot and logs a notice when a newer release exists; it never downloads or applies updates (that stays the manual update-obsidian.js step). Configurable via OBSIDIAN_UPDATE_CHECK, OBSIDIAN_VERSION (pin), and OBSIDIAN_CHECK_TIMEOUT. Documented in README; CHANGELOG added. --- CHANGELOG.md | 19 +++ scripts/check-obsidian-version.js | 236 ++++++++++++++++++++++++++++++ src/server/index.js | 20 +++ 3 files changed, 275 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 scripts/check-obsidian-version.js diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..67176b1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to this project are documented here. The format is loosely +based on [Keep a Changelog](https://keepachangelog.com/). + +## [Unreleased] + +### Added +- **Obsidian update notifications (notify-only).** The server now checks + `obsidianmd/obsidian-releases` at startup and logs a notice when a newer + renderer is available. It never downloads or applies the update + automatically; applying stays the deliberate `node scripts/update-obsidian.js` + step. (2026-06-17) +- `scripts/check-obsidian-version.js` — dependency-free, offline-safe version + check usable both as a module (called by the server at boot) and as a CLI + (`--json` for machine output; exit code 10 when an update is available). +- Environment variables `OBSIDIAN_UPDATE_CHECK` (disable the check), + `OBSIDIAN_VERSION` (pin a version / compare without network), and + `OBSIDIAN_CHECK_TIMEOUT` (GitHub request timeout). See README. diff --git a/scripts/check-obsidian-version.js b/scripts/check-obsidian-version.js new file mode 100644 index 0000000..d6ac237 --- /dev/null +++ b/scripts/check-obsidian-version.js @@ -0,0 +1,236 @@ +#!/usr/bin/env node +'use strict'; + +/** + * check-obsidian-version.js + * + * Dependency-free "is there a newer Obsidian renderer?" check. + * + * It compares the version currently extracted into vendor/obsidian/ against the + * latest release published to the obsidianmd/obsidian-releases GitHub repo, and + * reports whether an update is available. It NEVER downloads or applies the + * update — that stays a deliberate, manual step: + * + * node scripts/update-obsidian.js # latest + * node scripts/update-obsidian.js --version X # a specific version + * + * Usage (CLI): + * node scripts/check-obsidian-version.js # human-readable status + * node scripts/check-obsidian-version.js --json # machine-readable JSON + * + * Usage (module): + * const { checkObsidianVersion } = require('./check-obsidian-version'); + * const result = await checkObsidianVersion(); + * // { installed, latest, pinned, updateAvailable, checked, reason } + * + * Environment: + * OBSIDIAN_VERSION Pin to a specific version (e.g. 1.12.7). When set, + * the check compares the installed version against the + * pin instead of GitHub's latest, and never reports a + * newer release as "available". + * OBSIDIAN_UPDATE_CHECK Set to "false"/"0"/"off" to disable the network + * check entirely (the server still calls it but it + * short-circuits). Default: enabled. + * OBSIDIAN_CHECK_TIMEOUT Network timeout in ms for the GitHub request. + * Default: 5000. + */ + +const fs = require('fs'); +const https = require('https'); +const path = require('path'); + +const PROJECT_ROOT = path.resolve(__dirname, '..'); +const VENDOR_PKG = path.join(PROJECT_ROOT, 'vendor', 'obsidian', 'package.json'); +const GITHUB_LATEST_API = + 'https://api.github.com/repos/obsidianmd/obsidian-releases/releases/latest'; +const USER_AGENT = 'obsidian-web-updater'; +const DEFAULT_TIMEOUT = 5000; + +function isCheckDisabled() { + const raw = (process.env.OBSIDIAN_UPDATE_CHECK || '').trim().toLowerCase(); + return raw === 'false' || raw === '0' || raw === 'off' || raw === 'no'; +} + +function pinnedVersion() { + const raw = (process.env.OBSIDIAN_VERSION || '').trim(); + return raw ? raw.replace(/^v/i, '') : null; +} + +/** + * Read the version actually extracted into vendor/obsidian/. Falls back to the + * server's config APP_VERSION when the vendor dir has not been populated yet + * (e.g. before the first-run download), and finally to null. + */ +function readInstalledVersion() { + try { + const pkg = JSON.parse(fs.readFileSync(VENDOR_PKG, 'utf8')); + if (pkg && pkg.version) return String(pkg.version).replace(/^v/i, ''); + } catch (_) { + /* vendor not populated yet */ + } + try { + // Lazy require so this script stays usable outside the server tree. + const cfg = require(path.join(PROJECT_ROOT, 'src', 'server', 'config')); + if (cfg && cfg.appVersion) return String(cfg.appVersion).replace(/^v/i, ''); + } catch (_) { + /* config not importable in this context */ + } + return null; +} + +/** + * Compare two dotted version strings numerically. + * Returns 1 if a > b, -1 if a < b, 0 if equal. Non-numeric / missing parts + * are treated as 0, so "1.12" === "1.12.0". + */ +function compareVersions(a, b) { + const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0); + const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0); + const len = Math.max(pa.length, pb.length); + for (let i = 0; i < len; i += 1) { + const x = pa[i] || 0; + const y = pb[i] || 0; + if (x > y) return 1; + if (x < y) return -1; + } + return 0; +} + +function fetchLatestTag(timeout) { + return new Promise((resolve, reject) => { + const req = https.get( + GITHUB_LATEST_API, + { + headers: { + Accept: 'application/vnd.github+json', + 'User-Agent': USER_AGENT, + }, + timeout, + }, + (res) => { + if ( + res.statusCode >= 300 && + res.statusCode < 400 && + res.headers.location + ) { + res.resume(); + // The latest endpoint shouldn't redirect, but follow once to be safe. + https + .get( + res.headers.location, + { headers: { Accept: 'application/vnd.github+json', 'User-Agent': USER_AGENT }, timeout }, + (r2) => collect(r2, resolve, reject), + ) + .on('error', reject); + return; + } + collect(res, resolve, reject); + }, + ); + req.on('timeout', () => req.destroy(new Error(`GitHub request timed out after ${timeout}ms`))); + req.on('error', reject); + }); +} + +function collect(res, resolve, reject) { + if (res.statusCode < 200 || res.statusCode >= 300) { + res.resume(); + reject(new Error(`GitHub API returned HTTP ${res.statusCode}`)); + return; + } + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + try { + const data = JSON.parse(Buffer.concat(chunks).toString('utf8')); + const tag = data.tag_name || data.name; + if (!tag) { + reject(new Error('GitHub response did not include a tag_name')); + return; + } + resolve(String(tag).replace(/^v/i, '')); + } catch (err) { + reject(err); + } + }); + res.on('error', reject); +} + +/** + * Perform the check. Always resolves (never throws) so callers can fire it at + * startup without guarding. Network / parse failures are reported via + * `checked: false` + `reason`. + */ +async function checkObsidianVersion(opts = {}) { + const installed = readInstalledVersion(); + const pinned = pinnedVersion(); + const timeout = Number(process.env.OBSIDIAN_CHECK_TIMEOUT) || opts.timeout || DEFAULT_TIMEOUT; + + if (isCheckDisabled()) { + return { installed, latest: null, pinned, updateAvailable: false, checked: false, reason: 'disabled' }; + } + + // Pinned mode: compare installed against the pin, no network needed. + if (pinned) { + const updateAvailable = installed ? compareVersions(pinned, installed) > 0 : true; + return { installed, latest: pinned, pinned, updateAvailable, checked: true, reason: 'pinned' }; + } + + let latest = null; + try { + latest = await fetchLatestTag(timeout); + } catch (err) { + return { + installed, + latest: null, + pinned, + updateAvailable: false, + checked: false, + reason: err.message || String(err), + }; + } + + const updateAvailable = installed ? compareVersions(latest, installed) > 0 : true; + return { installed, latest, pinned, updateAvailable, checked: true, reason: 'ok' }; +} + +function formatNotice(result) { + if (!result.checked) { + if (result.reason === 'disabled') return '[update-check] Obsidian update check disabled (OBSIDIAN_UPDATE_CHECK).'; + return `[update-check] Could not check for a newer Obsidian release: ${result.reason}`; + } + if (result.updateAvailable) { + const from = result.installed || 'unknown'; + return [ + '==========================================', + ` Obsidian update available: ${from} -> ${result.latest}`, + ' Apply it with:', + ' node scripts/update-obsidian.js', + ' node scripts/update-obsidian-mobile.js # mobile renderer', + '==========================================', + ].join('\n'); + } + return `[update-check] Obsidian renderer is up to date (${result.installed || result.latest}).`; +} + +async function main() { + const asJson = process.argv.includes('--json'); + const result = await checkObsidianVersion(); + if (asJson) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(formatNotice(result)); + } + // Exit 0 when up to date / not checkable, 10 when an update is available, so + // CI / cron can branch on it without parsing stdout. + process.exitCode = result.updateAvailable ? 10 : 0; +} + +if (require.main === module) { + main().catch((err) => { + console.error(err.stack || err.message || String(err)); + process.exitCode = 1; + }); +} + +module.exports = { checkObsidianVersion, compareVersions, formatNotice }; diff --git a/src/server/index.js b/src/server/index.js index 1e8ff75..fec33ba 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -200,6 +200,26 @@ function startServer(appConfig = config) { warmUpBootstrapCache(app.locals.vaultRegistry, appConfig.vaultPath) .catch((err) => console.warn('[bootstrap] warm-up error:', err.message)); }); + + // Notify-only Obsidian update check. Runs in the background so it never + // blocks boot, and only logs -- applying an update stays a manual step + // (node scripts/update-obsidian.js). Disable with OBSIDIAN_UPDATE_CHECK=false. + setImmediate(() => { + let checkObsidianVersion; + let formatNotice; + try { + ({ checkObsidianVersion, formatNotice } = require('../../scripts/check-obsidian-version')); + } catch (_) { + return; // scripts/ not present (e.g. trimmed deployment) -- skip silently. + } + checkObsidianVersion() + .then((result) => { + // Log on every successful check (update available or up to date); + // stay silent when not checkable (offline/disabled) to avoid noise. + if (result.checked) console.log(formatNotice(result)); + }) + .catch((err) => console.warn('[update-check] error:', err.message)); + }); }); return server; From 28b04b9f82f392bcbf04cc2750013cb32e53700e Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 17 Jun 2026 15:48:38 +0000 Subject: [PATCH 46/56] docs: document Obsidian update-check feature and env vars in README --- README.md | 48 +++++++++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 0d4e655..5e942d3 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,32 @@ node scripts/update-obsidian.js --no-cache The updater uses the official `obsidian-.asar.gz` release asset, verifies the SHA-256 digest when GitHub provides one, extracts it locally, validates required renderer files, then replaces `obsidian/`. +### Update notifications (auto-check, notify-only) + +The server checks for a newer Obsidian release at startup and logs a notice when one is available. It never downloads or applies the update on its own; applying stays the deliberate `node scripts/update-obsidian.js` step above. This keeps you in control of when the renderer changes while still telling you when you are behind. + +You can also run the check on demand: + +```bash +# human-readable status (exit 10 if an update is available, 0 otherwise) +node scripts/check-obsidian-version.js + +# machine-readable output for scripts/cron +node scripts/check-obsidian-version.js --json +``` + +The installed version is read from `vendor/obsidian/package.json` (falling back to the server's configured `APP_VERSION`), and compared against the latest tag on `obsidianmd/obsidian-releases`. + +Relevant environment variables: + +| Variable | Default | Effect | +|----------|---------|--------| +| `OBSIDIAN_UPDATE_CHECK` | enabled | Set to `false`/`0`/`off` to disable the startup network check entirely. | +| `OBSIDIAN_VERSION` | _(unset)_ | Pin to a version (e.g. `1.12.7`). The check then compares against the pin instead of GitHub's latest, and the check needs no network. | +| `OBSIDIAN_CHECK_TIMEOUT` | `5000` | Timeout (ms) for the GitHub request. | + +The check is non-blocking and offline-safe: if GitHub is unreachable it stays silent rather than failing boot. + ### Mobile bundle (`vendor/obsidian-mobile/`) The project ships **two runtimes** — a desktop one at `/` and a mobile one at `/mobile`. The mobile runtime needs the Obsidian Android APK bundle, extracted into `vendor/obsidian-mobile/`. Like `vendor/obsidian/`, this directory is gitignored and downloaded on demand: @@ -246,24 +272,4 @@ The Node.js server (`src/server/`) can be deployed to any Linux box. A typical s 1. Clone the repo and run `node scripts/update-obsidian.js` to get Obsidian's renderer files 2. `cd src/server && npm install && npm start` 3. Put it behind a reverse proxy (nginx, Caddy, Cloudflare Tunnel) with HTTPS -4. Do not expose the server directly to the internet without auth — there is no application-level authentication - -## Notes - -- Obsidian's extracted files are treated as third-party artifacts. Do not edit files under `vendor/obsidian/` or `vendor/obsidian-mobile/`; update wrappers/shims instead. -- The default vault is `user-data/demo-vault/`. -- The current starter folder picker is prompt-based: enter an absolute server path. -- Do not bind the server to a public IP without a tunnel or auth layer in front. -- Current architecture and roadmap are in `PLAN.md`. - -## Disclaimer - -This is an **educational proof-of-concept** exploring how Electron-based apps can run in a standard browser. It is not affiliated with, endorsed by, or associated with [Obsidian](https://obsidian.md) or Dynalist Inc. - -This repository does **not** include Obsidian's source code. The `vendor/obsidian/` and `vendor/obsidian-mobile/` directories are gitignored — users must download Obsidian's renderer themselves using the provided setup scripts. Obsidian's code remains the property of Dynalist Inc. under their [Terms of Service](https://obsidian.md/terms). - -If the Obsidian team has any concerns about this project, please [open an issue](https://github.com/MusiCode1/obsidian-web/issues) and we will address them promptly. - -## Credits - -Built by [MusiCode1](https://github.com/MusiCode1) and [Claude Code](https://claude.ai/code). +4. Do not \ No newline at end of file From cd0c8336c34600699e34a6732f1fb6ba1f92c921 Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 24 Jun 2026 08:17:04 -0700 Subject: [PATCH 47/56] fix: evict deleted folders from client bootstrap cache --- src/client/shims/original-fs.js | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/client/shims/original-fs.js b/src/client/shims/original-fs.js index 96cde3b..554c404 100644 --- a/src/client/shims/original-fs.js +++ b/src/client/shims/original-fs.js @@ -58,10 +58,23 @@ function invalidateBootstrap(p) { const cache = global.__owBootstrapCache; - if (cache && cache.fs) delete cache.fs[toRelative(p)]; - // Also invalidate parent dir listing so readdir re-fetches. + const rel = toRelative(p); + const prefix = rel + '/'; + if (cache && cache.fs) { + // Evict the entry itself plus any descendants (covers folder deletes, + // where children would otherwise linger in cache and reappear). + delete cache.fs[rel]; + for (const key of Object.keys(cache.fs)) { + if (key.startsWith(prefix)) delete cache.fs[key]; + } + } if (cache && cache.dirs) { - const rel = toRelative(p); + // Evict the path's own listing (if it was a directory) and its + // descendants' listings, plus the parent listing so readdir re-fetches. + delete cache.dirs[rel]; + for (const key of Object.keys(cache.dirs)) { + if (key.startsWith(prefix)) delete cache.dirs[key]; + } const parent = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''; delete cache.dirs[parent]; } @@ -307,6 +320,7 @@ function rmdirAsync(p, opts, cb) { if (typeof opts === 'function') { cb = opts; opts = {}; } + invalidateBootstrap(p); const recursive = opts && opts.recursive ? '1' : '0'; fetch('/api/fs/rmdir?' + vaultQuery() + 'path=' + encodePath(p) + '&recursive=' + recursive, { method: 'DELETE' }) .then(async (r) => { From d4dc050229b988126b75de0d237978017f669725 Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 24 Jun 2026 09:43:40 -0700 Subject: [PATCH 48/56] fix: prevent deleted files/folders from reappearing in file explorer --- src/client/shims/original-fs.js | 53 +++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/src/client/shims/original-fs.js b/src/client/shims/original-fs.js index 554c404..275f2a8 100644 --- a/src/client/shims/original-fs.js +++ b/src/client/shims/original-fs.js @@ -80,6 +80,41 @@ } } + // Like invalidateBootstrap, but for DELETIONS specifically. Instead of + // dropping the parent's cached listing (which forces a re-fetch and leaves a + // window where a stale listing can be re-read), it SPLICES the removed entry + // out of the parent listing in place. This keeps the listing valid and — most + // importantly — prevents populateStatFromDir() from resurrecting the deleted + // path into fs cache from a stale parent entry, which caused deleted folders + // to reappear in the file explorer until a full reload. + function removeFromBootstrap(p) { + const cache = global.__owBootstrapCache; + if (!cache) return; + const rel = toRelative(p); + const prefix = rel + '/'; + if (cache.fs) { + delete cache.fs[rel]; + for (const key of Object.keys(cache.fs)) { + if (key.startsWith(prefix)) delete cache.fs[key]; + } + } + if (cache.dirs) { + // Drop the deleted path's own listing and any descendant listings. + delete cache.dirs[rel]; + for (const key of Object.keys(cache.dirs)) { + if (key.startsWith(prefix)) delete cache.dirs[key]; + } + // Splice the entry out of the parent listing in place (keeps it valid). + const parent = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''; + const name = rel.includes('/') ? rel.slice(rel.lastIndexOf('/') + 1) : rel; + const listing = cache.dirs[parent]; + if (Array.isArray(listing)) { + const idx = listing.findIndex((e) => e.name === name); + if (idx !== -1) listing.splice(idx, 1); + } + } + } + function makeStatsFromCache(entry) { const isDir = !!entry.isDirectory; return makeStats({ @@ -288,7 +323,7 @@ } function unlinkAsync(p, cb) { - invalidateBootstrap(p); + removeFromBootstrap(p); fetch('/api/fs/unlink?' + vaultQuery() + 'path=' + encodePath(p), { method: 'DELETE' }) .then(async (r) => { if (!r.ok) { @@ -301,7 +336,9 @@ } function renameAsync(oldPath, newPath, cb) { - invalidateBootstrap(oldPath); + // Source disappears (splice it out of its parent listing); destination is a + // new entry, so invalidate its parent listing to force a re-fetch. + removeFromBootstrap(oldPath); invalidateBootstrap(newPath); fetch('/api/fs/rename', { method: 'POST', @@ -320,7 +357,7 @@ function rmdirAsync(p, opts, cb) { if (typeof opts === 'function') { cb = opts; opts = {}; } - invalidateBootstrap(p); + removeFromBootstrap(p); const recursive = opts && opts.recursive ? '1' : '0'; fetch('/api/fs/rmdir?' + vaultQuery() + 'path=' + encodePath(p) + '&recursive=' + recursive, { method: 'DELETE' }) .then(async (r) => { @@ -401,7 +438,7 @@ } function unlinkSync(p) { - invalidateBootstrap(p); + removeFromBootstrap(p); global.__owSyncRequest('DELETE', '/api/fs/unlink?' + vaultQuery() + 'path=' + encodePath(p)); } @@ -491,13 +528,17 @@ // Invalidate bootstrap cache for the changed path. // 'change' = content update: only evict the file from fs cache. // Do NOT evict the parent dir listing — the file still exists there. - // Structural events (add/unlink/addDir/unlinkDir/rename/reset): evict - // both the file and its parent dir listing so readdir re-fetches. + // Deletions (unlink/unlinkDir): splice the entry out of its parent + // listing so a stale listing can't resurrect it via populateStatFromDir. + // Other structural events (add/addDir/rename/reset): drop the parent + // listing so readdir re-fetches and picks up the new entry. if (msg.path) { const absPath = vaultBase + '/' + msg.path; if (msg.type === 'change') { const cache = global.__owBootstrapCache; if (cache && cache.fs) delete cache.fs[toRelative(absPath)]; + } else if (msg.type === 'unlink' || msg.type === 'unlinkDir') { + removeFromBootstrap(absPath); } else { invalidateBootstrap(absPath); } From a240fc263d10e65b653a5704b83dbe7a3e941e53 Mon Sep 17 00:00:00 2001 From: Sean Date: Mon, 29 Jun 2026 16:49:05 +0000 Subject: [PATCH 49/56] fix: polyfill navigator.clipboard for plain-HTTP contexts Obsidian's code-block copy button calls navigator.clipboard.writeText, which browsers gate to secure contexts (HTTPS/localhost). On plain HTTP over LAN/NAS the API is undefined, so the copy button silently no-ops. Add an execCommand('copy') textarea fallback in boot.js alongside the existing crypto.subtle / randomUUID secure-context polyfills. --- src/client/boot.js | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/client/boot.js b/src/client/boot.js index 8f90d81..4fd40ac 100644 --- a/src/client/boot.js +++ b/src/client/boot.js @@ -29,6 +29,55 @@ if (typeof crypto !== 'undefined' && !crypto.randomUUID) { }; } +// Polyfill navigator.clipboard for non-secure contexts (plain HTTP on LAN). +// Browsers expose the async Clipboard API only in secure contexts (HTTPS or +// localhost); on plain HTTP `navigator.clipboard` is undefined. Obsidian's +// code-block "copy" button and the electron clipboard shim both call +// navigator.clipboard.writeText, so without this they silently no-op. The +// fallback uses the legacy document.execCommand('copy') path via a temporary +// off-screen textarea, which works on plain HTTP. +(function () { + function _legacyWriteText(text) { + return new Promise(function (resolve, reject) { + try { + var ta = document.createElement('textarea'); + ta.value = text == null ? '' : String(text); + ta.setAttribute('readonly', ''); + ta.style.position = 'fixed'; + ta.style.top = '-9999px'; + ta.style.left = '-9999px'; + ta.style.opacity = '0'; + var active = document.activeElement; + document.body.appendChild(ta); + ta.select(); + ta.setSelectionRange(0, ta.value.length); + var ok = document.execCommand('copy'); + document.body.removeChild(ta); + if (active && typeof active.focus === 'function') active.focus(); + if (ok) resolve(); + else reject(new Error('[polyfill] navigator.clipboard.writeText: execCommand("copy") failed')); + } catch (e) { + reject(e); + } + }); + } + + // execCommand('paste') is blocked in browsers, so a reliable read is not + // possible on plain HTTP. Resolve empty so callers awaiting readText() don't throw. + function _legacyReadText() { return Promise.resolve(''); } + + if (typeof navigator !== 'undefined' && !navigator.clipboard) { + try { + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: _legacyWriteText, readText: _legacyReadText }, + configurable: true, + }); + } catch (_) { /* non-configurable on some engines; ignore */ } + } else if (typeof navigator !== 'undefined' && navigator.clipboard && !navigator.clipboard.writeText) { + try { navigator.clipboard.writeText = _legacyWriteText; } catch (_) {} + } +})(); + // Polyfill crypto.subtle for non-secure contexts (plain HTTP on LAN). // Browsers only expose SubtleCrypto on HTTPS/localhost. Plugins like ion-sync // call digest / importKey / deriveKey / encrypt / decrypt — without this they From 6fc3431d8575aaa68b2c22dabab5af67a621faca Mon Sep 17 00:00:00 2001 From: Sean Date: Mon, 29 Jun 2026 09:55:30 -0700 Subject: [PATCH 50/56] docs: expand README with deployment notes, disclaimer, and credits --- README.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5e942d3..cd1db02 100644 --- a/README.md +++ b/README.md @@ -272,4 +272,24 @@ The Node.js server (`src/server/`) can be deployed to any Linux box. A typical s 1. Clone the repo and run `node scripts/update-obsidian.js` to get Obsidian's renderer files 2. `cd src/server && npm install && npm start` 3. Put it behind a reverse proxy (nginx, Caddy, Cloudflare Tunnel) with HTTPS -4. Do not \ No newline at end of file +4. Do not expose the server directly to the internet without auth — there is no application-level authentication + +## Notes + +- Obsidian's extracted files are treated as third-party artifacts. Do not edit files under `vendor/obsidian/` or `vendor/obsidian-mobile/`; update wrappers/shims instead. +- The default vault is `user-data/demo-vault/`. +- The current starter folder picker is prompt-based: enter an absolute server path. +- Do not bind the server to a public IP without a tunnel or auth layer in front. +- Current architecture and roadmap are in `PLAN.md`. + +## Disclaimer + +This is an **educational proof-of-concept** exploring how Electron-based apps can run in a standard browser. It is not affiliated with, endorsed by, or associated with [Obsidian](https://obsidian.md) or Dynalist Inc. + +This repository does **not** include Obsidian's source code. The `vendor/obsidian/` and `vendor/obsidian-mobile/` directories are gitignored — users must download Obsidian's renderer themselves using the provided setup scripts. Obsidian's code remains the property of Dynalist Inc. under their [Terms of Service](https://obsidian.md/terms). + +If the Obsidian team has any concerns about this project, please [open an issue](https://github.com/MusiCode1/obsidian-web/issues) and we will address them promptly. + +## Credits + +Built by [MusiCode1](https://github.com/MusiCode1) and [Claude Code](https://claude.ai/code). From 8b34022d33518841b6cb9615e2e4c0d54aaa5a9c Mon Sep 17 00:00:00 2001 From: Sean Date: Mon, 29 Jun 2026 09:55:30 -0700 Subject: [PATCH 51/56] feat: implement App.requestUrl direct-fetch wrapper for LiveSync --- PLAN.md | 2 +- src/client-mobile/shims/capacitor-shim.js | 47 ++++++++++++++++++++--- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/PLAN.md b/PLAN.md index e2366d5..69d1729 100644 --- a/PLAN.md +++ b/PLAN.md @@ -284,7 +284,7 @@ read should be in `server/config.js` for consistency with other settings. ### Direct-fetch implementation checklist -- [ ] `capacitor-shim.js`: replace `App.requestUrl: () => Promise.resolve({})` with a real fetch wrapper. +- [x] `capacitor-shim.js`: replace `App.requestUrl: () => Promise.resolve({})` with a real fetch wrapper. - [ ] Self-test with `app.requestUrl({ url: 'https://example.com', method: 'GET' })` from DevTools. - [ ] Install LiveSync into `test-vault/.obsidian/plugins/obsidian-livesync/` (manual; will graduate to system plugin once stable). - [ ] Verify initial replication with a small CouchDB on Fly.io or local. diff --git a/src/client-mobile/shims/capacitor-shim.js b/src/client-mobile/shims/capacitor-shim.js index 6ea3424..4f330e9 100644 --- a/src/client-mobile/shims/capacitor-shim.js +++ b/src/client-mobile/shims/capacitor-shim.js @@ -35,11 +35,11 @@ * Noop stubs (return success, do nothing — irrelevant on web): * SplashScreen, StatusBar, Keyboard, KeepAwake, Haptics, RateApp * - * TODO / known limitations: - * App.requestUrl — currently returns {}. Needs a real fetch() impl - * for LiveSync support (depends on target CORS). - * See PLAN.md → "Updated approach (2026-05-11): direct - * fetch + CORS". + * Implemented via direct fetch (see PLAN.md §2026-05-11): + * App.requestUrl — real fetch() wrapper returning + * { status, headers, arrayBuffer, text, json }. Enables + * LiveSync's outbound HTTP. Requires the target (e.g. + * CouchDB) to send permissive CORS headers for this origin. * * Vault path: read from localStorage / URL params (same mechanism as desktop). * All FS calls get ?vault= query param so the server routes to the right vault. @@ -448,7 +448,42 @@ getFonts: () => Promise.resolve({ fonts: [] }), takeScreenshot: () => Promise.resolve({ base64String: '' }), isInstalledFromStore: () => Promise.resolve({ isFromStore: false }), - requestUrl: () => Promise.resolve({}), + // requestUrl: real fetch() wrapper (direct fetch + CORS approach — see + // PLAN.md §2026-05-11). Used by community plugins (LiveSync) for outbound + // HTTP that bypasses Obsidian's own request layer. Returns the shape + // Obsidian's requestUrl exposes: { status, headers, arrayBuffer, text, json }. + // Mirrors Obsidian's contract: throws on HTTP >= 400 unless { throw: false }. + // Subject to browser CORS — CouchDB must send permissive CORS headers. + requestUrl: (options) => { + const opts = options || {}; + const url = opts.url; + if (!url) return Promise.reject(new Error('[capacitor-shim] App.requestUrl: missing url')); + const method = (opts.method || 'GET').toUpperCase(); + const headers = Object.assign({}, opts.headers || {}); + if (opts.contentType && !('Content-Type' in headers) && !('content-type' in headers)) { + headers['Content-Type'] = opts.contentType; + } + const init = { method, headers }; + if (opts.body != null && method !== 'GET' && method !== 'HEAD') { + init.body = opts.body instanceof ArrayBuffer ? new Uint8Array(opts.body) : opts.body; + } + return fetch(url, init).then((res) => + res.arrayBuffer().then((buf) => { + const respHeaders = {}; + res.headers.forEach((v, k) => { respHeaders[k] = v; }); + let text = ''; + try { text = new TextDecoder('utf-8').decode(buf); } catch (_) {} + let json; + try { json = JSON.parse(text); } catch (_) { /* not JSON */ } + if (opts.throw !== false && res.status >= 400) { + const e = new Error('Request failed, status ' + res.status); + e.status = res.status; e.headers = respHeaders; + throw e; + } + return { status: res.status, headers: respHeaders, arrayBuffer: buf, text, json }; + }) + ); + }, setBackgroundColor: noop, }; From fdea0e8c437172f4611606368cd8e50fd98a4a71 Mon Sep 17 00:00:00 2001 From: Sean Date: Mon, 29 Jun 2026 11:09:38 -0700 Subject: [PATCH 52/56] fix: break clipboard writeText recursion between Electron shim and app.js --- src/client/boot.js | 53 ++++++++++++++++++++++++++---------- src/client/shims/electron.js | 11 ++++++-- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/client/boot.js b/src/client/boot.js index 4fd40ac..8cb59ee 100644 --- a/src/client/boot.js +++ b/src/client/boot.js @@ -29,13 +29,21 @@ if (typeof crypto !== 'undefined' && !crypto.randomUUID) { }; } -// Polyfill navigator.clipboard for non-secure contexts (plain HTTP on LAN). -// Browsers expose the async Clipboard API only in secure contexts (HTTPS or -// localhost); on plain HTTP `navigator.clipboard` is undefined. Obsidian's -// code-block "copy" button and the electron clipboard shim both call -// navigator.clipboard.writeText, so without this they silently no-op. The -// fallback uses the legacy document.execCommand('copy') path via a temporary -// off-screen textarea, which works on plain HTTP. +// Clipboard shim for non-secure contexts + Electron-shim recursion guard. +// +// Two problems show up on plain HTTP (LAN/NAS): +// 1. navigator.clipboard is undefined outside secure contexts. +// 2. Obsidian's app.js patches navigator.clipboard.writeText to delegate to +// the Electron clipboard (electron.js), while our Electron clipboard shim +// historically delegated back to navigator.clipboard.writeText — an +// infinite recursion ('Maximum call stack size exceeded') the moment the +// code-block copy button is pressed. +// +// Fix: capture a non-recursive clipboard implementation HERE, before app.js +// runs and patches navigator.clipboard. On HTTPS we bind the genuine native +// methods; on plain HTTP we fall back to document.execCommand('copy'). The +// Electron shim calls these globals (window.__owClipboard*) instead of +// navigator.clipboard, so the delegation cycle can never form. (function () { function _legacyWriteText(text) { return new Promise(function (resolve, reject) { @@ -55,7 +63,7 @@ if (typeof crypto !== 'undefined' && !crypto.randomUUID) { document.body.removeChild(ta); if (active && typeof active.focus === 'function') active.focus(); if (ok) resolve(); - else reject(new Error('[polyfill] navigator.clipboard.writeText: execCommand("copy") failed')); + else reject(new Error('[polyfill] clipboard writeText: execCommand("copy") failed')); } catch (e) { reject(e); } @@ -63,18 +71,35 @@ if (typeof crypto !== 'undefined' && !crypto.randomUUID) { } // execCommand('paste') is blocked in browsers, so a reliable read is not - // possible on plain HTTP. Resolve empty so callers awaiting readText() don't throw. + // possible on plain HTTP. Resolve empty so awaiting callers don't throw. function _legacyReadText() { return Promise.resolve(''); } - if (typeof navigator !== 'undefined' && !navigator.clipboard) { + // Capture the genuine native methods now, before app.js can replace them. + // Binding to navigator.clipboard guarantees these never route through a + // later monkey-patch (the source of the recursion). + var _nav = typeof navigator !== 'undefined' ? navigator : null; + var _hasNative = _nav && _nav.clipboard; + var _nativeWrite = _hasNative && typeof _nav.clipboard.writeText === 'function' + ? _nav.clipboard.writeText.bind(_nav.clipboard) : null; + var _nativeRead = _hasNative && typeof _nav.clipboard.readText === 'function' + ? _nav.clipboard.readText.bind(_nav.clipboard) : null; + + // Stable, non-recursive entry points used by electron.js's clipboard shim. + window.__owClipboardWriteText = function (text) { + return _nativeWrite ? _nativeWrite(text) : _legacyWriteText(text); + }; + window.__owClipboardReadText = function () { + return _nativeRead ? _nativeRead() : _legacyReadText(); + }; + + // Install navigator.clipboard for direct callers (plugins) when it's absent. + if (_nav && !_nav.clipboard) { try { - Object.defineProperty(navigator, 'clipboard', { - value: { writeText: _legacyWriteText, readText: _legacyReadText }, + Object.defineProperty(_nav, 'clipboard', { + value: { writeText: window.__owClipboardWriteText, readText: window.__owClipboardReadText }, configurable: true, }); } catch (_) { /* non-configurable on some engines; ignore */ } - } else if (typeof navigator !== 'undefined' && navigator.clipboard && !navigator.clipboard.writeText) { - try { navigator.clipboard.writeText = _legacyWriteText; } catch (_) {} } })(); diff --git a/src/client/shims/electron.js b/src/client/shims/electron.js index 7df85f0..43d9dfa 100644 --- a/src/client/shims/electron.js +++ b/src/client/shims/electron.js @@ -668,8 +668,15 @@ getCurrentWebContents: () => webContentsInstance, BrowserWindow: function () { return makeWindow(); }, clipboard: { - writeText: (text) => navigator.clipboard.writeText(text), - readText: () => navigator.clipboard.readText(), + // Use the non-recursive entry points installed by boot.js. Calling + // navigator.clipboard.writeText here would recurse: Obsidian patches + // navigator.clipboard.writeText to delegate back into this shim. + writeText: (text) => (window.__owClipboardWriteText + ? window.__owClipboardWriteText(text) + : Promise.resolve()), + readText: () => (window.__owClipboardReadText + ? window.__owClipboardReadText() + : Promise.resolve('')), // Returns the image that was most recently pasted (captured via the DOM // paste event above). Clears the cache so a subsequent text paste doesn't // accidentally return a stale image. From c954484f7dc7443f2b8085c67489211e1b114cab Mon Sep 17 00:00:00 2001 From: Sean Date: Mon, 13 Jul 2026 16:07:46 -0700 Subject: [PATCH 53/56] fix: shim Capacitor App plugin so getLaunchUrl-using plugins don't crash boot --- CHANGELOG.md | 12 +++++++++ CLAUDE.md | 24 ++++++++++++++++- src/client/boot.js | 65 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67176b1..bfac326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,3 +17,15 @@ based on [Keep a Changelog](https://keepachangelog.com/). - Environment variables `OBSIDIAN_UPDATE_CHECK` (disable the check), `OBSIDIAN_VERSION` (pin a version / compare without network), and `OBSIDIAN_CHECK_TIMEOUT` (GitHub request timeout). See README. + +### Fixed +- **Boot crash from plugins that use Capacitor's App plugin.** Community + plugins bundling `@capacitor/app` (e.g. **Homepage**) call + `Capacitor.Plugins.App.getLaunchUrl()` at startup. In the browser there is no + native `App` plugin, so the call threw + `Cannot read properties of undefined (reading 'getLaunchUrl')` and Obsidian's + loader showed "An error occurred while loading Obsidian" — the whole app + failed to boot. `src/client/boot.js` now installs a defensive Capacitor `App` + shim (via a `window.Capacitor` assignment interceptor) so such plugins + degrade gracefully instead of crashing boot. (2026-07-13) + diff --git a/CLAUDE.md b/CLAUDE.md index 8756699..c8c5b85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ the server. | 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/boot.js` | Runs first. Installs polyfills (`crypto.subtle`, `crypto.randomUUID`), a defensive Capacitor `App` shim (so plugins calling `App.getLaunchUrl()` don't crash boot), 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. | @@ -85,6 +85,28 @@ even when the wildcard `*/*` is specified, so the body is left unparsed and `Content-Type: application/octet-stream` on PUT requests so body-parser always parses. +## Capacitor App-plugin shim (`src/client/boot.js`) + +Some community plugins bundle `@capacitor/app` and call +`Capacitor.Plugins.App.getLaunchUrl()` at startup to detect an `obsidian://` +deeplink (e.g. the **Homepage** plugin's `hasUrlParams()` → +`runOpeningBehavior()`). In the browser, `@capacitor/core` initialises with +platform `"web"` and no native `App` plugin, so `Capacitor.Plugins.App` is +`undefined`, the call throws +`TypeError: Cannot read properties of undefined (reading 'getLaunchUrl')`, and +Obsidian's loader turns it into the "An error occurred while loading Obsidian" +crash screen — one plugin takes down the whole boot. + +`boot.js` installs a minimal defensive `App` stub (`getLaunchUrl` resolves to +`{ url: undefined }`, plus noop `getInfo`/`getState`/`addListener`/… ). Because +`window.Capacitor` doesn't exist at boot (the plugin's own `@capacitor/core` +bundle creates it later via `win.Capacitor = createCapacitor(win)`), the shim +intercepts assignment to `window.Capacitor` with a setter that (re)injects +`Plugins.App` the moment Capacitor is built. `@capacitor/core` preserves an +existing `cap.Plugins`, and nothing registers a real `App`, so the stub is what +plugins read. The mobile client's fuller equivalent lives in +`src/client-mobile/shims/capacitor-shim.js`. + ## ENOTDIR self-repair (`src/server/api/fs.js` — `mkdirRepair`) Sync plugins can leave a regular file at a path that should be a directory diff --git a/src/client/boot.js b/src/client/boot.js index 8cb59ee..dddf315 100644 --- a/src/client/boot.js +++ b/src/client/boot.js @@ -29,6 +29,71 @@ if (typeof crypto !== 'undefined' && !crypto.randomUUID) { }; } +// Capacitor App-plugin shim (desktop web client). +// +// Some community plugins bundle @capacitor/app and, on startup, call +// `Capacitor.Plugins.App.getLaunchUrl()` to detect whether Obsidian was opened +// via an `obsidian://` deeplink (e.g. the Homepage plugin's +// `hasUrlParams()` -> `runOpeningBehavior()`). In the browser, @capacitor/core +// initialises with platform "web" and NO native `App` plugin registered, so +// `Capacitor.Plugins.App` is `undefined`. The call then throws +// TypeError: Cannot read properties of undefined (reading 'getLaunchUrl') +// which Obsidian's loader catches and turns into the "An error occurred while +// loading Obsidian" crash screen -- the whole app fails to boot over one plugin. +// +// The mobile client already installs a full Capacitor shim +// (src/client-mobile/shims/capacitor-shim.js); the desktop client had none. +// Here we install a minimal, defensive `App` stub so any such plugin degrades +// gracefully instead of crashing the app. +// +// `window.Capacitor` does not exist yet at boot time -- it is created later by +// the plugin's own @capacitor/core bundle via `win.Capacitor = createCapacitor(win)`. +// We therefore intercept assignment to `window.Capacitor` with a setter that +// (re)injects `Plugins.App` the moment Capacitor is (re)built, and also patch +// any Capacitor that somehow already exists. @capacitor/core preserves an +// existing `cap.Plugins`, and nothing registers a real `App`, so our stub +// survives and is what plugins read. +(function () { + var appStub = { + getLaunchUrl: function () { return Promise.resolve({ url: undefined }); }, + getInfo: function () { return Promise.resolve({ name: 'Obsidian', id: 'md.obsidian', build: '0', version: '0.0.0' }); }, + getState: function () { return Promise.resolve({ isActive: true }); }, + addListener: function () { return Promise.resolve({ remove: function () {} }); }, + removeAllListeners: function () { return Promise.resolve(); }, + exitApp: function () { return Promise.resolve(); }, + minimizeApp: function () { return Promise.resolve(); }, + }; + + function ensureApp(cap) { + if (!cap) return cap; + try { + if (!cap.Plugins) cap.Plugins = {}; + if (!cap.Plugins.App) { + cap.Plugins.App = appStub; + } else if (typeof cap.Plugins.App.getLaunchUrl !== 'function') { + cap.Plugins.App.getLaunchUrl = appStub.getLaunchUrl; + } + } catch (e) { + console.warn('[obsidian-web] Capacitor App shim: could not patch Plugins.App:', e && e.message); + } + return cap; + } + + try { + var current = ensureApp(window.Capacitor); + Object.defineProperty(window, 'Capacitor', { + configurable: true, + get: function () { return current; }, + set: function (v) { current = v ? ensureApp(v) : v; }, + }); + } catch (e) { + // If Capacitor is a non-configurable own property (shouldn't happen at + // boot), fall back to patching whatever is there right now. + ensureApp(window.Capacitor); + console.warn('[obsidian-web] Capacitor App shim: defineProperty failed, patched in place:', e && e.message); + } +})(); + // Clipboard shim for non-secure contexts + Electron-shim recursion guard. // // Two problems show up on plain HTTP (LAN/NAS): From 0eb6f0b3125ef8e42fb3053920f39acafc186ca7 Mon Sep 17 00:00:00 2001 From: Sean Date: Tue, 14 Jul 2026 10:46:44 -0700 Subject: [PATCH 54/56] fix: Capacitor App shim returns a valid launch URL so Homepage's new URL() doesn't crash boot --- src/client/boot.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/client/boot.js b/src/client/boot.js index dddf315..75fbaed 100644 --- a/src/client/boot.js +++ b/src/client/boot.js @@ -55,7 +55,12 @@ if (typeof crypto !== 'undefined' && !crypto.randomUUID) { // survives and is what plugins read. (function () { var appStub = { - getLaunchUrl: function () { return Promise.resolve({ url: undefined }); }, + // Return a valid, param-free URL (not { url: undefined }) so plugins that + // do `new URL((await App.getLaunchUrl()).url)` without guarding — e.g. the + // Homepage plugin's hasUrlParams() — don't throw "Invalid URL", while its + // launch-param check (openMode/mode/view) still finds nothing and opens the + // configured homepage normally. + getLaunchUrl: function () { return Promise.resolve({ url: (typeof location !== 'undefined' ? (location.origin + location.pathname) : 'app://obsidian.md/') }); }, getInfo: function () { return Promise.resolve({ name: 'Obsidian', id: 'md.obsidian', build: '0', version: '0.0.0' }); }, getState: function () { return Promise.resolve({ isActive: true }); }, addListener: function () { return Promise.resolve({ remove: function () {} }); }, From dc66f6a2961f6c2e4f963682badccd5f0a25ba31 Mon Sep 17 00:00:00 2001 From: Sean Date: Fri, 17 Jul 2026 16:12:55 -0700 Subject: [PATCH 55/56] feat: harden auth middleware and add update-check; add auth tests --- CLAUDE.md | 8 +- README.md | 1 + docker-compose.yml | 3 + src/deployments/cloudflare/api/bootstrap.js | 2 +- src/deployments/cloudflare/api/electron.js | 2 +- src/server/api/bootstrap.js | 4 +- src/server/api/electron.js | 5 +- src/server/api/watch.js | 12 +- src/server/index.js | 6 +- src/server/middleware/auth.js | 56 ++++++-- src/server/test/auth.test.js | 143 ++++++++++++++++++++ 11 files changed, 226 insertions(+), 16 deletions(-) create mode 100644 src/server/test/auth.test.js diff --git a/CLAUDE.md b/CLAUDE.md index c8c5b85..a93309c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,6 +144,12 @@ On plain HTTP all of the following must be polyfilled / shimmed: - 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. + (7-day expiry). Failed TOTP attempts (login and `/__totp-setup` token + guesses) are rate-limited to 5 per IP per 15 min. +- Set `TRUST_PROXY=true` only behind a reverse proxy/tunnel so rate limiting + keys on the `X-Forwarded-For` client IP; on a directly exposed port the + header is client-forgeable and is ignored. +- The `/api/watch` WebSocket upgrade bypasses Express middleware; it runs the + same session check via `verifyClient` (see `attachWatchServer`). - `/api/vaults/open` only accepts paths under `VAULTS_ROOT` (default `user-data/`). Set `VAULTS_ROOT=*` to disable the restriction. diff --git a/README.md b/README.md index cd1db02..1a7accd 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,7 @@ Copy `.env.example` to `.env` and fill in your values: | `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 | +| `TRUST_PROXY` | `false` | Set to `true` only when the server sits behind a reverse proxy / tunnel (nginx, Caddy, cloudflared) so login rate limiting uses the `X-Forwarded-For` client IP. Leave `false` on a directly exposed port — the header is client-forgeable there. | ### Notes diff --git a/docker-compose.yml b/docker-compose.yml index 1c29fa7..0179515 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,9 @@ services: # 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:-}" + # Set to "true" only when behind a reverse proxy/tunnel (cloudflared, + # nginx) so login rate limiting reads the X-Forwarded-For client IP. + TRUST_PROXY: "${TRUST_PROXY:-false}" env_file: - path: .env required: false diff --git a/src/deployments/cloudflare/api/bootstrap.js b/src/deployments/cloudflare/api/bootstrap.js index 050765b..e5645d1 100644 --- a/src/deployments/cloudflare/api/bootstrap.js +++ b/src/deployments/cloudflare/api/bootstrap.js @@ -49,7 +49,7 @@ export function buildBootstrap(vault) { 'vault-list': { demo: { path: '/vault', ts: Date.now(), open: true } }, 'is-dev': false, 'version': APP_VERSION, - 'frame': 'hidden', + 'frame': 'native', 'resources': '', 'file-url': '', 'disable-update': true, diff --git a/src/deployments/cloudflare/api/electron.js b/src/deployments/cloudflare/api/electron.js index a893e84..d27def3 100644 --- a/src/deployments/cloudflare/api/electron.js +++ b/src/deployments/cloudflare/api/electron.js @@ -11,7 +11,7 @@ const SIMPLE_VALUES = { 'is-dev': false, 'is-quitting': false, 'resources': '', - 'frame': 'hidden', + 'frame': 'native', 'version': APP_VERSION, 'update': '', 'check-update': false, diff --git a/src/server/api/bootstrap.js b/src/server/api/bootstrap.js index 241cbec..6b2c0e7 100644 --- a/src/server/api/bootstrap.js +++ b/src/server/api/bootstrap.js @@ -248,7 +248,9 @@ async function _buildCacheEntry(vaultId, vaultRoot, vaultRegistry, full = false) 'vault-list': vaultRegistry.list(), 'is-dev': false, 'version': APP_VERSION, - 'frame': 'hidden', + // 'native': browser supplies window chrome; 'hidden' would reserve an + // empty frameless titlebar (see api/electron.js /frame). + 'frame': 'native', 'resources': '', 'file-url': '', 'disable-update': true, diff --git a/src/server/api/electron.js b/src/server/api/electron.js index 8a86eff..3286a32 100644 --- a/src/server/api/electron.js +++ b/src/server/api/electron.js @@ -91,8 +91,11 @@ function createElectronRouter(vaultRegistry, fallbackVaultRoot) { }); // ipcRenderer.sendSync('frame') + // 'native' — the browser provides the window chrome. 'hidden' would make + // Obsidian reserve a frameless-window titlebar (drag region + min/max/close + // buttons that can't exist in a browser), leaving a tall empty header strip. router.get('/frame', (req, res) => { - res.json({ value: 'hidden' }); + res.json({ value: 'native' }); }); // ipcRenderer.sendSync('documents-dir') / 'desktop-dir' diff --git a/src/server/api/watch.js b/src/server/api/watch.js index 01c445f..322f9dc 100644 --- a/src/server/api/watch.js +++ b/src/server/api/watch.js @@ -20,8 +20,16 @@ const path = require('path'); const { WebSocketServer } = require('ws'); const config = require('../config'); -function attachWatchServer(httpServer, vaultRegistry, fallbackVaultRoot) { - const wss = new WebSocketServer({ server: httpServer, path: '/api/watch' }); +function attachWatchServer(httpServer, vaultRegistry, fallbackVaultRoot, isAuthenticated) { + // WebSocket upgrades never pass through Express middleware, so when TOTP + // auth is enabled the session cookie must be verified here — otherwise an + // unauthenticated client could subscribe to file-change events (vault file + // and folder names). Rejected upgrades get a plain HTTP 401. + const wss = new WebSocketServer({ + server: httpServer, + path: '/api/watch', + verifyClient: (info) => !isAuthenticated || isAuthenticated(info.req), + }); // Shared watchers: vaultRoot → { watcher, clients: Set } const sharedWatchers = new Map(); diff --git a/src/server/index.js b/src/server/index.js index fec33ba..51f70b2 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -173,6 +173,9 @@ function createApp(appConfig = config) { app.use('/api/electron', createElectronRouter(vaultRegistry, appConfig.vaultPath)); app.locals.vaultRegistry = vaultRegistry; + // Session check for non-Express entry points (the /api/watch WebSocket + // upgrade bypasses Express middleware entirely). Null when auth is disabled. + app.locals.isAuthenticated = authMiddleware ? authMiddleware.isAuthenticated : null; return app; } @@ -183,7 +186,8 @@ function startServer(appConfig = config) { const app = createApp(appConfig); const server = http.createServer(app); - attachWatchServer(server, app.locals.vaultRegistry, appConfig.vaultPath); + attachWatchServer(server, app.locals.vaultRegistry, appConfig.vaultPath, + app.locals.isAuthenticated); server.listen(appConfig.port, appConfig.host, () => { console.log('=========================================='); diff --git a/src/server/middleware/auth.js b/src/server/middleware/auth.js index 395f8b7..74e4567 100644 --- a/src/server/middleware/auth.js +++ b/src/server/middleware/auth.js @@ -23,6 +23,31 @@ const TOTP_SECRET = process.env.TOTP_SECRET || ''; const COOKIE = 'obsidian-web-session'; const MAX_AGE = 7 * 24 * 60 * 60; // 7 days +// Only honor X-Forwarded-For when explicitly told the server sits behind a +// trusted proxy (cloudflared, nginx, …). On a directly exposed port the header +// is attacker-controlled: a client could rotate fake IPs to reset the +// per-IP login rate limit and brute-force the 6-digit TOTP code. +const TRUST_PROXY = process.env.TRUST_PROXY === 'true'; + +// Constant-time string comparison (hash first so unequal lengths don't +// short-circuit timingSafeEqual). +function safeEqual(a, b) { + const ha = crypto.createHash('sha256').update(String(a)).digest(); + const hb = crypto.createHash('sha256').update(String(b)).digest(); + return crypto.timingSafeEqual(ha, hb); +} + +// Restrict post-login redirect targets to local absolute paths. Anything else +// ("https://evil.example", "//evil.example", "/\\evil.example") falls back to +// "/" — an open redirect is a phishing aid on a login flow. +function safeRedirectPath(next) { + const dest = String(next || '/'); + if (dest.startsWith('/') && !dest.startsWith('//') && !dest.startsWith('/\\')) { + return dest; + } + return '/'; +} + // Allow 1 step (±30 s) of clock drift between phone and server authenticator.options = { window: 1 }; @@ -109,9 +134,12 @@ class RateLimiter { clientIp(req) { // Behind cloudflared/reverse proxies the socket address is the proxy; - // use the first forwarded hop when present. - const fwd = req.headers['x-forwarded-for']; - if (fwd) return String(fwd).split(',')[0].trim(); + // use the first forwarded hop — but only when TRUST_PROXY=true, since a + // direct client can forge the header to dodge rate limiting. + if (TRUST_PROXY) { + const fwd = req.headers['x-forwarded-for']; + if (fwd) return String(fwd).split(',')[0].trim(); + } return req.socket.remoteAddress || 'unknown'; } @@ -312,11 +340,18 @@ function createAuthMiddleware(appConfig = {}) { return sessions.has(parseCookies(req)[COOKIE]); } - return async function authMiddleware(req, res, next) { + async function authMiddleware(req, res, next) { - // ── Setup page ── requires ?token=TOTP_SECRET so only the owner can view it + // ── Setup page ── requires ?token=TOTP_SECRET so only the owner can view it. + // Shares the login rate limiter and uses a constant-time compare so the + // secret can't be probed by brute force or timing. if (req.path === '/__totp-setup') { - if ((req.query?.token || '') !== TOTP_SECRET) { + if (limiter.isBlocked(req)) { + return res.status(429) + .end('Too many failed attempts — try again in a few minutes.'); + } + if (!safeEqual(req.query?.token || '', TOTP_SECRET)) { + limiter.recordFailure(req); return res.status(403).end('Forbidden — add ?token=YOUR_TOTP_SECRET to the URL'); } const html = await buildSetupPage(TOTP_SECRET); @@ -331,7 +366,7 @@ function createAuthMiddleware(appConfig = {}) { .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 || '/'; + const dest = safeRedirectPath(req.query?.next); if (code.length === 6 && authenticator.verify({ token: code, secret: TOTP_SECRET })) { limiter.recordSuccess(req); const token = sessions.create(); @@ -364,7 +399,12 @@ function createAuthMiddleware(appConfig = {}) { return res.status(401).json({ error: 'Unauthorized — valid TOTP session required' }); } res.redirect(`/__login?next=${encodeURIComponent(req.originalUrl)}`); - }; + } + + // Exposed so non-Express entry points (the /api/watch WebSocket upgrade, + // which never passes through Express middleware) can run the same check. + authMiddleware.isAuthenticated = isAuthenticated; + return authMiddleware; } module.exports = { createAuthMiddleware }; diff --git a/src/server/test/auth.test.js b/src/server/test/auth.test.js new file mode 100644 index 0000000..bf9cb96 --- /dev/null +++ b/src/server/test/auth.test.js @@ -0,0 +1,143 @@ +/** + * TOTP auth middleware + WebSocket auth tests. + * + * TOTP_SECRET is read at module load of middleware/auth.js, so it must be + * set BEFORE requiring ../index. node --test runs each file in its own + * process, so this doesn't leak into the other test files. + */ + +const { authenticator } = require('otplib'); + +const SECRET = authenticator.generateSecret(); +process.env.TOTP_SECRET = SECRET; + +const assert = require('assert/strict'); +const fs = require('fs'); +const fsp = fs.promises; +const http = require('http'); +const os = require('os'); +const path = require('path'); +const test = require('node:test'); +const WebSocket = require('ws'); + +const { createApp } = require('../index'); +const attachWatchServer = require('../api/watch'); + +async function startTestServer(config) { + 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); + attachWatchServer(server, app.locals.vaultRegistry, config.vaultPath, + app.locals.isAuthenticated); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address(); + return { + baseUrl: `http://127.0.0.1:${port}`, + wsUrl: `ws://127.0.0.1:${port}`, + close: () => new Promise((resolve) => server.close(resolve)), + }; +} + +async function makeVaultServer(t) { + const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'obsidian-web-auth-')); + t.after(() => fsp.rm(tmp, { recursive: true, force: true })); + const vaultPath = path.join(tmp, 'vault'); + await fsp.mkdir(vaultPath); + const server = await startTestServer({ + clientPath: path.join(tmp, 'client'), + obsidianPath: path.join(tmp, 'obsidian'), + registryPath: path.join(tmp, 'vaults.json'), + vaultPath, + }); + t.after(server.close); + return server; +} + +async function login(server) { + const code = authenticator.generate(SECRET); + const res = await fetch(`${server.baseUrl}/__auth?code=${code}`, { redirect: 'manual' }); + assert.equal(res.status, 302); + const setCookie = res.headers.get('set-cookie'); + assert.ok(setCookie, 'login should set a session cookie'); + return setCookie.split(';')[0]; +} + +function wsConnect(url, headers) { + return new Promise((resolve) => { + const ws = new WebSocket(url, { headers }); + ws.on('open', () => resolve({ ok: true, ws })); + ws.on('unexpected-response', (req, res) => { + ws.terminate(); + resolve({ ok: false, status: res.statusCode }); + }); + ws.on('error', () => resolve({ ok: false, status: 0 })); + }); +} + +test('API requests without a session get 401', async (t) => { + const server = await makeVaultServer(t); + const res = await fetch(server.baseUrl + '/api/vaults/list'); + assert.equal(res.status, 401); +}); + +test('valid TOTP code logs in and grants API access', async (t) => { + const server = await makeVaultServer(t); + const cookie = await login(server); + const res = await fetch(server.baseUrl + '/api/vaults/list', { + headers: { cookie }, + }); + assert.equal(res.status, 200); +}); + +test('login redirect target is restricted to local paths', async (t) => { + const server = await makeVaultServer(t); + const code = authenticator.generate(SECRET); + for (const evil of ['https://evil.example', '//evil.example', '/\\evil.example']) { + const res = await fetch( + `${server.baseUrl}/__auth?code=${code}&next=${encodeURIComponent(evil)}`, + { redirect: 'manual' }); + assert.equal(res.status, 302); + assert.equal(res.headers.get('location'), '/', `next=${evil} must fall back to /`); + } +}); + +test('/api/watch WebSocket upgrade requires a session', async (t) => { + const server = await makeVaultServer(t); + + const denied = await wsConnect(server.wsUrl + '/api/watch'); + assert.equal(denied.ok, false, 'unauthenticated upgrade must be rejected'); + assert.equal(denied.status, 401); + + const cookie = await login(server); + const granted = await wsConnect(server.wsUrl + '/api/watch', { cookie }); + assert.equal(granted.ok, true, 'authenticated upgrade must succeed'); + granted.ws.terminate(); +}); + +test('__totp-setup rejects wrong tokens and rate-limits attempts', async (t) => { + const server = await makeVaultServer(t); + + const bad = await fetch(server.baseUrl + '/__totp-setup?token=wrong'); + assert.equal(bad.status, 403); + + // 4 more failures exhaust the 5-attempt window; the next request is 429 + // even with the correct token (limit applies before the compare). + for (let i = 0; i < 4; i++) { + await fetch(server.baseUrl + '/__totp-setup?token=wrong' + i); + } + const blocked = await fetch(`${server.baseUrl}/__totp-setup?token=${SECRET}`); + assert.equal(blocked.status, 429); +}); + +test('__totp-setup serves the QR page for the correct token', async (t) => { + const server = await makeVaultServer(t); + const res = await fetch(`${server.baseUrl}/__totp-setup?token=${SECRET}`); + assert.equal(res.status, 200); + const html = await res.text(); + assert.ok(html.includes(SECRET), 'setup page should show the manual-entry secret'); +}); From dd648edd643d281a096cf9d429439a605e2530f3 Mon Sep 17 00:00:00 2001 From: Sean Date: Sat, 18 Jul 2026 18:42:36 -0700 Subject: [PATCH 56/56] fix: keep EmulateMobile/DebugMode device-local in roaming localStorage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A phone's EmulateMobile=1 roamed through the server-backed localStorage to desktop browsers, putting them into mobile emulation with a fake 59px notch inset — the 'huge header'. Device-specific keys now always use native localStorage, and previously leaked ones are purged from the server store on next load. Also make the length property configurable so the localStorage Proxy's ownKeys trap doesn't break Object.keys(localStorage). Co-Authored-By: claude-flow --- src/client/shims/remote-localstorage.js | 35 ++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/client/shims/remote-localstorage.js b/src/client/shims/remote-localstorage.js index ad64b3d..192805c 100644 --- a/src/client/shims/remote-localstorage.js +++ b/src/client/shims/remote-localstorage.js @@ -22,6 +22,16 @@ var LOCAL_PREFIX = 'obsidian-web:'; // stays per-device var FLUSH_DELAY_MS = 300; + // Device-specific Obsidian keys that must NEVER roam through the server + // store. EmulateMobile=1 synced from a phone puts the desktop client into + // mobile emulation — fake 59px notch inset, tablet layout, "huge header". + var DEVICE_LOCAL_KEYS = { 'EmulateMobile': 1, 'DebugMode': 1 }; + + function isDeviceLocal(key) { + return key.indexOf(LOCAL_PREFIX) === 0 || + Object.prototype.hasOwnProperty.call(DEVICE_LOCAL_KEYS, key); + } + window.__owInstallRemoteLocalStorage = function () { var native = window.localStorage; @@ -34,6 +44,12 @@ var mem = {}; Object.keys(serverData).forEach(function (k) { mem[k] = String(serverData[k]); }); + // Purge device-local keys that leaked into the server store before + // this guard existed (e.g. a phone's EmulateMobile=1) — both from the + // in-memory view and, below via queue(), from the server itself. + var purge = Object.keys(mem).filter(isDeviceLocal); + purge.forEach(function (k) { delete mem[k]; }); + // One-time migration: keys already in this device's native storage // but missing on the server get uploaded, so the first PC that runs // this seeds the server store with its existing tokens/state. @@ -41,7 +57,7 @@ var seeded = 0; for (var i = 0; i < native.length; i++) { var k = native.key(i); - if (k.indexOf(LOCAL_PREFIX) === 0) continue; + if (isDeviceLocal(k)) continue; if (!(k in mem)) { var v = native.getItem(k); mem[k] = v; @@ -98,18 +114,18 @@ var storageShim = { getItem: function (key) { key = String(key); - if (key.indexOf(LOCAL_PREFIX) === 0) return native.getItem(key); + if (isDeviceLocal(key)) return native.getItem(key); return Object.prototype.hasOwnProperty.call(mem, key) ? mem[key] : null; }, setItem: function (key, value) { key = String(key); value = String(value); - if (key.indexOf(LOCAL_PREFIX) === 0) return native.setItem(key, value); + if (isDeviceLocal(key)) return native.setItem(key, value); mem[key] = value; queue(key, value); }, removeItem: function (key) { key = String(key); - if (key.indexOf(LOCAL_PREFIX) === 0) return native.removeItem(key); + if (isDeviceLocal(key)) return native.removeItem(key); if (Object.prototype.hasOwnProperty.call(mem, key)) { delete mem[key]; queue(key, null); @@ -125,8 +141,13 @@ return n >= 0 && n < keys.length ? keys[n] : null; }, }; + // configurable: true is required — the Proxy's ownKeys trap doesn't + // report 'length', which violates the proxy invariant for + // non-configurable properties and makes Object.keys(localStorage) + // throw ("trap result did not include 'length'"). Object.defineProperty(storageShim, 'length', { get: function () { return Object.keys(mem).length; }, + configurable: true, }); // Proxy catches direct property access (localStorage.foo = 'x' / @@ -166,6 +187,12 @@ configurable: true, }); + // Delete leaked device-local keys from the server store. + purge.forEach(function (k) { queue(k, null); }); + if (purge.length > 0) { + console.log('[obsidian-web] remote localStorage: purged device-local keys from server: ' + purge.join(', ')); + } + // Upload the migration seed after install so it can't race the map. if (seeded > 0) { Object.keys(seed).forEach(function (k) { queue(k, seed[k]); });