import { readdir, realpath, stat } from 'node:fs/promises'; import path from 'node:path'; import { config } from './config.js'; import { query } from './db.js'; export type BusinessStatus = 'ACTIVE' | 'SOLD' | 'INACTIVE'; export interface ScanResult { scanned: number; inserted: number; updated: number; missing: number; } interface BusinessRow { id: string; name: string; nas_path: string; status: string; } /** The three status directories directly below NAS_ROOT. */ function statusDirs(): { status: BusinessStatus; dir: string }[] { return [ { status: 'ACTIVE', dir: config.nasDirActive }, { status: 'SOLD', dir: config.nasDirSold }, { status: 'INACTIVE', dir: config.nasDirInactive }, ]; } interface FoundBusiness { name: string; nasPath: string; status: BusinessStatus; } /** Reads the three status directories; every immediate subdirectory is one business. */ async function readFromDisk(): Promise { const found: FoundBusiness[] = []; for (const { status, dir } of statusDirs()) { const full = path.join(config.nasRoot, dir); let entries; try { entries = await readdir(full, { withFileTypes: true }); } catch (err) { throw new Error( `NAS directory not found or not readable: ${full} (${(err as Error).message})`, ); } for (const entry of entries) { if (!entry.isDirectory()) continue; found.push({ name: entry.name, nasPath: path.join(full, entry.name), status }); } } return found; } /** * Scans the NAS and upserts the businesses. Idempotent: existing rows are matched * by name and only touched when nas_path or status actually changed. Rows that no * longer exist on disk are kept and only reported via the logger. */ export async function scanBusinesses( log: { warn: (msg: string) => void } = console, ): Promise { const found = await readFromDisk(); const existing = await query('SELECT id, name, nas_path, status FROM business'); const byName = new Map(existing.map((row) => [row.name, row])); let inserted = 0; let updated = 0; const seen = new Set(); for (const business of found) { seen.add(business.name); const row = byName.get(business.name); if (!row) { await query( 'INSERT INTO business (name, nas_path, status) VALUES ($1, $2, $3)', [business.name, business.nasPath, business.status], ); inserted += 1; } else if (row.nas_path !== business.nasPath || row.status !== business.status) { await query('UPDATE business SET nas_path = $1, status = $2 WHERE id = $3', [ business.nasPath, business.status, row.id, ]); updated += 1; } } const missing = existing.filter((row) => !seen.has(row.name)); if (missing.length > 0) { log.warn( `[business-scan] ${missing.length} business(es) in the database no longer found on disk: ` + missing.map((row) => row.name).join(', '), ); } return { scanned: found.length, inserted, updated, missing: missing.length }; } export interface BusinessFile { /** Relative to the business directory, always with posix separators. */ path: string; size: number; mtime: string; } /** 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.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() }); } } /** * 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 { const files: BusinessFile[] = []; await walk(nasPath, '', 1, files); const isPdf = (p: string) => p.toLowerCase().endsWith('.pdf'); return files.sort((a, b) => { 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 { 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) }; }