diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 29dfeac..9d47561 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,29 @@ "Bash(kill 128755)", "Bash(echo \"exit=$?\")", "Bash(npm view *)", - "Bash(echo \"--- exit $? ---\")" + "Bash(echo \"--- exit $? ---\")", + "Bash(rm -rf /tmp/nas-test)", + "Bash(mkdir -p \"/tmp/nas-test/AAA = ACTIVE/Test Biz GmbH/docs/subdir\")", + "Bash(mkdir -p \"/tmp/nas-test/AAA = ACTIVE/Test Biz GmbH/.git\")", + "Bash(ln -s /etc/passwd \"/tmp/nas-test/AAA = ACTIVE/Test Biz GmbH/escape.pdf\")", + "Bash(mkdir -p \"/tmp/nas-test/AAA = SOLD\" \"/tmp/nas-test/AAA = INACTIVE\")", + "Bash(break)", + "Bash(node -pe 'JSON.parse\\(require\\(\"fs\"\\).readFileSync\\(0\\)\\).[0].id')", + "Bash(curl -s localhost:8090/api/staff)", + "Bash(pkill -f 'tsx.*server')", + "Bash(curl -s -m 2 -o /dev/null -w '%{http_code}\\\\n' localhost:8090/api/health)", + "Bash(/tmp/claude-1000/-home-aknuth-git-bizmatch-app/4b67ffa5-f538-477a-ae54-6d68ec7ee19c/scratchpad/run-server.sh *)", + "Bash(docker run *)", + "Skill(claude-in-chrome)", + "Bash(/tmp/claude-1000/-home-aknuth-git-bizmatch-app/4b67ffa5-f538-477a-ae54-6d68ec7ee19c/scratchpad/run-vite.sh *)", + "Bash(curl -s -m 2 localhost:8090/api/health)", + "Bash(ps -eo pid,etime,cmd)", + "Bash(curl -s localhost:8090/viewer/pdf_viewer.js)", + "Bash(echo \"QC occurrences served: $?\")", + "Bash(kill 29669 29703 29714)", + "Bash(curl -s -m 2 -o /dev/null -w 'server: %{http_code}\\\\n' localhost:8090/api/health)", + "Bash(git check-ignore *)", + "Bash(git add *)" ] } } diff --git a/.dockerignore b/.dockerignore index af59045..3fd0c84 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,4 @@ web/node_modules web/dist .env .git +web/public/pdfjs diff --git a/.gitignore b/.gitignore index 2068d28..dc73701 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ dist/ web/node_modules/ web/dist/ .env +web/public/pdfjs/ diff --git a/README.md b/README.md index 6deda93..640ddb4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Module 1: foundation (Docker Compose, PostgreSQL, schema, migrations, login). Module 2: business scan (NAS -> DB) and the first UI. +Module 3: recursive file listing, PDF streaming from the NAS and the ported +pdf.js viewer. The UI and all domain constants are English. @@ -83,7 +85,7 @@ directory aborts the scan with an error naming the path. 3. Copy the project folder to the AI machine, run `docker compose up -d --build` 4. Restore the dump: `docker compose exec -T db psql -U bizmatch bizmatch < backup.sql` -## API (as of module 2) +## API (as of module 3) | Method | Path | Purpose | Session | | ------ | --------------------------- | ------------------------------------------------ | ------- | @@ -96,22 +98,74 @@ directory aborts the scan with an error naming the path. | POST | /api/businesses/scan | scan the NAS → `{scanned, inserted, updated, missing}` | yes | | GET | /api/businesses | list `?status=&search=` + counts per status | yes | | GET | /api/businesses/:id | single business incl. `nas_path` | yes | -| GET | /api/businesses/:id/files | live directory listing (PDFs first) | yes | +| GET | /api/businesses/:id/files | recursive listing, max depth 3 (PDFs first) | yes | +| GET | /api/businesses/:id/file | stream one file, `?path=` | yes | Everything except health, staff (GET+POST) and login requires the session cookie; without it the API answers `401`. +### File listing and streaming + +`/files` walks the business directory recursively (max depth 3), skipping +dotfiles, dot-directories and symlinks, and returns +`{ path, size, mtime }` with `path` relative to the business directory and +always posix-separated. PDFs come first, then everything else, each group +alphabetical. + +`/file?path=…` streams one of those files straight from disk +(`createReadStream`, never buffered): + +* the path is resolved against `nas_path` and then `realpath`-validated to be + inside `realpath(business dir)`. Absolute paths, `..`, leading dots, empty + paths and symlinks pointing out of the tree get `400`; a missing file `404`. +* single-range HTTP `Range` requests answer `206` with `Content-Range`, + unsatisfiable ones `416`. +* `ETag` is derived from mtime + size, `If-None-Match` answers `304`. +* `.pdf` is served as `application/pdf` (inline), anything else as + `application/octet-stream` with `Content-Disposition: attachment`. + ## Frontend `web/` is a Vite + React + TypeScript app with Tailwind v4 (no router, no state library). Views: login ("Who is working?"), business list (tabs with counts, -search, "Scan NAS now") and business detail (status badge, live file list). +search, "Scan NAS now") and business detail — a master-detail split filling the +viewport: file table left, PDF viewer right. In dev, Vite proxies `/api` to `http://localhost:8090`. In production the Fastify app serves `web/dist` via `@fastify/static` with an SPA fallback to `index.html` for all non-`/api` routes; the Dockerfile builds the frontend in its own stage and copies `web/dist` into the runtime image. +### PDF viewer + +The viewer is the proven one from the phase-1 Deno desktop app (see +`viewer-phase1/`), ported nearly byte-identical. It lives in +`web/public/viewer/` as plain, unbundled ES modules — Vite serves `public/` +as-is, so the same files work in dev and prod. The React app embeds it in an +`