This commit is contained in:
2026-07-26 17:45:58 -05:00
parent 5830cd7ab5
commit b0e677d298
15 changed files with 1016 additions and 68 deletions

View File

@@ -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 *)"
]
}
}

View File

@@ -4,3 +4,4 @@ web/node_modules
web/dist
.env
.git
web/public/pdfjs

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@ dist/
web/node_modules/
web/dist/
.env
web/public/pdfjs/

View File

@@ -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=<relative>` | 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
`<iframe>`:
```
/viewer/index.html?file=<urlencoded /api/businesses/:id/file?path=...>
```
The page refuses any `file` value that is not a root-relative `/api/` path, and
the iframe is same-origin, so the normal session cookie authenticates it.
`web/public/pdfjs/` holds the pdf.js runtime, copied out of
`node_modules/pdfjs-dist` (pinned to exactly 6.1.200) by
`web/scripts/copy-pdfjs.mjs`, which runs on `predev` and `prebuild` — also
inside the Docker web stage. The directory is generated and git-ignored:
```
web/public/pdfjs/legacy/ pdf.min.mjs + pdf.worker.min.mjs
web/public/pdfjs/wasm/ CCITT-G4/JBIG2, JPEG2000 and ICC decoders
web/public/pdfjs/standard_fonts/ standardFontDataUrl
web/public/pdfjs/iccs/ iccUrl
```
The `wasm/` directory is what makes scanned B/W pages render at all; without it
pdf.js fails the decoders silently and shows blank white canvases.
## Structure
```
@@ -120,10 +174,14 @@ src/
config.ts env configuration
db.ts pg pool + query helpers
migrate.ts migration runner (transactional, advisory lock)
business-scan.ts NAS scan + directory listing
server.ts Fastify app (health, staff, login, businesses, static)
business-scan.ts NAS scan, recursive listing, safe file path resolution
server.ts Fastify app (health, staff, login, businesses, file, static)
web/
scripts/copy-pdfjs.mjs pdfjs-dist -> public/pdfjs/ (predev + prebuild)
public/viewer/ standalone, unbundled pdf.js viewer page
public/pdfjs/ generated, git-ignored pdf.js runtime
src/api.ts typed API client
src/App.tsx session gate + view switch
src/views/ Login, Businesses, BusinessDetail
viewer-phase1/ reference copy of the phase-1 desktop viewer
```

View File

@@ -1,4 +1,4 @@
import { readdir, stat } from 'node:fs/promises';
import { readdir, realpath, stat } from 'node:fs/promises';
import path from 'node:path';
import { config } from './config.js';
import { query } from './db.js';
@@ -103,23 +103,101 @@ export async function scanBusinesses(
}
export interface BusinessFile {
name: string;
/** Relative to the business directory, always with posix separators. */
path: string;
size: number;
mtime: string;
}
/** Live, non-recursive listing of a business directory: PDFs first, then the rest, alphabetical. */
export async function listBusinessFiles(nasPath: string): Promise<BusinessFile[]> {
const entries = await readdir(nasPath, { withFileTypes: true });
const files: BusinessFile[] = [];
/** How deep below the business directory the listing descends. */
const MAX_DEPTH = 3;
async function walk(root: string, relative: string, depth: number, out: BusinessFile[]) {
const entries = await readdir(path.join(root, relative), { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
const info = await stat(path.join(nasPath, entry.name));
files.push({ name: entry.name, size: info.size, mtime: info.mtime.toISOString() });
if (entry.name.startsWith('.')) continue; // dotfiles and dot-directories
const rel = relative ? `${relative}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (depth < MAX_DEPTH) await walk(root, rel, depth + 1, out);
continue;
}
if (!entry.isFile()) continue; // symlinks, sockets, ... are not listed
const info = await stat(path.join(root, rel));
out.push({ path: rel, size: info.size, mtime: info.mtime.toISOString() });
}
const isPdf = (name: string) => name.toLowerCase().endsWith('.pdf');
}
/**
* Live, recursive listing of a business directory (max depth 3): PDFs first,
* then the rest, each group alphabetical by relative path.
*/
export async function listBusinessFiles(nasPath: string): Promise<BusinessFile[]> {
const files: BusinessFile[] = [];
await walk(nasPath, '', 1, files);
const isPdf = (p: string) => p.toLowerCase().endsWith('.pdf');
return files.sort((a, b) => {
if (isPdf(a.name) !== isPdf(b.name)) return isPdf(a.name) ? -1 : 1;
return a.name.localeCompare(b.name);
if (isPdf(a.path) !== isPdf(b.path)) return isPdf(a.path) ? -1 : 1;
return a.path.localeCompare(b.path);
});
}
/** Thrown by resolveBusinessFile; `status` is the HTTP status the route should answer with. */
export class BusinessFileError extends Error {
constructor(readonly status: 400 | 404, message: string) {
super(message);
}
}
export interface ResolvedFile {
/** Absolute, symlink-resolved path — safe to open. */
absPath: string;
size: number;
mtimeMs: number;
}
/**
* Turns a client-supplied relative path into an absolute path inside the business
* directory. Rejects empty/absolute paths and "..", then realpath-validates that
* the result really is below realpath(business directory), which also catches
* symlinks pointing out of the NAS tree.
*/
export async function resolveBusinessFile(
nasPath: string,
relPath: unknown,
): Promise<ResolvedFile> {
if (typeof relPath !== 'string' || relPath === '') {
throw new BusinessFileError(400, 'path is missing');
}
if (relPath.includes('\0')) throw new BusinessFileError(400, 'invalid path');
if (relPath.startsWith('/') || relPath.startsWith('\\') || /^[a-zA-Z]:/.test(relPath)) {
throw new BusinessFileError(400, 'absolute paths are not allowed');
}
// Both separators, so a "..\\.." variant cannot slip past on any platform.
// Rejecting every leading dot covers "." and ".." and keeps the endpoint in
// sync with listBusinessFiles(), which skips dotfiles and dot-directories.
const segments = relPath.split(/[/\\]/);
if (segments.some((s) => s === '' || s.startsWith('.'))) {
throw new BusinessFileError(400, 'invalid path segment');
}
let base: string;
try {
base = await realpath(nasPath);
} catch {
throw new BusinessFileError(404, 'business directory not found');
}
let absPath: string;
try {
absPath = await realpath(path.join(base, ...segments));
} catch {
throw new BusinessFileError(404, 'file not found');
}
if (absPath !== base && !absPath.startsWith(base + path.sep)) {
throw new BusinessFileError(400, 'path escapes the business directory');
}
const info = await stat(absPath);
if (!info.isFile()) throw new BusinessFileError(404, 'file not found');
return { absPath, size: info.size, mtimeMs: Math.floor(info.mtimeMs) };
}

