Files
anonymous_github/src/server/routes/file.ts
T
Thomas Durieux a9760eab39 perf: avoid needless downloads and track request latency (#876)
* perf: avoid needless downloads and track request latency

* fix: correct request timing and monitoring revalidation
2026-10-08 10:38:33 +01:00

168 lines
5.2 KiB
TypeScript

import * as express from "express";
import AnonymizedFile from "../../core/AnonymizedFile";
import AnonymousError from "../../core/AnonymousError";
import { getRepo, handleError } from "./route-utils";
import { fileETag } from "./file-etag";
import config from "../../config";
import { lookup } from "mime-types";
import { isTextFile } from "../../core/anonymize-utils";
export const router = express.Router();
function decodePathSegment(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
try {
return decodeURI(segment);
} catch {
return segment;
}
}
}
// Extensions the browser will execute script from when it renders the
// response as a document (directly, or via the "Raw" action).
const SCRIPTABLE_EXTENSIONS = new Set([
"html",
"htm",
"xhtml",
"xht",
"svg",
"xml",
"xsl",
"xslt",
"mhtml",
]);
export function isScriptableDocument(anonymizedPath: string): boolean {
const name = anonymizedPath.substring(anonymizedPath.lastIndexOf("/") + 1);
const dot = name.lastIndexOf(".");
if (dot < 0) return false;
return SCRIPTABLE_EXTENSIONS.has(name.substring(dot + 1).toLowerCase());
}
export function filePathFromRequestUrl(
reqUrl: string,
protocol: string,
hostname: string,
repoId: string
): string {
const pathname = new URL(reqUrl, `${protocol}://${hostname}`).pathname;
const prefix = `/${encodeURIComponent(repoId)}/file/`;
const rawPath = pathname.startsWith(prefix)
? pathname.substring(prefix.length)
: pathname.replace(`/${repoId}/file/`, "");
return rawPath.split("/").map(decodePathSegment).join("/");
}
router.get(
"/:repoId/file/*path",
async (req, res) => {
const anonymizedPath = filePathFromRequestUrl(
req.url,
req.protocol,
req.hostname,
req.params.repoId
);
if (anonymizedPath.endsWith("/")) {
return handleError(
new AnonymousError("folder_not_supported", {
httpStatus: 404,
object: anonymizedPath,
}),
res
);
}
// Reject path traversal before the path reaches the storage layer. The
// storage backends also validate, but failing fast here keeps a crafted
// "../" URL from being treated as a real lookup (CWE-22/25).
if (
anonymizedPath
.split(/[\\/]/)
.some((segment) => segment === "..") ||
/^[\\/]/.test(anonymizedPath)
) {
return handleError(
new AnonymousError("invalid_path", {
httpStatus: 400,
object: anonymizedPath,
}),
res
);
}
const repo = await getRepo(req, res, {
nocheck: false,
});
if (!repo) return;
try {
if (!(await repo.isReady())) {
throw new AnonymousError("repository_not_ready", {
object: repo,
httpStatus: 425,
});
}
const f = new AnonymizedFile({
repository: repo,
anonymizedPath,
});
const originalPath = await f.originalPath();
if (!f.isFileSupported()) {
throw new AnonymousError("file_not_supported", {
httpStatus: 403,
object: f,
});
}
if (req.query.download) {
res.attachment(
anonymizedPath.substring(anonymizedPath.lastIndexOf("/") + 1)
);
} else if (isScriptableDocument(originalPath) || isScriptableDocument(anonymizedPath)) {
// A repository's own .html/.svg is untrusted content served from our
// origin: opening it renders it as a document, and any script in it
// would run as the site itself (session cookie, same-origin fetches
// against /api). The CSP sandbox directive forces the response into an
// opaque origin, so the document still renders but can reach nothing
// of ours. Scripts are left out entirely, matching the file viewer's
// default (html-doc.js) — a reader who wants them opts in there.
// allow-same-origin must never be added: combined with allow-scripts
// it lets the document remove its own sandbox.
res.header(
"Content-Security-Policy",
"sandbox allow-popups allow-forms allow-modals"
);
}
const etag = fileETag(
`${repo.model.source.commit || ""}:${await f.sha() || ""}`,
anonymizedPath,
repo.model.options
);
res.header("ETag", etag);
// Force the browser to revalidate every time. The previous 210-day
// max-age was keyed only on the upstream sha, so editing the
// anonymization term list left old anonymizations cached under the
// same URL.
res.header("Cache-Control", "private, no-cache, must-revalidate");
if (req.fresh) {
return res.status(304).end();
}
if (req.method === "HEAD") {
const size = await f.size();
if (size != null && size > config.MAX_FILE_SIZE) throw new AnonymousError("file_too_big", { httpStatus: 413 });
const mime = lookup(originalPath);
if (mime && !originalPath.endsWith(".ts")) res.type(mime);
else if (isTextFile(originalPath)) res.type("text/plain");
res.end(); return;
}
await f.send(res);
await repo.countView();
} catch (error) {
return handleError(error, res, req);
}
}
);
export default router;