changes
This commit is contained in:
@@ -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) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user