Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
ca22513
docker: add Docker support with two auth options
s39n Jun 9, 2026
ad8333a
docker: consolidate auth options into single docker-compose.yml
s39n Jun 9, 2026
c2c4e9b
docker: add unzip to build stage for obsidian-mobile script
s39n Jun 9, 2026
9d08213
docker: move vendor download to entrypoint (fixes build-time network …
s39n Jun 9, 2026
1191ee0
docker: fix docker-compose.yml structure (nginx inside services, not …
s39n Jun 9, 2026
b939fb9
docker: fix EXDEV rename error by symlinking .tmp into vendor volume
s39n Jun 9, 2026
9d2b7c2
feat: custom vault/plugins mount, .env auth key, mobile safe-area fix
s39n Jun 10, 2026
5083585
docker: add granular volume mounts for plugins, themes, snippets, fonts
s39n Jun 10, 2026
bb5405d
docker: use env var substitution for all volume paths
s39n Jun 10, 2026
09d0a1d
docker: comment out granular volume mounts (opt-in only)
s39n Jun 10, 2026
a48f287
docker: fix port conflict between obsidian-web and nginx
s39n Jun 10, 2026
bf0426d
docker: nginx hardcoded to port 80, no port conflict
s39n Jun 10, 2026
a9b4530
fix: AUTH_KEY not passed to container + mobile bottom safe-area
s39n Jun 10, 2026
0576e63
feat: replace static AUTH_KEY with TOTP authentication
s39n Jun 10, 2026
e610b73
fix: open-url recursion + proxy redirect following
s39n Jun 10, 2026
86aa2c8
fix: open-url recursion, proxy redirects, crypto.randomUUID polyfill
s39n Jun 10, 2026
f277c80
fix: open-url recursion, proxy redirects, crypto.randomUUID, keytar shim
s39n Jun 10, 2026
653c60b
fix: add isEncryptionAvailable to keytar shim
s39n Jun 10, 2026
6b8684c
fix: add safeStorage and keytar shims for keychain support
s39n Jun 10, 2026
269af27
debug: log safeStorage and keychain-related IPC calls
s39n Jun 10, 2026
5bbc90d
fix: safeStorage round-trip through localStorage using tagged base64
s39n Jun 10, 2026
8923689
fix: safeStorage — store secrets server-side via /api/keytar, return …
s39n Jun 10, 2026
7b94f20
fix: add crypto.createHmac + sync SHA-256 — fixes ion-sync _computeToken
s39n Jun 10, 2026
84adde1
fix: polyfill crypto.subtle for HTTP — fixes ion-sync _computeToken S…
s39n Jun 10, 2026
a3b2636
fix: expand crypto.subtle polyfill with AES-GCM for ion-sync on plain…
s39n Jun 10, 2026
792d72f
fix: add Content-Type on binary writes so body-parser parses them
s39n Jun 10, 2026
a36ac86
fix: add SHA-1 to crypto.subtle.digest polyfill for ion-sync getSHA
s39n Jun 10, 2026
fb812b3
fix: auto-repair ENOTDIR vault corruption in write path
s39n Jun 10, 2026
74aa3d1
docs: add Docker/NAS setup guide and CLAUDE.md developer context
s39n Jun 10, 2026
01acda8
feat: add favicon, open-in-new-window support, and vault manager popu…
s39n Jun 10, 2026
3a99347
feat: favicon, open-in-new-window, and vault manager popup
s39n Jun 10, 2026
3e03cad
feat: favicon, image paste, open-in-new-window, vault manager popup
s39n Jun 10, 2026
5fa9bb3
fix: image rendering and favicon Docker copy
s39n Jun 10, 2026
1343f2d
fix: strip virtual /vault/ prefix in trash handler
s39n Jun 10, 2026
7b0c5e7
fix: showItemInFolder opens file in new tab instead of silent noop
s39n Jun 10, 2026
519e75f
fix: override vault.getResourcePath to return HTTP URLs for images
s39n Jun 10, 2026
74ab25b
fix: use file.path directly in getResourcePath override to avoid time…
s39n Jun 10, 2026
d6ffda0
chore: add .gitattributes to normalize line endings (fixes CRLF churn…
s39n Jun 10, 2026
7705299
feat: restrict /api/vaults/open to VAULTS_ROOT (default user-data/)
s39n Jun 11, 2026
46d5d65
feat: rate-limit TOTP attempts and issue random per-session tokens
s39n Jun 11, 2026
6707f95
feat: server-backed localStorage so keychain tokens roam across devic…
s39n Jun 11, 2026
dab0c97
feat: add Service Worker bootstrap cache (stale-while-revalidate)
s39n Jun 12, 2026
ccb44fc
fix: complete truncated index.js (syntax error in Docker)
s39n Jun 15, 2026
af07246
perf: stale-while-revalidate for bootstrap server cache
s39n Jun 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
vendor/
node_modules/
.git/
.tmp/
docs/
.codenomad/
.DS_Store
.idea/
.vscode/
*.log
npm-debug.log*
nginx.htpasswd
30 changes: 30 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Copy this file to .env and set the values you need.
# .env is gitignored — never commit real secrets to the repo.

# ── Auth (TOTP / Google Authenticator) ────────────────────────────────────────
# Generate a secret (run once from src/server/):
# node -e "const {authenticator}=require('otplib');console.log(authenticator.generateSecret())"
# Paste the output here, then visit /__totp-setup?token=YOUR_SECRET to scan the QR code.
# TOTP_SECRET=

# ── Port ───────────────────────────────────────────────────────────────────────
# Direct access port (default 3000). nginx always uses 80, no conflict.
# PORT=3000

# ── Vault ──────────────────────────────────────────────────────────────────────
# Point USER_DATA at your vault folder on the host. Everything inside
# .obsidian/ (plugins, themes, snippets, fonts, settings) comes along for free.
# USER_DATA=/path/to/your/vault
# VAULT_PATH=user-data/demo-vault # right-hand side of the USER_DATA mount

# Synology NAS example:
# USER_DATA=/volume1/Docker/obsidian/data
# VAULT_PATH=user-data
# WATCH_POLLING=true

# ── Granular folder overrides ──────────────────────────────────────────────────
# Only needed if you want a shared folder used across multiple vaults.
# OBSIDIAN_PLUGINS=/path/to/.obsidian/plugins
# OBSIDIAN_THEMES=/path/to/.obsidian/themes
# OBSIDIAN_SNIPPETS=/path/to/.obsidian/snippets
# OBSIDIAN_FONTS=/path/to/.obsidian/fonts
14 changes: 14 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Normalize all text files to LF in the repo and working tree.
# Prevents CRLF conversion churn (whole-file diffs) on Windows checkouts.
* text=auto eol=lf

# Windows-specific scripts keep CRLF
*.bat text eol=crlf
*.ps1 text eol=crlf

# Binary files — never touch line endings
*.png binary
*.ico binary
*.jpg binary
*.woff binary
*.woff2 binary
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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*
Expand Down
127 changes: 127 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# obsidian-web — developer context for Claude

## What this project is

obsidian-web loads Obsidian's original renderer (`app.js`) unmodified inside a
standard browser. Every Node.js / Electron dependency is replaced with HTTP
shims. The Node.js server provides a REST + WebSocket API that the browser-side
shims call. There is no bundling step — the browser loads `app.js` directly from
the server.

## Key files

| Path | Role |
|------|------|
| `src/client/boot.js` | Runs first. Installs polyfills (`crypto.subtle`, `crypto.randomUUID`), then fetches the bootstrap cache and injects Obsidian's scripts. |
| `src/client/shims/original-fs.js` | Replaces Node's `fs` module. Async ops use `fetch`; sync ops use synchronous XHR via `__owSyncRequest`. |
| `src/client/shims/sync-http.js` | Implements `__owSyncRequest` / `__owSyncJson` using synchronous `XMLHttpRequest`. |
| `src/server/index.js` | Express + HTTP server. Registers all API routers. |
| `src/server/api/fs.js` | File-system REST API (`/api/fs/*`). Includes write coalescing and `mkdirRepair` for ENOTDIR vault corruption. |
| `src/server/api/pbkdf2.js` | `POST /api/pbkdf2` — offloads PBKDF2 key derivation to Node's native crypto (100k iterations in pure JS would freeze the browser for ~10 s). |
| `src/server/api/keytar.js` | `safeStorage` shim — stores plugin secrets server-side (Electron's `safeStorage` is unavailable in the browser). |
| `src/server/api/localstorage.js` | Server-backed `window.localStorage` store (`user-data/.localstorage.json`). Pairs with `src/client/shims/remote-localstorage.js`. |
| `src/client/shims/remote-localstorage.js` | Replaces `window.localStorage` with the server-backed store before app.js runs, so safeStorage tokens and app state roam across devices/origins. Keys prefixed `obsidian-web:` stay device-local. |
| `src/server/api/bootstrap.js` | `/api/bootstrap` — serves the entire vault's file tree and metadata in one compressed response so Obsidian's sync `statSync`/`readFileSync` calls hit an in-memory cache. |
| `src/server/middleware/auth.js` | Optional TOTP authentication middleware. Enabled by setting `TOTP_SECRET`. |

## Running locally (Node.js)

```bash
node scripts/update-obsidian.js # download Obsidian's renderer into vendor/
cd src/server && npm install
npm run dev # auto-reload; open http://127.0.0.1:3000
```

## Docker / NAS deployment

```bash
docker compose up -d
```

### Vault path

Inside the container, the working root is `/app/user-data/`. The
`docker-compose.yml` mounts the host directory `${USER_DATA:-./user-data}` to
that path. **Vaults must live under this mount.** Place your vault at
`${USER_DATA}/<VaultName>/` on the host; it appears at
`/app/user-data/<VaultName>/` inside the container. Set `VAULT_PATH=user-data/<VaultName>`
to open it on boot.

Example `.env` for a Synology NAS:
```
USER_DATA=/volume1/obsidian
VAULT_PATH=user-data/BrainTrust
TOTP_SECRET=JBSWY3DPEHPK3PXP
PORT=3005
WATCH_POLLING=true
```

## Plain-HTTP polyfills (`src/client/boot.js`)

Browsers restrict `crypto.subtle` (SubtleCrypto) to HTTPS/localhost. On plain
HTTP (common on LAN NAS setups) the entire `crypto.subtle` object is `undefined`.
`boot.js` polyfills it in pure JS:

- **SHA-256** — pure-JS implementation used by `crypto.subtle.digest('SHA-256', ...)`
- **SHA-1** — pure-JS implementation used by `crypto.subtle.digest('SHA-1', ...)` (ion-sync `getSHA`)
- **AES-256 + AES-GCM** — forward S-box, key expansion, CTR mode, GHASH auth tag
- **PBKDF2** — offloaded to `/api/pbkdf2` (Node's native `crypto.pbkdf2`) to avoid ~10 s browser freeze for 100k iterations
- **`crypto.randomUUID`** — polyfilled using `crypto.getRandomValues`

The polyfill only activates when `crypto.subtle` is absent (`!crypto.subtle`),
so HTTPS deployments use the native browser implementation.

## Binary write fix (`src/client/shims/original-fs.js`)

`fetch(url, { body: ArrayBuffer })` sends no `Content-Type` header. Express's
`body-parser` (used by `express.raw({ type: '*/*' })`) calls `type-is` to match
the content type; `type-is` returns `false` for requests with **no** Content-Type
even when the wildcard `*/*` is specified, so the body is left unparsed and
`req.body` defaults to `{}`. Node's `fs.writeFile` then throws:

> TypeError: The 'data' argument must be … Received an instance of Object

**Fix:** `writeFileAsync` and `syncRequest` always set
`Content-Type: application/octet-stream` on PUT requests so body-parser
always parses.

## ENOTDIR self-repair (`src/server/api/fs.js` — `mkdirRepair`)

Sync plugins can leave a regular file at a path that should be a directory
(e.g. a previous partial sync writes `Atlas/Books` as a file before the
directory structure is created). Later, `fs.stat('Atlas/Books/note.md')`
throws `ENOTDIR`.

Two mitigations:
1. `handleError` remaps `ENOTDIR` → `ENOENT` in the JSON `code` field so
callers treat it as "not found" and attempt a write.
2. `mkdirRepair` walks the target directory path, unlinks the first non-directory
component it finds, then retries `mkdir -p`. The write then succeeds and the
correct directory structure is created.

## ion-sync specifics

ion-sync is a community sync plugin using AES-GCM-256 with PBKDF2 key derivation.
On plain HTTP all of the following must be polyfilled / shimmed:

| Operation | Where handled |
|-----------|---------------|
| `crypto.subtle.importKey('raw', pw, 'PBKDF2', ...)` | boot.js polyfill |
| `crypto.subtle.deriveKey({ name: 'PBKDF2', ... })` | boot.js → `/api/pbkdf2` |
| `crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data)` | boot.js polyfill |
| `crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, data)` | boot.js polyfill |
| `crypto.subtle.digest('SHA-256', data)` | boot.js polyfill |
| `crypto.subtle.digest('SHA-1', data)` | boot.js polyfill |
| `safeStorage.encryptString` / `decryptString` | `/api/keytar` |
| `crypto.randomUUID()` | boot.js polyfill |

## Secrets / auth

- **Never commit a real `TOTP_SECRET`** — `.env` is gitignored.
- Auth is disabled when `TOTP_SECRET` is empty.
- Generate a secret: `node -e "const {authenticator}=require('otplib');console.log(authenticator.generateSecret())"`
- Scan QR code: visit `/__totp-setup?token=YOUR_SECRET` after starting the server.
- Sessions are random per-login tokens persisted in `user-data/.sessions.json`
(7-day expiry). Failed TOTP attempts are rate-limited to 5 per IP per 15 min.
- `/api/vaults/open` only accepts paths under `VAULTS_ROOT` (default
`user-data/`). Set `VAULTS_ROOT=*` to disable the restriction.
31 changes: 31 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
FROM node:20-alpine
WORKDIR /app

# unzip is required by scripts/update-obsidian-mobile.js (extracts APK assets)
RUN apk add --no-cache unzip

# App source, scripts, and default vault
COPY src/ src/
COPY scripts/ scripts/
COPY user-data/ user-data/
COPY entrypoint.sh /entrypoint.sh

# Static assets served from the project root (favicon, touch icon)
COPY favicon.ico favicon.svg apple-touch-icon.png ./
RUN chmod +x /entrypoint.sh

# Install server dependencies (production only)
RUN cd src/server && npm ci --omit=dev

EXPOSE 3000

# HOST must be 0.0.0.0 inside a container
ENV HOST=0.0.0.0
ENV PORT=3000
ENV VAULT_PATH=user-data/demo-vault

# Obsidian renderer bundles are downloaded at first start into the
# obsidian_vendor volume (see docker-compose.yml). Network is available
# at runtime but not during `docker build`, so we can't download here.
ENTRYPOINT ["/entrypoint.sh"]
CMD ["node", "src/server/index.js"]
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,50 @@ Open `http://127.0.0.1:3000`.
Open `http://127.0.0.1:3000/starter` to manage recent vaults and add a
server folder path as a vault.

## Docker (self-hosted / NAS)

The repo ships a `docker-compose.yml` for running obsidian-web on a NAS or any Docker host.

```bash
docker compose up -d
```

### Vault location

Inside the container, the server's working root is `/app/user-data/`. The `docker-compose.yml` mounts a host directory there:

```
${USER_DATA:-./user-data} → /app/user-data
```

**Your vault folder must live under that mount point.** For example, if your `.env` has:

```
USER_DATA=/volume1/obsidian
```

then place your vault at `/volume1/obsidian/<VaultName>/` on the host. Inside the container it appears at `/app/user-data/<VaultName>/`. The `VAULT_PATH` env var (default `user-data/demo-vault`) tells the server which subdirectory to open on boot — set it to `user-data/<VaultName>` to open your vault automatically.

### Configuration (`.env`)

Copy `.env.example` to `.env` and fill in your values:

| Variable | Default | Description |
|----------|---------|-------------|
| `USER_DATA` | `./user-data` | Host path mounted to `/app/user-data` — put your vault here |
| `VAULT_PATH` | `user-data/demo-vault` | Relative path inside the container to the vault to open on boot |
| `PORT` | `3000` | Host port |
| `TOTP_SECRET` | _(empty — auth disabled)_ | Base-32 TOTP secret; set to enable auth. Generate with `node -e "const {authenticator}=require('otplib');console.log(authenticator.generateSecret())"` then visit `/__totp-setup?token=YOUR_SECRET` to scan the QR code. |
| `WATCH_POLLING` | `false` | Set to `true` on network filesystems (NFS, SMB, rclone) that don't support inotify |

### Notes

- The Obsidian renderer is downloaded into a named Docker volume (`obsidian_vendor`) on first start — no manual step needed.
- Run behind a reverse proxy (nginx, Caddy, Cloudflare Tunnel) for HTTPS. Without HTTPS, the Web Crypto API (`crypto.subtle`) is unavailable; the project includes a pure-JS polyfill so plugins like ion-sync that rely on AES-GCM/PBKDF2 still work on plain HTTP.
- If you use a third-party sync plugin (e.g. ion-sync) that previously synced to the vault on another device, the initial sync may encounter ENOTDIR errors where old sync artifacts left files where directories should be. The server auto-repairs these by removing the blocking file and recreating the correct directory structure.

---

## Obsidian Version

`vendor/obsidian/` is generated from the official `obsidianmd/obsidian-releases` GitHub releases and is intentionally ignored by Git.
Expand Down
Binary file added apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
58 changes: 58 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
services:
obsidian-web:
build: .
ports:
- "${PORT:-3000}:3000"
volumes:
# Set these in .env (or Dockhand's env editor) to point at folders on
# your host. Defaults keep everything self-contained in the repo.

# Your vault — notes, and anything inside .obsidian/ (plugins, themes,
# snippets, fonts, settings). Point USER_DATA at your real vault folder.
- ${USER_DATA:-./user-data}:/app/user-data

# Granular overrides — only needed if you want to share a single
# plugins/themes/snippets/fonts folder across multiple vaults.
# - ${OBSIDIAN_PLUGINS:-./user-data/demo-vault/.obsidian/plugins}:/app/user-data/demo-vault/.obsidian/plugins
# - ${OBSIDIAN_THEMES:-./user-data/demo-vault/.obsidian/themes}:/app/user-data/demo-vault/.obsidian/themes
# - ${OBSIDIAN_SNIPPETS:-./user-data/demo-vault/.obsidian/snippets}:/app/user-data/demo-vault/.obsidian/snippets
# - ${OBSIDIAN_FONTS:-./user-data/demo-vault/.obsidian/fonts}:/app/user-data/demo-vault/.obsidian/fonts

# Obsidian renderer bundles (downloaded on first start)
- obsidian_vendor:/app/vendor

environment:
HOST: "0.0.0.0"
PORT: "3000"
VAULT_PATH: "${VAULT_PATH:-user-data/demo-vault}"
WATCH_POLLING: "${WATCH_POLLING:-false}"
# VAULT_REGISTRY: "user-data/registry.json"

# Auth — generate a secret with:
# node -e "const {authenticator}=require('otplib');console.log(authenticator.generateSecret())"
# Then set TOTP_SECRET in Dockhand's env editor or in a .env file.
# Visit /__totp-setup?token=YOUR_SECRET to scan the QR code.
TOTP_SECRET: "${TOTP_SECRET:-}"
env_file:
- path: .env
required: false
restart: unless-stopped

# Auth Option 2: HTTP Basic Auth via nginx
# nginx listens on port 80 and proxies to obsidian-web internally.
# Setup: htpasswd -c nginx.htpasswd <username>
# Start: docker compose --profile auth-nginx up
nginx:
profiles: ["auth-nginx"]
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx.htpasswd:/etc/nginx/htpasswd:ro
depends_on:
- obsidian-web
restart: unless-stopped

volumes:
obsidian_vendor:
23 changes: 23 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/bin/sh
set -e

# The update scripts extract files to /app/.tmp/ then atomically rename() them
# into /app/vendor/. rename() across different filesystems raises EXDEV, and
# /app/.tmp (container layer) vs /app/vendor (Docker volume) are different
# devices. Fix: redirect .tmp into the vendor volume so both paths share the
# same filesystem and rename() succeeds.
mkdir -p /app/vendor/.tmp
rm -rf /app/.tmp
ln -sf /app/vendor/.tmp /app/.tmp

if [ ! -f /app/vendor/obsidian/app.js ]; then
echo "[docker] Downloading Obsidian desktop renderer (first run — this takes ~30s)..."
node scripts/update-obsidian.js
fi

if [ ! -f /app/vendor/obsidian-mobile/app.js ]; then
echo "[docker] Downloading Obsidian mobile renderer (first run — this takes ~30s)..."
node scripts/update-obsidian-mobile.js
fi

exec "$@"
Binary file added favicon.ico
Binary file not shown.
6 changes: 6 additions & 0 deletions favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 29 additions & 0 deletions nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;

# HTTP Basic Auth — generate nginx.htpasswd with:
# htpasswd -c nginx.htpasswd <username>
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;
}
}
Loading