forked from bai/curriculum-project-hub
96 lines
2.4 KiB
TypeScript
96 lines
2.4 KiB
TypeScript
import { access, stat } from "node:fs/promises";
|
|
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
|
|
export interface DeliverableFile {
|
|
readonly path: string;
|
|
readonly name: string;
|
|
}
|
|
|
|
const DELIVERABLE_EXTENSIONS = new Set([
|
|
".csv",
|
|
".doc",
|
|
".docx",
|
|
".gif",
|
|
".jpeg",
|
|
".jpg",
|
|
".md",
|
|
".pdf",
|
|
".png",
|
|
".ppt",
|
|
".pptx",
|
|
".svg",
|
|
".txt",
|
|
".typ",
|
|
".xls",
|
|
".xlsx",
|
|
".zip",
|
|
]);
|
|
|
|
export async function resolveDeliverableFile(
|
|
requestedPath: string,
|
|
workspaceDir: string,
|
|
): Promise<DeliverableFile | null> {
|
|
const token = requestedPath.trim();
|
|
if (token === "" || !DELIVERABLE_EXTENSIONS.has(extname(token).toLowerCase())) {
|
|
return null;
|
|
}
|
|
|
|
const roots = await allowedRoots(workspaceDir);
|
|
for (const candidate of candidatePaths(token, workspaceDir, roots)) {
|
|
if (!isAllowedPath(candidate, roots)) continue;
|
|
const existing = await existingFile(candidate);
|
|
if (existing !== null) return { path: existing, name: basename(existing) };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function allowedRoots(workspaceDir: string): Promise<string[]> {
|
|
const roots = [resolve(workspaceDir)];
|
|
const gitRoot = await findGitRoot(workspaceDir);
|
|
if (gitRoot !== undefined) roots.push(gitRoot);
|
|
return [...new Set(roots)];
|
|
}
|
|
|
|
async function findGitRoot(startDir: string): Promise<string | undefined> {
|
|
let current = resolve(startDir);
|
|
while (true) {
|
|
try {
|
|
await access(join(current, ".git"));
|
|
return current;
|
|
} catch {
|
|
const parent = resolve(current, "..");
|
|
if (parent === current) return undefined;
|
|
current = parent;
|
|
}
|
|
}
|
|
}
|
|
|
|
function candidatePaths(token: string, workspaceDir: string, roots: readonly string[]): string[] {
|
|
if (isAbsolute(token)) return [resolve(token)];
|
|
|
|
const candidates: string[] = [];
|
|
for (const root of roots) {
|
|
candidates.push(resolve(root, token));
|
|
}
|
|
if (!token.includes("/") && !token.includes("\\")) {
|
|
candidates.push(resolve(workspaceDir, "build", token));
|
|
}
|
|
return [...new Set(candidates)];
|
|
}
|
|
|
|
function isAllowedPath(path: string, roots: readonly string[]): boolean {
|
|
return roots.some((root) => {
|
|
const rel = relative(root, path);
|
|
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
});
|
|
}
|
|
|
|
async function existingFile(path: string): Promise<string | null> {
|
|
try {
|
|
const s = await stat(path);
|
|
return s.isFile() ? resolve(path) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|