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

@@ -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) };
}