View File

@@ -1,4 +1,4 @@
import { existsSync } from 'node:fs';
import { createReadStream, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import Fastify from 'fastify';
@@ -7,7 +7,12 @@ import fastifyStatic from '@fastify/static';
import { config } from './config.js';
import { pool, query, queryOne } from './db.js';
import { runMigrations } from './migrate.js';
import { listBusinessFiles, scanBusinesses } from './business-scan.js';
import {
BusinessFileError,
listBusinessFiles,
resolveBusinessFile,
scanBusinesses,
} from './business-scan.js';
interface Staff {
id: string;
@@ -169,6 +174,83 @@ app.get<{ Params: { id: string } }>('/api/businesses/:id/files', async (req, rep
}
});
/** Parses a single-range `Range` header. null = ignore, 'invalid' = 416. */
function parseRange(
header: string | undefined,
size: number,
): { start: number; end: number } | null | 'invalid' {
if (!header) return null;
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!match) return null; // multi-range or garbage: answer with the full body
const [, rawStart, rawEnd] = match;
if (rawStart === '' && rawEnd === '') return 'invalid';
let start: number;
let end: number;
if (rawStart === '') {
// suffix range: the last N bytes
const suffix = Number(rawEnd);
if (suffix === 0) return 'invalid';
start = Math.max(0, size - suffix);
end = size - 1;
} else {
start = Number(rawStart);
end = rawEnd === '' ? size - 1 : Math.min(Number(rawEnd), size - 1);
}
if (start > end || start >= size) return 'invalid';
return { start, end };
}
app.get<{ Params: { id: string }; Querystring: { path?: string } }>(
'/api/businesses/:id/file',
async (req, reply) => {
const row = await queryOne<Business>('SELECT * FROM business WHERE id = $1', [req.params.id]);
if (!row) return reply.code(404).send({ error: 'business not found' });
let file;
try {
file = await resolveBusinessFile(row.nas_path, req.query.path);
} catch (err) {
if (err instanceof BusinessFileError) {
return reply.code(err.status).send({ error: err.message });
}
throw err;
}
const etag = `"${file.mtimeMs.toString(16)}-${file.size.toString(16)}"`;
reply.header('ETag', etag);
reply.header('Accept-Ranges', 'bytes');
reply.header('Cache-Control', 'private, max-age=0, must-revalidate');
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch && ifNoneMatch.split(',').some((t) => t.trim().replace(/^W\//, '') === etag)) {
return reply.code(304).send();
}
const range = parseRange(req.headers.range, file.size);
if (range === 'invalid') {
reply.header('Content-Range', `bytes */${file.size}`);
return reply.code(416).send({ error: 'range not satisfiable' });
}
const name = (req.query.path ?? '').split('/').pop() ?? 'file';
const isPdf = name.toLowerCase().endsWith('.pdf');
const filenameStar = `filename*=UTF-8''${encodeURIComponent(name)}`;
reply.header('Content-Type', isPdf ? 'application/pdf' : 'application/octet-stream');
reply.header('Content-Disposition', `${isPdf ? 'inline' : 'attachment'}; ${filenameStar}`);
if (range) {
reply.code(206);
reply.header('Content-Range', `bytes ${range.start}-${range.end}/${file.size}`);
reply.header('Content-Length', range.end - range.start + 1);
return reply.send(createReadStream(file.absPath, { start: range.start, end: range.end }));
}
reply.header('Content-Length', file.size);
return reply.send(createReadStream(file.absPath));
},
);
// --------------------------------------------------------------- Static
// Serves the built frontend in production; SPA fallback for non-/api routes.
const WEB_DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web', 'dist');

View File

@@ -4,7 +4,10 @@
"private": true,
"type": "module",
"scripts": {
"copy-pdfjs": "node scripts/copy-pdfjs.mjs",
"predev": "npm run copy-pdfjs",
"dev": "vite",
"prebuild": "npm run copy-pdfjs",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"

View File

@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PDF</title>
<link rel="stylesheet" href="/viewer/viewer.css">
<script type="module">
// Vite serves public/ verbatim, so this page is unbundled in dev and prod alike.
console.log("[BizMatch] PDF.js frontend build: legacy");
import * as pdfjsLib from "/pdfjs/legacy/pdf.min.mjs";
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdfjs/legacy/pdf.worker.min.mjs";
globalThis.pdfjsLib = pdfjsLib;
</script>
<script type="module" src="/viewer/pdf_viewer.js"></script>
</head>
<body>
<section class="viewer">
<div id="pdfViewer"></div>
<div id="pdfMessage">Select a document</div>
</section>
<script type="module" src="/viewer/viewer.js"></script>
</body>
</html>

View File

@@ -0,0 +1,385 @@
// PDF.js viewer using pdfjs-dist 6.1.200 legacy build.
// Depends on global pdfjsLib (loaded in index.html).
const ZOOM_MIN = 0.25;
const ZOOM_MAX = 5;
const ZOOM_STEP = 1.2; // multiplicative
class PdfViewer {
constructor(container, options = {}) {
this.container = container;
this.generation = 0;
this.loadingTask = null;
this.doc = null;
this.numPages = 0;
this.renderTasks = new Set();
this.lastUrl = null;
this.lastSize = null;
// multiplier on fit-to-width (1 = fit width); can be seeded from the
// previous viewer instance so zoom survives switching documents
this.zoom = Math.min(
ZOOM_MAX,
Math.max(ZOOM_MIN, options.initialZoom ?? 1),
);
this._lastRenderWidth = 0;
this._resizeTimer = null;
this._zoomTimer = null;
this.resizeObserver = null;
this._createDOM();
this._updateZoomLabel();
this._observeResize();
}
_createDOM() {
// The pages container is always visible (never hidden).
// Loading and error states are overlays on top.
this.container.innerHTML = `
<div class="pdf-v-toolbar" hidden>
<button class="pdf-v-zoom-out" title="Zoom out (Ctrl+mouse wheel)">\u2212</button>
<button class="pdf-v-zoom-label" title="Fit to page width">100%</button>
<button class="pdf-v-zoom-in" title="Zoom in (Ctrl+mouse wheel)">+</button>
</div>
<div class="pdf-v-pages"></div>
<div class="pdf-v-overlay pdf-v-loading" hidden>Loading PDF\u2026</div>
<div class="pdf-v-overlay pdf-v-error" hidden>
<div class="pdf-v-error-msg"></div>
<button class="pdf-v-retry">Retry</button>
</div>
`;
this.toolbar = this.container.querySelector(".pdf-v-toolbar");
this.zoomLabel = this.container.querySelector(".pdf-v-zoom-label");
this.pagesDiv = this.container.querySelector(".pdf-v-pages");
this.loadingDiv = this.container.querySelector(".pdf-v-loading");
this.errorDiv = this.container.querySelector(".pdf-v-error");
this.errorMsg = this.container.querySelector(".pdf-v-error-msg");
this.retryBtn = this.container.querySelector(".pdf-v-retry");
this.retryBtn.addEventListener("click", () => {
if (this.lastUrl != null) {
this._setState("loading");
this._loadInternal(this.lastUrl, this.lastSize);
}
});
this.container.querySelector(".pdf-v-zoom-in").addEventListener(
"click",
() => this.setZoom(this.zoom * ZOOM_STEP),
);
this.container.querySelector(".pdf-v-zoom-out").addEventListener(
"click",
() => this.setZoom(this.zoom / ZOOM_STEP),
);
// Clicking the percentage resets to fit-width.
this.zoomLabel.addEventListener("click", () => this.setZoom(1));
// Ctrl + mouse wheel zoom, like in browsers / PDF readers.
this.pagesDiv.addEventListener("wheel", (e) => {
if (!e.ctrlKey) return;
e.preventDefault();
this.setZoom(this.zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1));
}, { passive: false });
}
// ---- Zoom ----
setZoom(zoom) {
const clamped = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom));
if (Math.abs(clamped - this.zoom) < 0.001) return;
this.zoom = clamped;
this._updateZoomLabel();
if (!this.doc) return;
// Debounce so repeated +/+/+ or wheel ticks trigger one re-render.
clearTimeout(this._zoomTimer);
this._zoomTimer = setTimeout(() => this._rerender(), 120);
}
_updateZoomLabel() {
this.zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
}
// ---- Resize ----
_observeResize() {
if (typeof ResizeObserver === "undefined") return;
this.resizeObserver = new ResizeObserver(() => {
if (!this.doc) return;
const width = this._renderWidth();
if (Math.abs(width - this._lastRenderWidth) < 2) return;
clearTimeout(this._resizeTimer);
this._resizeTimer = setTimeout(() => {
console.log(
`[BizMatch] PDF viewer resized, re-rendering at ${this._renderWidth()}px`,
);
this._rerender();
}, 150);
});
this.resizeObserver.observe(this.container);
}
_renderWidth() {
return Math.max(Math.round(this._getViewportWidth() * this.zoom), 100);
}
// Re-render all pages of the already-loaded document (zoom / resize),
// preserving the relative scroll position.
async _rerender() {
if (!this.doc) return;
const generation = ++this.generation;
for (const rt of this.renderTasks) {
try {
rt.cancel();
} catch { /* ok */ }
}
this.renderTasks.clear();
const scrollRatio = this.pagesDiv.scrollHeight > 0
? this.pagesDiv.scrollTop / this.pagesDiv.scrollHeight
: 0;
await this._renderAllPages(this.doc, generation, false);
if (generation !== this.generation) return;
this.pagesDiv.scrollTop = scrollRatio * this.pagesDiv.scrollHeight;
}
// ---- Loading ----
async load(url, byteSize) {
this.lastUrl = url;
this.lastSize = byteSize;
this._setState("loading");
await this._loadInternal(url, byteSize);
}
async _loadInternal(url, _byteSize) {
const generation = ++this.generation;
console.log(
`[BizMatch] PDF viewer load started: generation ${generation}`,
);
// Await cleanup of previous document (promises may be involved)
await this._disposeCurrentDocument();
if (generation !== this.generation) return;
this.pagesDiv.innerHTML = "";
const loadStart = performance.now();
try {
const task = pdfjsLib.getDocument({
url,
rangeChunkSize: 65536,
disableAutoFetch: false,
// PDF.js >= 5 decodes CCITT-G4/JBIG2 (B/W scans), JPEG2000 and ICC
// color via WASM modules fetched from wasmUrl. Without these URLs
// the decoders fail silently (ignoreErrors default) and scanned
// pages render as blank white canvases of the correct size.
wasmUrl: "/pdfjs/wasm/",
standardFontDataUrl: "/pdfjs/standard_fonts/",
iccUrl: "/pdfjs/iccs/",
});
this.loadingTask = task;
const doc = await task.promise;
if (generation !== this.generation) {
doc.destroy();
return;
}
this.doc = doc;
this.numPages = doc.numPages;
const loadMs = Math.round(performance.now() - loadStart);
console.log(
`[BizMatch] PDF document loaded: ${this.numPages} pages in ${loadMs}ms`,
);
this.toolbar.hidden = false;
this._updateZoomLabel();
await this._renderAllPages(doc, generation, true);
if (generation !== this.generation) return;
const totalMs = Math.round(performance.now() - loadStart);
console.log(
`[BizMatch] PDF viewer load completed: generation ${generation}`,
);
console.log(
`[BizMatch] PDF fully rendered: ${this.numPages} pages in ${totalMs}ms`,
);
} catch (err) {
if (generation !== this.generation) return;
// Distinguish cancellation from real errors
if (isCancellationError(err)) {
console.log(
`[BizMatch] PDF generation ${generation} cancelled`,
);
return;
}
const msg = errorMsg(err);
console.error(`[BizMatch] PDF load failed:`, msg);
this.errorMsg.textContent = `Unable to open PDF: ${msg}`;
this._setState("error");
}
}
// Shared render loop for initial load, zoom and resize.
// hideLoadingAfterFirstPage: true on initial load (removes the overlay
// as soon as page 1 is visible).
async _renderAllPages(doc, generation, hideLoadingAfterFirstPage) {
this.pagesDiv.innerHTML = "";
const viewerWidth = this._renderWidth();
this._lastRenderWidth = viewerWidth;
console.log(`[BizMatch] PDF render width: ${viewerWidth}px`);
for (let p = 1; p <= this.numPages; p++) {
if (generation !== this.generation) return;
const pStart = performance.now();
await this._renderPage(doc, p, viewerWidth, generation);
if (generation !== this.generation) return;
if (p === 1 && hideLoadingAfterFirstPage) {
this._setState(null); // Remove loading overlay
}
console.log(
`[BizMatch] PDF page ${p} rendered in ${
Math.round(performance.now() - pStart)
}ms`,
);
await new Promise((r) => requestAnimationFrame(r));
}
}
_setState(state) {
this.loadingDiv.hidden = true;
this.errorDiv.hidden = true;
if (state === "loading") this.loadingDiv.hidden = false;
if (state === "error") this.errorDiv.hidden = false;
this._viewerState = state;
}
_getViewportWidth() {
const style = globalThis.getComputedStyle
? getComputedStyle(this.pagesDiv)
: null;
let padding = 24; // default guess
if (style) {
padding = (parseFloat(style.paddingLeft) || 0) +
(parseFloat(style.paddingRight) || 0);
}
const raw = Math.round(this.pagesDiv.clientWidth || 600);
return Math.max(raw - padding, 200);
}
async _disposeCurrentDocument() {
for (const rt of this.renderTasks) {
try {
rt.cancel();
} catch { /* ok */ }
}
this.renderTasks.clear();
if (this.loadingTask) {
try {
await this.loadingTask.destroy();
} catch { /* ok */ }
this.loadingTask = null;
}
if (this.doc) {
try {
await this.doc.destroy();
} catch { /* ok */ }
this.doc = null;
this.numPages = 0;
}
}
async _renderPage(doc, pageNum, viewerWidth, generation) {
const pageDiv = document.createElement("div");
pageDiv.className = "pdf-v-page";
pageDiv.dataset.page = String(pageNum);
pageDiv.dataset.renderState = "idle";
const canvas = document.createElement("canvas");
pageDiv.appendChild(canvas);
this.pagesDiv.appendChild(pageDiv);
pageDiv.dataset.renderState = "rendering";
try {
const page = await doc.getPage(pageNum);
if (generation !== this.generation) return;
const vp1 = page.getViewport({ scale: 1 });
const fitScale = viewerWidth / vp1.width;
const viewport = page.getViewport({ scale: fitScale });
const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
canvas.width = Math.floor(viewport.width * dpr);
canvas.height = Math.floor(viewport.height * dpr);
canvas.style.width = `${Math.floor(viewport.width)}px`;
canvas.style.height = `${Math.floor(viewport.height)}px`;
const ctx = canvas.getContext("2d");
const transform = dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined;
const renderTask = page.render({
canvasContext: ctx,
viewport,
transform,
});
this.renderTasks.add(renderTask);
await renderTask.promise;
this.renderTasks.delete(renderTask);
if (generation !== this.generation) return;
pageDiv.dataset.renderState = "rendered";
} catch (err) {
if (generation !== this.generation) return;
if (isCancellationError(err)) return;
pageDiv.dataset.renderState = "error";
console.error(
`[BizMatch] Failed to render PDF page ${pageNum}`,
err,
);
if (pageNum === 1) {
this.errorMsg.textContent = `Unable to open PDF: ${errorMsg(err)}`;
this._setState("error");
} else {
pageDiv.textContent = `Error rendering page ${pageNum}`;
pageDiv.style.padding = "20px";
pageDiv.style.color = "#f88";
pageDiv.style.textAlign = "center";
}
}
}
destroy() {
this.generation = 0;
clearTimeout(this._resizeTimer);
clearTimeout(this._zoomTimer);
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
this._disposeCurrentDocument().catch(() => {});
this.pagesDiv.innerHTML = "";
this.toolbar.hidden = true;
this._setState("loading");
}
}
function isCancellationError(err) {
if (!(err instanceof Error)) return false;
const msg = err.message || "";
return msg.includes("cancelled") ||
msg.includes("canceled") ||
msg.includes("destroyed") ||
msg.includes("Worker was destroyed");
}
function errorMsg(err) {
return err instanceof Error ? err.message : String(err);
}
globalThis.PdfViewer = PdfViewer;

