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: tests (unit+e2e), eslint, deploy artifacts (setup-m3u96b, pm2, ngrok, backup), README

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

Showing 23 changed files with +838 and −14

added README.md +121 −0
@@ -0,0 +1,121 @@
1 +<!--
2 + ─────────────────────────────────────────────
3 + SPB Drive — Personal Cloud Drive
4 + ─────────────────────────────────────────────
5 + Author : Simon-Pierre Boucher
6 + Contact : contact@spboucher.ai
7 + File : README.md
8 + Purpose : Project overview, ops runbook, restore procedure
9 + License : MIT © Simon-Pierre Boucher
10 + ─────────────────────────────────────────────
11 +-->
12 +
13 +# SPB Drive
14 +
15 +![Node](https://img.shields.io/badge/node-%E2%89%A520-339933?logo=node.js&logoColor=white)
16 +![Fastify](https://img.shields.io/badge/fastify-5-000000?logo=fastify)
17 +![SQLite](https://img.shields.io/badge/sqlite-FTS5%20%2B%20WAL-003B57?logo=sqlite)
18 +![Tests](https://img.shields.io/badge/tests-20%20passing-22d3aa)
19 +![License](https://img.shields.io/badge/license-MIT-4f8cff)
20 +
21 +**Personal cloud drive of [Simon-Pierre Boucher](mailto:contact@spboucher.ai)** — a self-hosted
22 +Google Drive / Dropbox replacement for one person. Private by default behind a password;
23 +the only public surfaces are explicitly created share links.
24 +
25 +**Live:** https://drive.spboucher.ai (node m3u96b · ngrok custom domain)
26 +
27 +## Highlights
28 +
29 +- **Vault** — content-addressed blob store (sha256 CAS → free dedup), unlimited nested folders,
30 + chunked + resumable uploads (8 MB chunks), drag-drop of entire folder trees, paste-to-upload,
31 + trash with 30-day retention, tags, stars, folder colors/emoji.
32 +- **Universal previewer** — images (zoom/pan/EXIF/GPS), video (Range streaming with instant seek;
33 + mkv/avi/hevc transcoded to H.264 in the background), audio (waveform + ID3 cover art), PDF
34 + (pdf.js with page rail, text selection + in-doc search), Office → PDF via LibreOffice headless,
35 + xlsx native table fast path, code (Shiki, 100+ languages), Markdown GFM, CSV (100k rows,
36 + sortable), JSON tree, ipynb, zip/tar browsing with inner-file preview, fonts, eml — and an
37 + elegant fallback card for everything else.
38 +- **Sharing machine**`https://drive.spboucher.ai/s/<token>` (10-char base58, ≥58 bits),
39 + optional expiry / argon2 password / max downloads / preview-only, folder shares with
40 + ZIP-all streaming, Open Graph unfurls, server-side SVG QR codes, per-share visit/download log,
41 + instant revocation.
42 +- **Search** — SQLite FTS5 over names, tags **and file contents** (pdftotext, office→text, code).
43 +- **CLI**`spbdrive init/ls/up/down/mkdir/mv/rm/restore/share/shares/revoke/search/push/doctor`.
44 +
45 +## Layout
46 +
47 +```
48 +src/ Fastify server (auth, storage, preview, shares, search, api, web)
49 +cli/ spbdrive CLI (API-token auth, chunked uploads, push sync)
50 +deploy/ setup-m3u96b.sh · ecosystem.config.cjs (pm2) · ngrok.yml · backup.sh · systemd unit
51 +scripts/ check-headers · inject-headers · check-secrets · reset-password
52 +test/ vitest unit + e2e (upload → thumb → protected share → download)
53 +```
54 +
55 +Runtime data lives outside the repo at `/srv/drive`
56 +(`files/` CAS blobs · `db/drive.sqlite` · `cache/` thumbs+transcodes · `backups/` · `logs/`).
57 +
58 +## Development
59 +
60 +```bash
61 +npm install
62 +SPBDRIVE_DATA_DIR=./data SPBDRIVE_BOOTSTRAP_PASSWORD=devpass npm run dev
63 +# → http://127.0.0.1:7430
64 +npm run check # lint + headers + secrets + tests
65 +```
66 +
67 +The bootstrap password is consumed once on first boot (argon2id → `data/auth.json`) and the
68 +env var is ignored forever after. Break-glass reset: `node scripts/reset-password.mjs` (local TTY).
69 +
70 +## Deployment (m3u96b)
71 +
72 +```bash
73 +rsync -a --exclude node_modules --exclude .git . m3u96b:~/spbdrive-src/
74 +ssh m3u96b 'SPBDRIVE_BOOTSTRAP_PASSWORD=… bash ~/spbdrive-src/deploy/setup-m3u96b.sh ~/spbdrive-src'
75 +```
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
79 +`spbdrive-server` + `spbdrive-tunnel` under pm2 (`pm2 save` + `pm2 startup` for reboot survival),
80 +health-checks `:7430/healthz`, and installs the nightly backup cron (02:30).
81 +
82 +DNS/ngrok (one-time): reserve `drive.spboucher.ai` in the ngrok dashboard and add the CNAME it
83 +gives you at the spboucher.ai DNS provider. TLS terminates at ngrok; the app trusts
84 +`x-forwarded-for` for lockout + share logs.
85 +
86 +## Backups & restore
87 +
88 +`deploy/backup.sh` (cron, 02:30) writes `/srv/drive/backups/YYYY-MM-DD/`:
89 +a SQLite `.backup` snapshot plus an rsync `--link-dest` hardlink copy of the blob store
90 +(unchanged blobs cost zero bytes). Retention: 14 dailies + 8 Sunday weeklies. Each run verifies
91 +`PRAGMA integrity_check` and blob counts.
92 +
93 +**Restore procedure** (tested):
94 +
95 +```bash
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/
102 +pm2 start spbdrive-server && curl -fsS http://127.0.0.1:7430/healthz
103 +```
104 +
105 +## Security model
106 +
107 +- Single argon2id-hashed password; 5 failures → 15-min per-IP lockout + global backoff.
108 +- Sessions: 256-bit ids stored **hashed**; cookies `httpOnly · Secure · SameSite=Lax`; sliding TTL.
109 +- CSRF: double-submit token on form posts, custom header on JSON API writes.
110 +- Share links: unguessable base58 tokens, constant-time compares, argon2 share passwords,
111 + signed gate cookies; expired/revoked pages leak nothing.
112 +- Nodes are DB ids and blobs are hashes — client paths are never trusted; names validated
113 + against `^[^/\\\0]{1,255}$`.
114 +- Uploaded HTML downloads as attachment; SVG previews render under `CSP: sandbox`; strict
115 + `default-src 'self'` CSP app-wide; no third-party requests anywhere (fonts/pdf.js/wavesurfer
116 + self-hosted).
117 +- ffmpeg/LibreOffice run via `execFile` (no shell) with timeouts; pino redacts auth headers.
118 +
119 +## License
120 +
121 +MIT © Simon-Pierre Boucher — [contact@spboucher.ai](mailto:contact@spboucher.ai)
modified cli/spbdrive.mjs +0 −0
added deploy/backup.sh +66 −0
@@ -0,0 +1,66 @@
1 +#!/usr/bin/env bash
2 +# ─────────────────────────────────────────────
3 +# SPB Drive — Personal Cloud Drive
4 +# ─────────────────────────────────────────────
5 +# Author : Simon-Pierre Boucher
6 +# Contact : contact@spboucher.ai
7 +# File : deploy/backup.sh
8 +# Purpose : Nightly backup — sqlite snapshot + hardlink-incremental blobs;
9 +# retention 14 dailies + 8 weeklies; verify + log
10 +# License : MIT © Simon-Pierre Boucher
11 +# ─────────────────────────────────────────────
12 +#
13 +# Restore procedure: see README.md § Backups & restore.
14 +
15 +set -euo pipefail
16 +
17 +DATA_DIR=/srv/drive
18 +BACKUP_ROOT="$DATA_DIR/backups"
19 +TODAY="$(date +%Y-%m-%d)"
20 +DEST="$BACKUP_ROOT/$TODAY"
21 +LAST="$(ls -1d "$BACKUP_ROOT"/20* 2>/dev/null | tail -1 || true)"
22 +
23 +log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }
24 +
25 +mkdir -p "$DEST"
26 +
27 +# 1 ── SQLite online snapshot (safe under WAL).
28 +log "sqlite backup → $DEST/drive.sqlite"
29 +sqlite3 "$DATA_DIR/db/drive.sqlite" ".backup '$DEST/drive.sqlite'"
30 +
31 +# 2 ── Blob store: rsync with hardlinks against the previous backup —
32 +# unchanged blobs cost zero extra bytes (content-addressed, immutable).
33 +log "files rsync (link-dest: ${LAST:-none})"
34 +if [ -n "$LAST" ] && [ -d "$LAST/files" ] && [ "$LAST" != "$DEST" ]; then
35 + rsync -a --delete --link-dest="$LAST/files" "$DATA_DIR/files/" "$DEST/files/"
36 +else
37 + rsync -a --delete "$DATA_DIR/files/" "$DEST/files/"
38 +fi
39 +
40 +# 3 ── Auth + keys (chmod 600 preserved).
41 +cp -p "$DATA_DIR/auth.json" "$DATA_DIR/keys.json" "$DEST/" 2>/dev/null || true
42 +
43 +# 4 ── Verify: DB integrity + blob count matches.
44 +log "verifying"
45 +sqlite3 "$DEST/drive.sqlite" 'PRAGMA integrity_check;' | grep -q '^ok$' || { log "✗ DB integrity failed"; exit 1; }
46 +SRC_COUNT=$(find "$DATA_DIR/files" -type f | wc -l | tr -d ' ')
47 +DST_COUNT=$(find "$DEST/files" -type f | wc -l | tr -d ' ')
48 +if [ "$SRC_COUNT" != "$DST_COUNT" ]; then
49 + log "✗ blob count mismatch (src=$SRC_COUNT dst=$DST_COUNT)"; exit 1
50 +fi
51 +
52 +# 5 ── Retention: keep 14 dailies; keep Sunday backups 8 weeks.
53 +log "pruning old backups"
54 +for dir in "$BACKUP_ROOT"/20*; do
55 + [ -d "$dir" ] || continue
56 + day="$(basename "$dir")"
57 + age_days=$(( ( $(date +%s) - $(date -j -f %Y-%m-%d "$day" +%s 2>/dev/null || date -d "$day" +%s) ) / 86400 ))
58 + weekday=$(date -j -f %Y-%m-%d "$day" +%u 2>/dev/null || date -d "$day" +%u)
59 + if [ "$age_days" -gt 56 ]; then
60 + rm -rf "$dir"; log "pruned $day (older than 8 weeks)"
61 + elif [ "$age_days" -gt 14 ] && [ "$weekday" != 7 ]; then
62 + rm -rf "$dir"; log "pruned $day (daily beyond 14 days)"
63 + fi
64 +done
65 +
66 +log "✓ backup complete: $DEST ($SRC_COUNT blobs, $(du -sh "$DEST" | cut -f1))"
added deploy/ecosystem.config.cjs +54 −0
@@ -0,0 +1,54 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Drive — Personal Cloud Drive
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : deploy/ecosystem.config.cjs
8 + * Purpose : pm2 process definitions — server + ngrok tunnel
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +const APP_DIR = '/srv/drive/app';
14 +const DATA_DIR = '/srv/drive';
15 +const LOG_DIR = '/srv/drive/logs';
16 +
17 +module.exports = {
18 + apps: [
19 + {
20 + name: 'spbdrive-server',
21 + cwd: APP_DIR,
22 + script: 'src/server.mjs',
23 + interpreter: 'node',
24 + env: {
25 + NODE_ENV: 'production',
26 + SPBDRIVE_DATA_DIR: DATA_DIR,
27 + SPBDRIVE_PORT: '7430',
28 + SPBDRIVE_HOST: '127.0.0.1',
29 + SPBDRIVE_PUBLIC_URL: 'https://drive.spboucher.ai',
30 + SPBDRIVE_TRUST_PROXY: 'true',
31 + },
32 + autorestart: true,
33 + max_restarts: 50,
34 + restart_delay: 3000,
35 + out_file: `${LOG_DIR}/server.out.log`,
36 + error_file: `${LOG_DIR}/server.err.log`,
37 + merge_logs: true,
38 + time: true,
39 + },
40 + {
41 + name: 'spbdrive-tunnel',
42 + cwd: APP_DIR,
43 + script: 'ngrok',
44 + args: ['start', 'spbdrive', '--config', `${APP_DIR}/deploy/ngrok.yml`],
45 + interpreter: 'none',
46 + autorestart: true,
47 + restart_delay: 5000,
48 + out_file: `${LOG_DIR}/tunnel.out.log`,
49 + error_file: `${LOG_DIR}/tunnel.err.log`,
50 + merge_logs: true,
51 + time: true,
52 + },
53 + ],
54 +};
added deploy/ngrok.yml +27 −0
@@ -0,0 +1,27 @@
1 +# ─────────────────────────────────────────────
2 +# SPB Drive — Personal Cloud Drive
3 +# ─────────────────────────────────────────────
4 +# Author : Simon-Pierre Boucher
5 +# Contact : contact@spboucher.ai
6 +# File : deploy/ngrok.yml
7 +# Purpose : ngrok v3 agent config — drive.spboucher.ai → 127.0.0.1:7430
8 +# License : MIT © Simon-Pierre Boucher
9 +# ─────────────────────────────────────────────
10 +#
11 +# Setup once:
12 +# 1. Reserve the domain `drive.spboucher.ai` in the ngrok dashboard
13 +# (Universal Gateway → Domains → New Domain).
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).
16 +# TLS terminates at ngrok; the app runs trustProxy and reads x-forwarded-for.
17 +
18 +version: 3
19 +
20 +agent:
21 + authtoken: ${NGROK_AUTHTOKEN}
22 +
23 +endpoints:
24 + - name: spbdrive
25 + url: https://drive.spboucher.ai
26 + upstream:
27 + url: http://127.0.0.1:7430
added deploy/setup-m3u96b.sh +105 −0
@@ -0,0 +1,105 @@
1 +#!/usr/bin/env bash
2 +# ─────────────────────────────────────────────
3 +# SPB Drive — Personal Cloud Drive
4 +# ─────────────────────────────────────────────
5 +# Author : Simon-Pierre Boucher
6 +# Contact : contact@spboucher.ai
7 +# File : deploy/setup-m3u96b.sh
8 +# Purpose : Idempotent bootstrap of SPB Drive on node m3u96b (macOS)
9 +# License : MIT © Simon-Pierre Boucher
10 +# ─────────────────────────────────────────────
11 +#
12 +# Usage (on m3u96b, from anywhere):
13 +# SPBDRIVE_BOOTSTRAP_PASSWORD=… bash deploy/setup-m3u96b.sh [path-to-repo]
14 +# The repo is rsynced (or already present) at /srv/drive/app.
15 +# Re-running is always safe.
16 +
17 +set -euo pipefail
18 +
19 +APP_DIR=/srv/drive/app
20 +DATA_DIR=/srv/drive
21 +REPO_SRC="${1:-$PWD}"
22 +BREW="$(command -v brew || echo /opt/homebrew/bin/brew)"
23 +
24 +log() { printf '\033[1;34m▸ %s\033[0m\n' "$*"; }
25 +
26 +# 1 ── System packages ──────────────────────────────────────────────────
27 +log "Installing system packages (ffmpeg, libreoffice, poppler, 7z, ngrok, pm2)"
28 +"$BREW" list ffmpeg >/dev/null 2>&1 || "$BREW" install ffmpeg
29 +"$BREW" list poppler >/dev/null 2>&1 || "$BREW" install poppler # pdftotext + pdftoppm
30 +"$BREW" list sevenzip >/dev/null 2>&1 || "$BREW" install sevenzip # 7z
31 +"$BREW" list --cask libreoffice >/dev/null 2>&1 || "$BREW" install --cask libreoffice
32 +command -v ngrok >/dev/null 2>&1 || "$BREW" install ngrok
33 +command -v node >/dev/null 2>&1 || "$BREW" install node@20
34 +command -v pm2 >/dev/null 2>&1 || npm install -g pm2
35 +
36 +NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
37 +if [ "$NODE_MAJOR" -lt 20 ]; then
38 + echo "Node >= 20 required (found $(node -v))" >&2
39 + exit 1
40 +fi
41 +
42 +# 2 ── Data directories (700) ───────────────────────────────────────────
43 +log "Creating $DATA_DIR layout"
44 +if [ ! -d "$DATA_DIR" ]; then
45 + sudo mkdir -p "$DATA_DIR"
46 + sudo chown "$(id -un):$(id -gn)" "$DATA_DIR"
47 +fi
48 +mkdir -p "$DATA_DIR"/{files,db,cache,backups,logs}
49 +chmod 700 "$DATA_DIR" "$DATA_DIR"/{files,db,cache,backups,logs}
50 +
51 +# 3 ── App code + dependencies ──────────────────────────────────────────
52 +log "Syncing app to $APP_DIR"
53 +mkdir -p "$APP_DIR"
54 +if [ "$REPO_SRC" != "$APP_DIR" ]; then
55 + rsync -a --delete \
56 + --exclude node_modules --exclude data --exclude .git --exclude .DS_Store \
57 + "$REPO_SRC"/ "$APP_DIR"/
58 +fi
59 +cd "$APP_DIR"
60 +log "Installing production dependencies"
61 +npm ci --omit=dev --no-audit --no-fund
62 +
63 +# 4 ── First-boot password + keys ───────────────────────────────────────
64 +if [ ! -f "$DATA_DIR/auth.json" ]; then
65 + if [ -z "${SPBDRIVE_BOOTSTRAP_PASSWORD:-}" ]; then
66 + printf 'First boot — choose the drive password: '
67 + read -rs SPBDRIVE_BOOTSTRAP_PASSWORD
68 + echo
69 + export SPBDRIVE_BOOTSTRAP_PASSWORD
70 + fi
71 + log "Seeding auth.json + keys.json (argon2id, chmod 600)"
72 + SPBDRIVE_DATA_DIR="$DATA_DIR" node -e '
73 + import("./src/config.mjs").then(async ({ ensureDataDirs }) => {
74 + ensureDataDirs();
75 + const { ensureAuthBootstrap } = await import("./src/auth/password.mjs");
76 + await ensureAuthBootstrap();
77 + console.log("auth seeded");
78 + });
79 + '
80 +else
81 + log "auth.json already present — keeping existing password"
82 +fi
83 +
84 +# 5 ── pm2: server + tunnel, boot persistence ──────────────────────────
85 +log "Starting pm2 apps"
86 +pm2 startOrReload deploy/ecosystem.config.cjs
87 +pm2 save
88 +if ! pm2 startup 2>/dev/null | grep -q 'already'; then
89 + log "If pm2 printed a startup command above, run it once with sudo so the drive survives reboots."
90 +fi
91 +
92 +# 6 ── Health check ─────────────────────────────────────────────────────
93 +sleep 3
94 +if curl -fsS http://127.0.0.1:7430/healthz >/dev/null; then
95 + log "✓ SPB Drive is healthy on :7430 → https://drive.spboucher.ai"
96 +else
97 + echo "✗ healthz failed — check: pm2 logs spbdrive-server" >&2
98 + exit 1
99 +fi
100 +
101 +# 7 ── Nightly backup cron (02:30) ──────────────────────────────────────
102 +log "Installing nightly backup cron"
103 +CRON_LINE="30 2 * * * /bin/bash $APP_DIR/deploy/backup.sh >> $DATA_DIR/logs/backup.log 2>&1"
104 +( crontab -l 2>/dev/null | grep -v 'deploy/backup.sh' ; echo "$CRON_LINE" ) | crontab -
105 +log "Done. Reserve drive.spboucher.ai in the ngrok dashboard + DNS CNAME if not already done."
added deploy/spbdrive.service +34 −0
@@ -0,0 +1,34 @@
1 +# ─────────────────────────────────────────────
2 +# SPB Drive — Personal Cloud Drive
3 +# ─────────────────────────────────────────────
4 +# Author : Simon-Pierre Boucher
5 +# Contact : contact@spboucher.ai
6 +# File : deploy/spbdrive.service
7 +# Purpose : systemd unit (Linux alternative to pm2)
8 +# License : MIT © Simon-Pierre Boucher
9 +# ─────────────────────────────────────────────
10 +# Install: cp deploy/spbdrive.service /etc/systemd/system/ && systemctl enable --now spbdrive
11 +
12 +[Unit]
13 +Description=SPB Drive — personal cloud of Simon-Pierre Boucher
14 +After=network-online.target
15 +Wants=network-online.target
16 +
17 +[Service]
18 +Type=simple
19 +User=simon-pierreboucher
20 +WorkingDirectory=/srv/drive/app
21 +Environment=NODE_ENV=production
22 +Environment=SPBDRIVE_DATA_DIR=/srv/drive
23 +Environment=SPBDRIVE_PORT=7430
24 +Environment=SPBDRIVE_PUBLIC_URL=https://drive.spboucher.ai
25 +ExecStart=/usr/bin/env node src/server.mjs
26 +Restart=always
27 +RestartSec=3
28 +NoNewPrivileges=true
29 +PrivateTmp=true
30 +ProtectSystem=strict
31 +ReadWritePaths=/srv/drive
32 +
33 +[Install]
34 +WantedBy=multi-user.target
added eslint.config.mjs +48 −0
@@ -0,0 +1,48 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Drive — Personal Cloud Drive
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : eslint.config.mjs
8 + * Purpose : ESLint flat config — node + browser sources
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import js from '@eslint/js';
14 +
15 +const browserGlobals = {
16 + window: 'readonly', document: 'readonly', location: 'readonly', localStorage: 'readonly',
17 + fetch: 'readonly', navigator: 'readonly', console: 'readonly', setTimeout: 'readonly',
18 + clearTimeout: 'readonly', setInterval: 'readonly', URL: 'readonly', URLSearchParams: 'readonly',
19 + Blob: 'readonly', FontFace: 'readonly', IntersectionObserver: 'readonly', TextDecoder: 'readonly',
20 + atob: 'readonly', escape: 'readonly', globalThis: 'readonly',
21 +};
22 +
23 +const nodeGlobals = {
24 + process: 'readonly', console: 'readonly', Buffer: 'readonly', setTimeout: 'readonly',
25 + setInterval: 'readonly', clearTimeout: 'readonly', URL: 'readonly', URLSearchParams: 'readonly',
26 + fetch: 'readonly', TextDecoder: 'readonly', globalThis: 'readonly', Int16Array: 'readonly',
27 +};
28 +
29 +export default [
30 + js.configs.recommended,
31 + {
32 + files: ['src/**/*.mjs', 'cli/**/*.mjs', 'scripts/**/*.mjs', 'test/**/*.mjs'],
33 + languageOptions: { ecmaVersion: 2024, sourceType: 'module', globals: nodeGlobals },
34 + rules: {
35 + 'no-unused-vars': ['error', { argsIgnorePattern: '^_|^req$|^reply$', varsIgnorePattern: '^_' }],
36 + 'no-empty': ['error', { allowEmptyCatch: true }],
37 + },
38 + },
39 + {
40 + files: ['src/web/assets/js/**/*.js'],
41 + languageOptions: { ecmaVersion: 2024, sourceType: 'module', globals: browserGlobals },
42 + rules: {
43 + 'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
44 + 'no-empty': ['error', { allowEmptyCatch: true }],
45 + 'no-unused-expressions': 'off',
46 + },
47 + },
48 +];
modified package-lock.json +1 −0
@@ -41,6 +41,7 @@
41 41 "spbdrive": "cli/spbdrive.mjs"
42 42 },
43 43 "devDependencies": {
44 + "@eslint/js": "^9.39.5",
44 45 "eslint": "^9.30.0",
45 46 "pino-pretty": "^13.0.0",
46 47 "vitest": "^3.2.4"
modified package.json +1 −0
@@ -50,6 +50,7 @@
50 50 "zod": "^3.25.67"
51 51 },
52 52 "devDependencies": {
53 + "@eslint/js": "^9.39.5",
53 54 "eslint": "^9.30.0",
54 55 "pino-pretty": "^13.0.0",
55 56 "vitest": "^3.2.4"
modified scripts/check-headers.mjs +0 −0
modified scripts/check-secrets.mjs +0 −0
modified scripts/inject-headers.mjs +0 −0
modified scripts/reset-password.mjs +0 −0
modified src/api/v1.mjs +2 −2
@@ -26,7 +26,7 @@ import { storageStats } from '../storage/blobs.mjs';
26 26 import {
27 27 ROOT_ID, childrenOf, copyNode, deleteForever, duplicateNode, getNode,
28 28 listDescendantFiles, listTrash, mkdir, moveNode, mustGetNode, pathOf,
29 purgeTrash, recentFiles, renameNode, resolvePath, restoreNode, trashNode,
29 + recentFiles, renameNode, resolvePath, restoreNode, trashNode,
30 30 } from '../storage/nodes.mjs';
31 31 import {
32 32 abortUpload, chunksPresent, completeUpload, getUpload, initUpload, writeChunk,
@@ -334,7 +334,7 @@ export function registerApiV1(app) {
334 334 const name = String(req.body?.name ?? '').trim().slice(0, 40);
335 335 if (!name) return apiError(reply, 400, 'bad_name', 'Tag name required');
336 336 const color = String(req.body?.color ?? '#4f8cff');
337 const info = getDb()
337 + getDb()
338 338 .prepare('INSERT INTO tags (name, color) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET color = excluded.color')
339 339 .run(name, color);
340 340 return { tag: getDb().prepare('SELECT * FROM tags WHERE name = ?').get(name) };
modified src/config.mjs +1 −1
@@ -19,7 +19,7 @@ const schema = z.object({
19 19 SPBDRIVE_PORT: z.coerce.number().int().min(1).max(65535).default(7430),
20 20 SPBDRIVE_HOST: z.string().default('127.0.0.1'),
21 21 SPBDRIVE_PUBLIC_URL: z.string().url().default('https://drive.spboucher.ai'),
22 SPBDRIVE_LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
22 + SPBDRIVE_LOG_LEVEL: z.enum(['silent', 'fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
23 23 SPBDRIVE_TRUST_PROXY: z
24 24 .string()
25 25 .default('true')
modified src/preview/transcode.mjs +1 −3
@@ -18,7 +18,7 @@ import { promisify } from 'node:util';
18 18 import { config } from '../config.mjs';
19 19 import { blobPath } from '../storage/blobs.mjs';
20 20 import { enqueue, jobStatus } from './queue.mjs';
21 import { hasBin, probeMedia } from './thumbs.mjs';
21 +import { hasBin } from './thumbs.mjs';
22 22
23 23 const execFileP = promisify(execFile);
24 24 const TRANSCODE_TIMEOUT = 30 * 60_000; // hostile/huge inputs get killed after 30 min
@@ -96,8 +96,6 @@ export async function getAudioPeaks(sha) {
96 96
97 97 return enqueue(`peaks:${sha}`, async () => {
98 98 if (existsSync(cache)) return JSON.parse(await readFile(cache, 'utf8'));
99 const probe = await probeMedia(sha);
100 const duration = probe?.duration || 1;
101 99 const target = 800;
102 100 // Resample to mono s16le at a rate that yields ~target samples-per-bucket work.
103 101 const rate = 4000;
modified src/web/assets/js/app.js +3 −3
@@ -11,9 +11,9 @@
11 11 * ─────────────────────────────────────────────
12 12 */
13 13
14 import { api, getJSON, post, patch, del } from './api.js';
14 +import { getJSON, post, patch, del } from './api.js';
15 15 import {
16 h, esc, fmtSize, fmtDate, toast, modal, confirmModal, contextMenu, copyText,
16 + h, fmtSize, fmtDate, toast, modal, confirmModal, contextMenu, copyText,
17 17 } from './ui.js';
18 18 import { UI, nodeIcon, folderIcon, fileIcon, EMPTY_ART } from './icons.js';
19 19 import { UploadManager, collectDropped, bindPasteUpload } from './upload.js';
@@ -1089,7 +1089,7 @@ function shareDialog(node) {
1089 1089 { label: 'Close', onClick: () => {} },
1090 1090 {
1091 1091 label: 'Create link', primary: true,
1092 onClick: async (close) => {
1092 + onClick: async () => {
1093 1093 const { share } = await post('/api/v1/shares', {
1094 1094 nodeId: node.id,
1095 1095 expiresAt: expiry.value ? Date.now() + Number(expiry.value) : null,
modified src/web/assets/js/share-page.js +1 −1
@@ -10,7 +10,7 @@
10 10 * ─────────────────────────────────────────────
11 11 */
12 12
13 import { h, fmtSize, fmtDate } from './ui.js';
13 +import { h, fmtSize } from './ui.js';
14 14 import { fileIcon, folderIcon, UI } from './icons.js';
15 15 import { renderPreview, Viewer } from './viewer.js';
16 16
modified src/web/assets/js/viewer.js +4 −4
@@ -10,7 +10,7 @@
10 10 * ─────────────────────────────────────────────
11 11 */
12 12
13 import { h, fmtSize, fmtDuration, toast, copyText } from './ui.js';
13 +import { h, fmtSize, fmtDuration, copyText } from './ui.js';
14 14 import { fileIcon, UI } from './icons.js';
15 15
16 16 /**
@@ -225,7 +225,7 @@ async function readId3(url) {
225 225 const text = enc === 1 || enc === 2
226 226 ? new TextDecoder('utf-16').decode(body.slice(1))
227 227 : (enc === 3 ? td : tl).decode(body.slice(1));
228 out[id === 'TIT2' ? 'title' : 'artist'] = text.replace(/\0+$/, '').replace(/^/, '');
228 + out[id === 'TIT2' ? 'title' : 'artist'] = text.replace(/\0+$/, '').replace(/^\uFEFF/, '');
229 229 }
230 230 if (id === 'APIC' && body.length > 10) {
231 231 let p = 1;
@@ -289,7 +289,7 @@ async function renderPdf(stage, desc, urls, srcOverride, note) {
289 289 const cssScale = parseFloat(canvas.style.width) / page.getViewport({ scale: 1 }).width;
290 290 for (const item of text.items) {
291 291 if (!item.str) continue;
292 const [a, b, , d, e, f] = item.transform;
292 + const [a, b, , , e, f] = item.transform;
293 293 const span = h('span', {
294 294 style: {
295 295 left: `${e * cssScale}px`,
@@ -949,7 +949,7 @@ export class Viewer {
949 949 stream: this.opts.streamUrl(node),
950 950 dl: this.opts.dlUrl ? this.opts.dlUrl(node) : null,
951 951 });
952 } catch (err) {
952 + } catch {
953 953 this.stage.innerHTML = '';
954 954 renderFallback(this.stage, { name: node.name, size: node.size, mime: node.mime, icon: node.icon }, { dl: this.opts.dlUrl?.(node) ?? null });
955 955 }
added test/e2e.test.mjs +189 −0
@@ -0,0 +1,189 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Drive — Personal Cloud Drive
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : test/e2e.test.mjs
8 + * Purpose : E2E — boot server, login, chunk-upload, thumbnail,
9 + * password-protected share, download through it
10 + * License : MIT © Simon-Pierre Boucher
11 + * ─────────────────────────────────────────────
12 + */
13 +
14 +import { describe, it, expect, beforeAll, afterAll } from 'vitest';
15 +import { spawn } from 'node:child_process';
16 +import { mkdtempSync } from 'node:fs';
17 +import os from 'node:os';
18 +import path from 'node:path';
19 +
20 +const PORT = 7442;
21 +const B = `http://127.0.0.1:${PORT}`;
22 +const PASSWORD = 'e2e-password-1';
23 +let server;
24 +let cookie;
25 +
26 +const H = () => ({ cookie, 'x-spbdrive-csrf': '1' });
27 +const HJ = () => ({ ...H(), 'content-type': 'application/json' });
28 +
29 +async function waitForServer() {
30 + for (let i = 0; i < 60; i += 1) {
31 + try {
32 + const res = await fetch(`${B}/healthz`);
33 + if (res.ok) return;
34 + } catch { /* not up yet */ }
35 + await new Promise((r) => setTimeout(r, 400));
36 + }
37 + throw new Error('server did not boot');
38 +}
39 +
40 +beforeAll(async () => {
41 + server = spawn(process.execPath, ['src/server.mjs'], {
42 + env: {
43 + ...process.env,
44 + SPBDRIVE_DATA_DIR: mkdtempSync(path.join(os.tmpdir(), 'spbdrive-e2e-')),
45 + SPBDRIVE_PORT: String(PORT),
46 + SPBDRIVE_PUBLIC_URL: B,
47 + SPBDRIVE_LOG_LEVEL: 'silent',
48 + SPBDRIVE_BOOTSTRAP_PASSWORD: PASSWORD,
49 + },
50 + stdio: 'ignore',
51 + });
52 + await waitForServer();
53 +}, 40_000);
54 +
55 +afterAll(() => {
56 + server?.kill('SIGTERM');
57 +});
58 +
59 +describe('e2e: upload → thumbnail → protected share → download', () => {
60 + let nodeId;
61 + let shareToken;
62 + let payload;
63 +
64 + it('logs in and receives a session cookie', async () => {
65 + const res = await fetch(`${B}/api/v1/auth/login`, {
66 + method: 'POST',
67 + headers: { 'content-type': 'application/json' },
68 + body: JSON.stringify({ password: PASSWORD }),
69 + });
70 + expect(res.status).toBe(200);
71 + cookie = res.headers.get('set-cookie').split(';')[0];
72 + expect(cookie).toMatch(/^spbdrive_sid=/);
73 + });
74 +
75 + it('rejects unauthenticated API access', async () => {
76 + const res = await fetch(`${B}/api/v1/me`);
77 + expect(res.status).toBe(401);
78 + });
79 +
80 + it('chunk-uploads a multi-chunk PNG', async () => {
81 + const sharp = (await import('sharp')).default;
82 + payload = await sharp({
83 + create: { width: 1600, height: 1200, channels: 3, noise: { type: 'gaussian', mean: 128, sigma: 40 } },
84 + }).png({ compressionLevel: 0 }).toBuffer();
85 + expect(payload.length).toBeGreaterThan(1024 * 1024);
86 +
87 + const initRes = await fetch(`${B}/api/v1/upload/init`, {
88 + method: 'POST', headers: HJ(),
89 + body: JSON.stringify({ parentId: 1, name: 'e2e.png', size: payload.length }),
90 + });
91 + const init = await initRes.json();
92 + // Force multi-chunk exercise by splitting manually at 1 MB inside the 8 MB chunk API? No —
93 + // upload API chunk size is fixed; send each declared chunk.
94 + for (let n = 0; n < init.nChunks; n += 1) {
95 + const slice = payload.subarray(n * init.chunkSize, Math.min((n + 1) * init.chunkSize, payload.length));
96 + const res = await fetch(`${B}/api/v1/upload/${init.uploadId}/chunk/${n}`, {
97 + method: 'PUT',
98 + headers: { ...H(), 'content-type': 'application/octet-stream' },
99 + body: slice,
100 + });
101 + expect(res.status).toBe(200);
102 + }
103 + const doneRes = await fetch(`${B}/api/v1/upload/${init.uploadId}/complete`, {
104 + method: 'POST', headers: HJ(), body: JSON.stringify({}),
105 + });
106 + const done = await doneRes.json();
107 + expect(doneRes.status).toBe(200);
108 + expect(done.node.size).toBe(payload.length);
109 + nodeId = done.node.id;
110 + }, 30_000);
111 +
112 + it('serves a webp thumbnail', async () => {
113 + const res = await fetch(`${B}/thumb/${nodeId}?size=256`, { headers: { cookie } });
114 + expect(res.status).toBe(200);
115 + expect(res.headers.get('content-type')).toBe('image/webp');
116 + }, 20_000);
117 +
118 + it('honors Range requests on /stream', async () => {
119 + const res = await fetch(`${B}/stream/${nodeId}`, { headers: { cookie, range: 'bytes=10-19' } });
120 + expect(res.status).toBe(206);
121 + expect(res.headers.get('content-range')).toBe(`bytes 10-19/${payload.length}`);
122 + const buf = Buffer.from(await res.arrayBuffer());
123 + expect(buf.equals(payload.subarray(10, 20))).toBe(true);
124 + });
125 +
126 + it('creates a password-protected share', async () => {
127 + const res = await fetch(`${B}/api/v1/shares`, {
128 + method: 'POST', headers: HJ(),
129 + body: JSON.stringify({ nodeId, password: 'sharepw', expiresAt: Date.now() + 3_600_000 }),
130 + });
131 + const { share } = await res.json();
132 + expect(share.token).toHaveLength(10);
133 + shareToken = share.token;
134 + });
135 +
136 + it('gates the public page, unlocks with the password, downloads the bytes', async () => {
137 + // Gate
138 + let res = await fetch(`${B}/s/${shareToken}`);
139 + const gate = await res.text();
140 + expect(gate).toContain('Protected link');
141 + const csrf = res.headers.get('set-cookie').match(/spbdrive_csrf=([a-f0-9]+)/)[1];
142 +
143 + // Wrong password stays gated
144 + res = await fetch(`${B}/s/${shareToken}/unlock`, {
145 + method: 'POST', redirect: 'manual',
146 + headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: `spbdrive_csrf=${csrf}` },
147 + body: `password=wrong&_csrf=${csrf}`,
148 + });
149 + expect(res.status).toBe(401);
150 +
151 + // Right password → signed gate cookie
152 + res = await fetch(`${B}/s/${shareToken}/unlock`, {
153 + method: 'POST', redirect: 'manual',
154 + headers: { 'content-type': 'application/x-www-form-urlencoded', cookie: `spbdrive_csrf=${csrf}` },
155 + body: `password=sharepw&_csrf=${csrf}`,
156 + });
157 + expect(res.status).toBe(302);
158 + const gateCookie = res.headers.get('set-cookie').split(';')[0];
159 +
160 + // Download through the share
161 + res = await fetch(`${B}/s/${shareToken}/dl`, { headers: { cookie: gateCookie } });
162 + expect(res.status).toBe(200);
163 + const buf = Buffer.from(await res.arrayBuffer());
164 + expect(buf.equals(payload)).toBe(true);
165 +
166 + // Revoke kills it instantly, and the error page leaks no filename
167 + const { shares } = await (await fetch(`${B}/api/v1/shares`, { headers: { cookie } })).json();
168 + const mine = shares.find((s) => s.token === shareToken);
169 + await fetch(`${B}/api/v1/shares/${mine.id}`, { method: 'DELETE', headers: H() });
170 + res = await fetch(`${B}/s/${shareToken}`, { headers: { cookie: gateCookie } });
171 + expect(res.status).toBe(410);
172 + const dead = await res.text();
173 + expect(dead).not.toContain('e2e.png');
174 + });
175 +
176 + it('locks out after 5 wrong passwords', async () => {
177 + for (let i = 0; i < 5; i += 1) {
178 + await fetch(`${B}/api/v1/auth/login`, {
179 + method: 'POST', headers: { 'content-type': 'application/json' },
180 + body: JSON.stringify({ password: 'bad-guess' }),
181 + });
182 + }
183 + const res = await fetch(`${B}/api/v1/auth/login`, {
184 + method: 'POST', headers: { 'content-type': 'application/json' },
185 + body: JSON.stringify({ password: PASSWORD }),
186 + });
187 + expect(res.status).toBe(429);
188 + });
189 +});
added test/setup.mjs +22 −0
@@ -0,0 +1,22 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Drive — Personal Cloud Drive
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : test/setup.mjs
8 + * Purpose : Test bootstrap — isolated temp data dir before config loads
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { mkdtempSync } from 'node:fs';
14 +import os from 'node:os';
15 +import path from 'node:path';
16 +
17 +// Must run before src/config.mjs is imported anywhere.
18 +process.env.SPBDRIVE_DATA_DIR = mkdtempSync(path.join(os.tmpdir(), 'spbdrive-vitest-'));
19 +process.env.SPBDRIVE_PORT = '7439';
20 +process.env.SPBDRIVE_PUBLIC_URL = 'http://127.0.0.1:7439';
21 +process.env.SPBDRIVE_LOG_LEVEL = 'silent';
22 +process.env.SPBDRIVE_BOOTSTRAP_PASSWORD = 'vitest-password';
added test/unit.test.mjs +158 −0
@@ -0,0 +1,158 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Drive — Personal Cloud Drive
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : test/unit.test.mjs
8 + * Purpose : Unit tests — blob store, tree ops, share logic, range parser
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import './setup.mjs';
14 +import { describe, it, expect, beforeAll } from 'vitest';
15 +import { Readable } from 'node:stream';
16 +import { readFileSync } from 'node:fs';
17 +
18 +import { ensureDataDirs } from '../src/config.mjs';
19 +import { getDb } from '../src/db/db.mjs';
20 +import { putBlob, blobPath, refBlob, unrefBlob, gcBlobs } from '../src/storage/blobs.mjs';
21 +import {
22 + mkdir, createFileNode, renameNode, moveNode, copyNode, trashNode, restoreNode,
23 + deleteForever, uniqueName, validateName, isDescendant, listDescendantFiles, ROOT_ID,
24 +} from '../src/storage/nodes.mjs';
25 +import {
26 + createShare, getShareByToken, shareValidity, verifySharePassword, revokeShare,
27 +} from '../src/shares/shares.mjs';
28 +import { parseRange } from '../src/web/http-helpers.mjs';
29 +import { searchNodes } from '../src/search/search.mjs';
30 +
31 +beforeAll(() => {
32 + ensureDataDirs();
33 + getDb();
34 +});
35 +
36 +const put = (text) => putBlob(Readable.from([Buffer.from(text)]));
37 +
38 +describe('blob store (CAS)', () => {
39 + it('stores by sha256 and dedups identical content', async () => {
40 + const a = await put('same-bytes');
41 + const b = await put('same-bytes');
42 + expect(a.sha).toBe(b.sha);
43 + expect(b.deduped).toBe(true);
44 + expect(readFileSync(blobPath(a.sha), 'utf8')).toBe('same-bytes');
45 + });
46 +
47 + it('GC removes blobs only at refcount zero', async () => {
48 + const { sha } = await put('gc-me');
49 + refBlob(sha);
50 + await gcBlobs();
51 + expect(() => readFileSync(blobPath(sha))).not.toThrow();
52 + unrefBlob(sha);
53 + await gcBlobs();
54 + expect(() => readFileSync(blobPath(sha))).toThrow();
55 + });
56 +});
57 +
58 +describe('tree ops', () => {
59 + it('rejects path-traversal and invalid names', () => {
60 + for (const bad of ['..', '.', 'a/b', 'a\\b', 'x\0y', '']) {
61 + expect(() => validateName(bad), bad).toThrow();
62 + }
63 + expect(validateName('notes 2026.md')).toBe('notes 2026.md');
64 + });
65 +
66 + it('mkdir / rename / move with cycle protection', async () => {
67 + const a = mkdir(ROOT_ID, 'A');
68 + const b = mkdir(a.id, 'B');
69 + expect(isDescendant(b.id, a.id)).toBe(true);
70 + expect(() => moveNode(a.id, b.id)).toThrow(/into itself/);
71 + const renamed = renameNode(b.id, 'B2');
72 + expect(renamed.name).toBe('B2');
73 + });
74 +
75 + it('keep-both conflicts generate name-2 style suffixes', async () => {
76 + const dir = mkdir(ROOT_ID, 'conflicts');
77 + const { sha, size } = await put('v1');
78 + createFileNode(dir.id, 'file.txt', { sha, size, mime: 'text/plain' });
79 + expect(uniqueName(dir.id, 'file.txt')).toBe('file-2.txt');
80 + const second = createFileNode(dir.id, 'file.txt', { sha, size, mime: 'text/plain' });
81 + expect(second.name).toBe('file-2.txt');
82 + });
83 +
84 + it('copy shares blobs via refcount; deep-copies folders', async () => {
85 + const src = mkdir(ROOT_ID, 'copy-src');
86 + const inner = mkdir(src.id, 'inner');
87 + const { sha, size } = await put('copy-payload');
88 + createFileNode(inner.id, 'deep.txt', { sha, size, mime: 'text/plain' });
89 + const dst = mkdir(ROOT_ID, 'copy-dst');
90 + const copied = copyNode(src.id, dst.id);
91 + const files = listDescendantFiles(copied.id);
92 + expect(files).toHaveLength(1);
93 + expect(files[0].relPath).toBe('inner/deep.txt');
94 + const refs = getDb().prepare('SELECT refcount FROM blobs WHERE sha = ?').get(sha);
95 + expect(refs.refcount).toBe(2);
96 + });
97 +
98 + it('trash remembers origin; restore returns there; delete forever unrefs', async () => {
99 + const home = mkdir(ROOT_ID, 'trash-home');
100 + const { sha, size } = await put('trashable');
101 + const file = createFileNode(home.id, 'gone.txt', { sha, size, mime: 'text/plain' });
102 + trashNode(file.id);
103 + const back = restoreNode(file.id);
104 + expect(back.parent_id).toBe(home.id);
105 + deleteForever(file.id);
106 + const refs = getDb().prepare('SELECT refcount FROM blobs WHERE sha = ?').get(sha);
107 + expect(refs.refcount).toBe(0);
108 + });
109 +});
110 +
111 +describe('shares', () => {
112 + it('creates 10-char base58 tokens and validates lifecycle', async () => {
113 + const folder = mkdir(ROOT_ID, 'share-me');
114 + const share = await createShare(folder.id, {});
115 + expect(share.token).toMatch(/^[1-9A-HJ-NP-Za-km-z]{10}$/);
116 + expect(shareValidity(share).ok).toBe(true);
117 +
118 + const expired = await createShare(folder.id, { expiresAt: Date.now() - 1000 });
119 + expect(shareValidity(expired)).toEqual({ ok: false, reason: 'expired' });
120 +
121 + revokeShare(share.id);
122 + expect(shareValidity(getShareByToken(share.token))).toEqual({ ok: false, reason: 'revoked' });
123 + });
124 +
125 + it('argon2 share passwords verify correctly', async () => {
126 + const folder = mkdir(ROOT_ID, 'pw-share');
127 + const share = await createShare(folder.id, { password: 'secret42' });
128 + expect(share.password_hash).toMatch(/^\$argon2id\$/);
129 + expect(await verifySharePassword(share, 'secret42')).toBe(true);
130 + expect(await verifySharePassword(share, 'nope')).toBe(false);
131 + });
132 +
133 + it('max downloads exhausts the link', async () => {
134 + const folder = mkdir(ROOT_ID, 'maxdl-share');
135 + const share = await createShare(folder.id, { maxDownloads: 0 });
136 + expect(shareValidity(share)).toEqual({ ok: false, reason: 'exhausted' });
137 + });
138 +});
139 +
140 +describe('range parser', () => {
141 + it('handles normal, open-ended, suffix, and invalid ranges', () => {
142 + expect(parseRange('bytes=0-99', 1000)).toEqual({ start: 0, end: 99 });
143 + expect(parseRange('bytes=500-', 1000)).toEqual({ start: 500, end: 999 });
144 + expect(parseRange('bytes=-100', 1000)).toEqual({ start: 900, end: 999 });
145 + expect(parseRange('bytes=999-5000', 1000)).toEqual({ start: 999, end: 999 });
146 + expect(parseRange('bytes=1000-', 1000)).toBeNull();
147 + expect(parseRange('bytes=abc', 1000)).toBeNull();
148 + expect(parseRange(undefined, 1000)).toBeNull();
149 + });
150 +});
151 +
152 +describe('search', () => {
153 + it('finds nodes by name via FTS', async () => {
154 + mkdir(ROOT_ID, 'quarterly-report-folder');
155 + const rows = searchNodes('quarterly');
156 + expect(rows.some((r) => r.name === 'quarterly-report-folder')).toBe(true);
157 + });
158 +});
159