Files
bizmatch-app/src/business-scan.ts
2026-07-26 17:45:58 -05:00

204 lines
6.6 KiB
TypeScript

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<FoundBusiness[]> {
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<ScanResult> {
const found = await readFromDisk();
const existing = await query<BusinessRow>('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<string>();
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<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.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) };
}