View File

@@ -0,0 +1,136 @@
/* Taken from viewer-phase1/styles.css: the .viewer / #pdfViewer rules plus the
PDF.js viewer + zoom toolbar blocks. The page is nothing but the viewer pane,
so .viewer fills the iframe instead of sitting in a grid column. */
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
font: 14px system-ui, sans-serif;
color: #202124;
}
.viewer {
position: relative;
height: 100%;
background: #555;
overflow: hidden;
}
.viewer #pdfViewer {
position: relative;
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
background: #525659;
}
.viewer #pdfMessage {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: white;
pointer-events: none;
z-index: 2;
white-space: pre-wrap;
padding: 20px;
text-align: center;
}
.viewer.loaded #pdfMessage {
display: none;
}
/* PDF.js viewer */
.pdf-v-overlay {
position: absolute;
inset: 0;
z-index: 1;
}
.pdf-v-loading {
padding: 40px 20px;
color: #aaa;
text-align: center;
font-size: 15px;
}
.pdf-v-error {
padding: 30px 20px;
text-align: center;
}
.pdf-v-error-msg {
color: #f88;
font-size: 16px;
margin-bottom: 12px;
white-space: pre-wrap;
}
.pdf-v-retry {
padding: 8px 24px;
font-size: 14px;
cursor: pointer;
border: 1px solid #888;
background: #444;
color: #eee;
border-radius: 4px;
}
.pdf-v-retry:hover {
background: #555;
}
.pdf-v-pages {
box-sizing: border-box;
width: 100%;
height: 100%;
overflow-y: scroll;
overflow-x: auto;
scrollbar-gutter: stable;
display: flex;
flex-direction: column;
align-items: center;
align-items: safe center; /* keeps left edge reachable when zoomed wider than the pane */
gap: 16px;
padding: 12px;
}
.pdf-v-page {
background: white;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.35);
flex-shrink: 0;
}
.pdf-v-page canvas {
display: block;
}
/* Zoom toolbar */
.pdf-v-toolbar {
position: absolute;
top: 10px;
right: 24px;
z-index: 3;
display: flex;
align-items: center;
gap: 2px;
background: rgba(35, 35, 38, 0.88);
border: 1px solid #666;
border-radius: 6px;
padding: 3px 4px;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.4);
}
.pdf-v-toolbar button {
border: 0;
background: transparent;
color: #e5e5e5;
font-size: 15px;
line-height: 1;
padding: 5px 9px;
cursor: pointer;
border-radius: 4px;
}
.pdf-v-toolbar button:hover {
background: #555;
}
.pdf-v-zoom-label {
min-width: 52px;
text-align: center;
font-size: 12px !important;
color: #ccc !important;
}

