SPB Git

spb/drive Public

SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.

JavaScript 82.7% CSS 10.6% Nunjucks 3.6% Shell 1.8% SQL 1.3%

feat: production deploy to m3u96b — ~/srv/drive layout, ngrok config merge, LibreOffice via 7zz DMG extraction, backup+restore drill verified

Live at https://drive.spboucher.ai (pm2: spbdrive-server + spbdrive-tunnel)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed yesterday (Aug 10, 2026) parent d5916c6

Showing 5 changed files with +43 and −42

modified README.md +10 −9
@@ -52,7 +52,7 @@ scripts/ check-headers · inject-headers · check-secrets · reset-password
52 52 test/ vitest unit + e2e (upload → thumb → protected share → download)
53 53 ```
54 54
55 Runtime data lives outside the repo at `/srv/drive`
55 +Runtime data lives outside the repo at `~/srv/drive` on m3u96b (macOS seals `/srv`; on Linux use `/srv/drive` — the systemd unit keeps that path)
56 56 (`files/` CAS blobs · `db/drive.sqlite` · `cache/` thumbs+transcodes · `backups/` · `logs/`).
57 57
58 58 ## Development
@@ -74,8 +74,8 @@ rsync -a --exclude node_modules --exclude .git . m3u96b:~/spbdrive-src/
74 74 ssh m3u96b 'SPBDRIVE_BOOTSTRAP_PASSWORD=… bash ~/spbdrive-src/deploy/setup-m3u96b.sh ~/spbdrive-src'
75 75 ```
76 76
77 The script is idempotent: installs ffmpeg/LibreOffice/poppler/7z/ngrok/pm2, creates `/srv/drive`
78 (700), syncs the app to `/srv/drive/app`, `npm ci --omit=dev`, seeds auth on first boot, starts
77 +The script is idempotent: installs ffmpeg/LibreOffice/poppler/7z/ngrok/pm2, creates `~/srv/drive`
78 +(700), syncs the app to `~/srv/drive/app`, `npm ci --omit=dev`, seeds auth on first boot, starts
79 79 `spbdrive-server` + `spbdrive-tunnel` under pm2 (`pm2 save` + `pm2 startup` for reboot survival),
80 80 health-checks `:7430/healthz`, and installs the nightly backup cron (02:30).
81 81
@@ -85,7 +85,7 @@ gives you at the spboucher.ai DNS provider. TLS terminates at ngrok; the app tru
85 85
86 86 ## Backups & restore
87 87
88 `deploy/backup.sh` (cron, 02:30) writes `/srv/drive/backups/YYYY-MM-DD/`:
88 +`deploy/backup.sh` (cron, 02:30) writes `~/srv/drive/backups/YYYY-MM-DD/`:
89 89 a SQLite `.backup` snapshot plus an rsync `--link-dest` hardlink copy of the blob store
90 90 (unchanged blobs cost zero bytes). Retention: 14 dailies + 8 Sunday weeklies. Each run verifies
91 91 `PRAGMA integrity_check` and blob counts.
@@ -94,11 +94,12 @@ a SQLite `.backup` snapshot plus an rsync `--link-dest` hardlink copy of the blo
94 94
95 95 ```bash
96 96 pm2 stop spbdrive-server
97 SNAP=/srv/drive/backups/<date>
98 cp "$SNAP/drive.sqlite" /srv/drive/db/drive.sqlite
99 rm -f /srv/drive/db/drive.sqlite-wal /srv/drive/db/drive.sqlite-shm
100 rsync -a --delete "$SNAP/files/" /srv/drive/files/
101 cp -p "$SNAP/auth.json" "$SNAP/keys.json" /srv/drive/
97 +DRIVE=~/srv/drive
98 +SNAP=$DRIVE/backups/<date>
99 +cp "$SNAP/drive.sqlite" $DRIVE/db/drive.sqlite
100 +rm -f $DRIVE/db/drive.sqlite-wal $DRIVE/db/drive.sqlite-shm
101 +rsync -a --delete "$SNAP/files/" $DRIVE/files/
102 +cp -p "$SNAP/auth.json" "$SNAP/keys.json" $DRIVE/
102 103 pm2 start spbdrive-server && curl -fsS http://127.0.0.1:7430/healthz
103 104 ```
104 105
modified deploy/backup.sh +1 −1
@@ -14,7 +14,7 @@
14 14
15 15 set -euo pipefail
16 16
17 DATA_DIR=/srv/drive
17 +DATA_DIR="$HOME/srv/drive"
18 18 BACKUP_ROOT="$DATA_DIR/backups"
19 19 TODAY="$(date +%Y-%m-%d)"
20 20 DEST="$BACKUP_ROOT/$TODAY"
modified deploy/ecosystem.config.cjs +10 −19
@@ -10,24 +10,16 @@
10 10 * ─────────────────────────────────────────────
11 11 */
12 12
13 const APP_DIR = '/srv/drive/app';
14 const DATA_DIR = '/srv/drive';
15 const LOG_DIR = '/srv/drive/logs';
13 +const os = require('node:os');
16 14
17 // deploy/ngrok.yml expands ${NGROK_AUTHTOKEN}; when the env var isn't set,
18 // fall back to the token already stored in the agent's default config.
19 function ngrokAuthtoken() {
20 if (process.env.NGROK_AUTHTOKEN) return process.env.NGROK_AUTHTOKEN;
21 try {
22 const fs = require('node:fs');
23 const os = require('node:os');
24 const agentCfg = `${os.homedir()}/Library/Application Support/ngrok/ngrok.yml`;
25 const match = fs.readFileSync(agentCfg, 'utf8').match(/authtoken:\s*(\S+)/);
26 return match ? match[1] : '';
27 } catch {
28 return '';
29 }
30 }
15 +// macOS seals /srv — the drive lives under $HOME/srv (see setup-m3u96b.sh).
16 +const DATA_DIR = `${os.homedir()}/srv/drive`;
17 +const APP_DIR = `${DATA_DIR}/app`;
18 +const LOG_DIR = `${DATA_DIR}/logs`;
19 +
20 +// The authtoken lives in the agent's default config (ngrok config add-authtoken);
21 +// both configs are passed and merged in order — v3 configs don't expand env vars.
22 +const NGROK_AGENT_CFG = `${os.homedir()}/Library/Application Support/ngrok/ngrok.yml`;
31 23
32 24 module.exports = {
33 25 apps: [
@@ -56,9 +48,8 @@ module.exports = {
56 48 name: 'spbdrive-tunnel',
57 49 cwd: APP_DIR,
58 50 script: 'ngrok',
59 args: ['start', 'spbdrive', '--config', `${APP_DIR}/deploy/ngrok.yml`],
51 + args: ['start', 'spbdrive', '--config', NGROK_AGENT_CFG, '--config', `${APP_DIR}/deploy/ngrok.yml`],
60 52 interpreter: 'none',
61 env: { NGROK_AUTHTOKEN: ngrokAuthtoken() },
62 53 autorestart: true,
63 54 restart_delay: 5000,
64 55 out_file: `${LOG_DIR}/tunnel.out.log`,
modified deploy/ngrok.yml +3 −4
@@ -12,14 +12,13 @@
12 12 # 1. Reserve the domain `drive.spboucher.ai` in the ngrok dashboard
13 13 # (Universal Gateway → Domains → New Domain).
14 14 # 2. Add the CNAME record ngrok shows you at the spboucher.ai DNS provider.
15 # 3. Export NGROK_AUTHTOKEN (or keep the agent's default authtoken config).
15 +# 3. Authtoken: `ngrok config add-authtoken <token>` once on the node —
16 +# pm2 passes both the default agent config (token) and this file (endpoint),
17 +# merged in order. (ngrok v3 configs do not interpolate env vars.)
16 18 # TLS terminates at ngrok; the app runs trustProxy and reads x-forwarded-for.
17 19
18 20 version: 3
19 21
20 agent:
21 authtoken: ${NGROK_AUTHTOKEN}
22
23 22 endpoints:
24 23 - name: spbdrive
25 24 url: https://drive.spboucher.ai
modified deploy/setup-m3u96b.sh +19 −9
@@ -16,8 +16,9 @@
16 16
17 17 set -euo pipefail
18 18
19 APP_DIR=/srv/drive/app
20 DATA_DIR=/srv/drive
19 +# macOS seals /srv (read-only system volume) — the drive lives under $HOME/srv.
20 +DATA_DIR="$HOME/srv/drive"
21 +APP_DIR="$DATA_DIR/app"
21 22 REPO_SRC="${1:-$PWD}"
22 23 BREW="$(command -v brew || echo /opt/homebrew/bin/brew)"
23 24
@@ -28,9 +29,22 @@ log "Installing system packages (ffmpeg, libreoffice, poppler, 7z, ngrok, pm2)"
28 29 "$BREW" list ffmpeg >/dev/null 2>&1 || "$BREW" install ffmpeg
29 30 "$BREW" list poppler >/dev/null 2>&1 || "$BREW" install poppler # pdftotext + pdftoppm
30 31 "$BREW" list sevenzip >/dev/null 2>&1 || "$BREW" install sevenzip # 7z
31 if ! "$BREW" list --cask libreoffice >/dev/null 2>&1 && [ ! -d /Applications/LibreOffice.app ]; then
32 # --no-quarantine avoids the hdiutil Gatekeeper prompt on headless installs.
33 "$BREW" install --cask --no-quarantine libreoffice || \
32 +if [ ! -d /Applications/LibreOffice.app ]; then
33 + # macOS 26 blocks headless hdiutil attach with a consent prompt, so the cask
34 + # can't install over SSH. Fetch the DMG via brew, extract it with 7zz instead.
35 + "$BREW" fetch --cask libreoffice >/dev/null || true
36 + LO_DMG="$(ls "$HOME"/Library/Caches/Homebrew/downloads/*LibreOffice*.dmg 2>/dev/null | head -1)"
37 + if [ -n "$LO_DMG" ]; then
38 + LO_TMP="$(mktemp -d)"
39 + (cd "$LO_TMP" && 7zz x -y "$LO_DMG" >/dev/null 2>&1) || true
40 + LO_APP="$(find "$LO_TMP" -maxdepth 3 -name 'LibreOffice.app' | head -1)"
41 + if [ -n "$LO_APP" ]; then
42 + ditto "$LO_APP" /Applications/LibreOffice.app
43 + xattr -dr com.apple.quarantine /Applications/LibreOffice.app 2>/dev/null || true
44 + fi
45 + rm -rf "$LO_TMP"
46 + fi
47 + [ -d /Applications/LibreOffice.app ] || \
34 48 echo "⚠ LibreOffice install failed — office previews will fall back to download cards" >&2
35 49 fi
36 50 command -v ngrok >/dev/null 2>&1 || "$BREW" install ngrok
@@ -45,10 +59,6 @@ fi
45 59
46 60 # 2 ── Data directories (700) ───────────────────────────────────────────
47 61 log "Creating $DATA_DIR layout"
48 if [ ! -d "$DATA_DIR" ]; then
49 sudo mkdir -p "$DATA_DIR"
50 sudo chown "$(id -un):$(id -gn)" "$DATA_DIR"
51 fi
52 62 mkdir -p "$DATA_DIR"/{files,db,cache,backups,logs}
53 63 chmod 700 "$DATA_DIR" "$DATA_DIR"/{files,db,cache,backups,logs}
54 64
55 65