feat: tests (unit+e2e), eslint, deploy artifacts (setup-m3u96b, pm2, ngrok, backup), README
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 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 | ||