204 lines
6.6 KiB
TypeScript
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) };
|
|
}
|