View File

@@ -0,0 +1,51 @@
// Bootstrap for the standalone PDF viewer page. Plays the role of app.js in
// viewer-phase1: it owns the #pdfMessage overlay and hands one URL to PdfViewer
// (from pdf_viewer.js, unchanged). Everything else — zoom, resize, retry — lives
// in PdfViewer already.
//
// URL contract: /viewer/index.html?file=<urlencoded /api/... path>
// Same-origin only: any file value that is not a root-relative /api/ path is
// refused before it ever reaches pdf.js.
const viewer = document.querySelector(".viewer");
const pdfContainer = document.querySelector("#pdfViewer");
const pdfMessage = document.querySelector("#pdfMessage");
function showMessage(text) {
viewer.classList.remove("loaded");
pdfMessage.textContent = text;
pdfMessage.hidden = false;
}
// Only root-relative /api/ paths. Rejects absolute URLs ("https://..."),
// protocol-relative ones ("//host/api/x") and anything outside /api/.
function safeFileUrl(raw) {
if (!raw) return null;
if (!raw.startsWith("/api/") || raw.startsWith("//")) return null;
// A backslash can be normalized to "/" by some URL parsers — reject outright.
if (raw.includes("\\")) return null;
return raw;
}
const params = new URLSearchParams(globalThis.location.search);
const file = safeFileUrl(params.get("file"));
if (!file) {
showMessage(
params.get("file")
? "Refused to load this document: only same-origin /api/ paths are allowed."
: "Select a document",
);
} else {
showMessage("Loading PDF…");
const pdfViewer = new PdfViewer(pdfContainer);
viewer.classList.add("loaded");
pdfMessage.hidden = true;
pdfViewer.load(file).catch((error) => {
showMessage(
`Cannot open PDF: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
}

View File

@@ -0,0 +1,39 @@
// Copies the pdf.js runtime assets out of node_modules into public/pdfjs/ so the
// standalone viewer (public/viewer/) can load them as plain, unbundled ES modules.
// Runs via predev/prebuild, i.e. also inside the Docker web build stage.
//
// The target layout is dictated by the URLs the ported viewer uses:
// /pdfjs/legacy/pdf.min.mjs (import + GlobalWorkerOptions.workerSrc)
// /pdfjs/wasm/ (wasmUrl — CCITT-G4/JBIG2, JPEG2000, ICC decoders)
// /pdfjs/standard_fonts/ (standardFontDataUrl)
// /pdfjs/iccs/ (iccUrl)
import { cp, mkdir, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const src = path.join(webRoot, 'node_modules', 'pdfjs-dist');
const dest = path.join(webRoot, 'public', 'pdfjs');
try {
await stat(src);
} catch {
console.error(`[copy-pdfjs] ${src} not found — run npm install first.`);
process.exit(1);
}
// Full rebuild, so a pdfjs-dist upgrade never leaves stale files behind.
await rm(dest, { recursive: true, force: true });
await mkdir(path.join(dest, 'legacy'), { recursive: true });
const files = ['pdf.min.mjs', 'pdf.worker.min.mjs'];
for (const file of files) {
await cp(path.join(src, 'legacy', 'build', file), path.join(dest, 'legacy', file));
}
const dirs = ['wasm', 'standard_fonts', 'iccs'];
for (const dir of dirs) {
await cp(path.join(src, dir), path.join(dest, dir), { recursive: true });
}
console.log(`[copy-pdfjs] ${files.join(', ')} + ${dirs.join('/, ')}/ -> public/pdfjs/`);

View File

@@ -31,8 +31,10 @@ export default function App() {
if (!staff) return <Login onLogin={setStaff} />;
return (
<div className="min-h-screen bg-gray-50 text-gray-900">
<header className="flex items-center justify-between border-b border-gray-200 bg-white px-6 py-3">
// h-screen + min-h-0 below, so the business detail's master-detail split can
// fill exactly the remaining viewport height.
<div className="flex h-screen flex-col bg-gray-50 text-gray-900">
<header className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-white px-6 py-3">
<button
className="text-base font-semibold tracking-tight"
onClick={() => setView({ name: 'businesses' })}
@@ -50,13 +52,15 @@ export default function App() {
</div>
</header>
<main className="mx-auto max-w-5xl px-6 py-6">
{view.name === 'businesses' ? (
{view.name === 'businesses' ? (
<main className="mx-auto w-full max-w-5xl flex-1 overflow-auto px-6 py-6">
<Businesses onOpen={(id) => setView({ name: 'business', id })} />
) : (
</main>
) : (
<main className="flex min-h-0 flex-1 flex-col px-6 py-4">
<BusinessDetail id={view.id} onBack={() => setView({ name: 'businesses' })} />
)}
</main>
</main>
)}
</div>
);
}

View File

@@ -25,7 +25,8 @@ export interface Business {
}
export interface BusinessFile {
name: string;
/** Relative to the business directory, posix separators. */
path: string;
size: number;
mtime: string;
}
@@ -73,3 +74,13 @@ export const api = {
businessFiles: (id: string) => request<BusinessFile[]>(`/api/businesses/${id}/files`),
scan: () => post<ScanResult>('/api/businesses/scan'),
};
/** Same-origin streaming URL of one file inside a business directory. */
export function businessFileUrl(id: string, filePath: string): string {
return `/api/businesses/${id}/file?path=${encodeURIComponent(filePath)}`;
}
/** The standalone (unbundled) pdf.js viewer page, pointed at a business file. */
export function viewerUrl(id: string, filePath: string): string {
return `/viewer/index.html?file=${encodeURIComponent(businessFileUrl(id, filePath))}`;
}

View File

@@ -1,5 +1,11 @@
import { useEffect, useState } from 'react';
import { api, type Business, type BusinessFile } from '../api.js';
import {
api,
businessFileUrl,
viewerUrl,
type Business,
type BusinessFile,
} from '../api.js';
function formatSize(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
@@ -16,61 +22,108 @@ function formatDate(iso: string): string {
});
}
const isPdf = (filePath: string) => filePath.toLowerCase().endsWith('.pdf');
export default function BusinessDetail({ id, onBack }: { id: string; onBack: () => void }) {
const [business, setBusiness] = useState<Business | null>(null);
const [files, setFiles] = useState<BusinessFile[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
useEffect(() => {
setSelected(null);
api.business(id).then(setBusiness).catch((err: Error) => setError(err.message));
api.businessFiles(id).then(setFiles).catch((err: Error) => setError(err.message));
}, [id]);
return (
<div>
<button onClick={onBack} className="mb-4 text-sm text-blue-600 hover:underline">
Back to businesses
</button>
<div className="flex h-full min-h-0 flex-col">
<div className="mb-3 shrink-0">
<button onClick={onBack} className="text-sm text-blue-600 hover:underline">
Back to businesses
</button>
{error && <p className="mb-3 text-sm text-red-600">{error}</p>}
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
{business && (
<div className="mb-5">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold">{business.name}</h1>
<span className="rounded-full border border-gray-300 bg-white px-2 py-0.5 text-xs text-gray-600">
{business.status}
</span>
{business && (
<div className="mt-2">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold">{business.name}</h1>
<span className="rounded-full border border-gray-300 bg-white px-2 py-0.5 text-xs text-gray-600">
{business.status}
</span>
</div>
<p className="mt-1 font-mono text-xs text-gray-500">{business.nas_path}</p>
</div>
<p className="mt-1 font-mono text-xs text-gray-500">{business.nas_path}</p>
</div>
)}
)}
</div>
<table className="w-full border-collapse bg-white text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500">
<th className="px-3 py-2 font-medium">File</th>
<th className="w-24 px-3 py-2 font-medium">Size</th>
<th className="w-48 px-3 py-2 font-medium">Modified</th>
</tr>
</thead>
<tbody>
{files?.map((f) => (
<tr key={f.name} className="border-b border-gray-100">
<td className="px-3 py-1.5">{f.name}</td>
<td className="px-3 py-1.5 text-gray-500">{formatSize(f.size)}</td>
<td className="px-3 py-1.5 text-gray-500">{formatDate(f.mtime)}</td>
</tr>
))}
{files && files.length === 0 && (
<tr>
<td colSpan={3} className="px-3 py-4 text-gray-500">
No files in this directory.
</td>
</tr>
{/* Master-detail: file table left, PDF viewer right, together filling the viewport. */}
<div className="flex min-h-0 flex-1 gap-4">
<div className="min-w-0 flex-1 overflow-auto rounded border border-gray-200 bg-white">
<table className="w-full border-collapse text-sm">
<thead className="sticky top-0 bg-white">
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500">
<th className="px-3 py-2 font-medium">File</th>
<th className="w-24 px-3 py-2 font-medium">Size</th>
<th className="w-44 px-3 py-2 font-medium">Modified</th>
</tr>
</thead>
<tbody>
{files?.map((f) => (
<tr
key={f.path}
onClick={isPdf(f.path) ? () => setSelected(f.path) : undefined}
className={`border-b border-gray-100 ${
isPdf(f.path) ? 'cursor-pointer hover:bg-gray-50' : ''
} ${f.path === selected ? 'bg-blue-50 hover:bg-blue-50' : ''}`}
>
<td className="px-3 py-1.5">
{isPdf(f.path) ? (
<span className={f.path === selected ? 'font-medium text-blue-700' : ''}>
{f.path}
</span>
) : (
<a
href={businessFileUrl(id, f.path)}
className="text-blue-600 hover:underline"
>
{f.path}
</a>
)}
</td>
<td className="px-3 py-1.5 text-gray-500">{formatSize(f.size)}</td>
<td className="px-3 py-1.5 text-gray-500">{formatDate(f.mtime)}</td>
</tr>
))}
{files && files.length === 0 && (
<tr>
<td colSpan={3} className="px-3 py-4 text-gray-500">
No files in this directory.
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="min-w-0 flex-1 overflow-hidden rounded border border-gray-200 bg-gray-100">
{selected ? (
<iframe
// Remounting on `key` change is what makes the standalone page reload
// the new document — it reads ?file= once at startup.
key={selected}
src={viewerUrl(id, selected)}
title={selected}
className="h-full w-full border-0"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Select a PDF to view it here.
</div>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}