Files
anonymous_github/src/streamer/route.ts
T
Thomas Durieux d0b217a448 feat: include public GitHub submodules in shared repositories (#914)
* feat: include public GitHub submodules in shared repositories

* fix: preserve submodule access across token and document contexts
2026-11-02 09:00:02 +01:00

142 lines
4.9 KiB
TypeScript

import * as express from "express";
import GitHubStream from "../core/source/GitHubStream";
import {
AnonymizeTransformer,
isTextFile,
} from "../core/anonymize-utils";
import { handleError } from "../server/routes/route-utils";
import { lookup } from "mime-types";
import { streamAnonymizedZip } from "../core/zipStream";
export const router = express.Router();
router.post(
"/download",
async (req: express.Request, res: express.Response) => {
req.body = req.body || {};
const token: string = req.body.token;
if (typeof req.body.repoFullName !== "string" || typeof token !== "string") {
return res.status(400).json({ error: "invalid_request" });
}
const repoFullName = req.body.repoFullName.split("/");
const repoId = req.body.repoId;
const commit = req.body.commit;
const anonymizerOptions = req.body.anonymizerOptions;
const contentOptions = req.body.contentOptions;
try {
await streamAnonymizedZip(
{
repoId,
organization: repoFullName[0],
repoName: repoFullName[1],
commit,
getToken: () => token,
anonymizerOptions: { ...anonymizerOptions, repoId },
contentOptions,
submodules: req.body.submodules,
},
res
);
} catch (error) {
handleError(error, res);
if (res.headersSent && !res.writableEnded) res.destroy();
}
}
);
router.post("/", async (req: express.Request, res: express.Response) => {
req.body = req.body || {};
const token: string = req.body.token;
if (typeof req.body.repoFullName !== "string" || typeof token !== "string") {
return res.status(400).json({ error: "invalid_request" });
}
const repoFullName = req.body.repoFullName.split("/");
const repoId = req.body.repoId;
const fileSha = req.body.sha;
const fileSize: number | undefined =
typeof req.body.size === "number" ? req.body.size : undefined;
const commit = req.body.commit;
const filePath: string = req.body.filePath;
const anonymizerOptions = req.body.anonymizerOptions;
const anonymizer = new AnonymizeTransformer({ ...anonymizerOptions, repoId,
cacheGeneration: typeof req.body.cacheGeneration === "string" ? req.body.cacheGeneration : anonymizerOptions?.cacheGeneration,
});
// Defence in depth: the parent server validates filePath against
// FileModel before calling us, but the streamer joins this directly
// into the storage path on disk. Reject any segment that could escape
// the repo root, in case the streamer is ever exposed beyond the
// internal network or a buggy caller forwards an unvalidated path.
if (
typeof filePath !== "string" ||
filePath.length === 0 ||
filePath
.split(/[\\/]/)
.some((segment) => segment === ".." || segment === "")
) {
return res.status(400).json({ error: "invalid_file_path" });
}
const source = new GitHubStream({
repoId,
organization: repoFullName[0],
repoName: repoFullName[1],
commit: commit,
cacheGeneration: typeof req.body.cacheGeneration === "string" ? req.body.cacheGeneration : undefined,
cacheRevision: typeof req.body.cacheRevision === "string" ? req.body.cacheRevision : anonymizerOptions?.cacheRevision,
getToken: () => token,
});
try {
const content = await source.getFileContentCache(
filePath,
repoId,
() => ({ sha: fileSha, size: fileSize, upstream: req.body.upstream })
);
if (res.destroyed || res.writableEnded) { content.destroy(); anonymizer.destroy(); return; }
const mime = lookup(filePath);
if (mime && !filePath.endsWith(".ts")) {
res.contentType(mime);
} else if (isTextFile(filePath)) {
res.contentType("text/plain");
}
// Only declare Accept-Ranges: none for text files — they get rewritten on
// the fly so byte ranges aren't meaningful. For binary entries the
// transformer is a passthrough; let <video>/<audio> fall back to a full
// download instead of refusing to play (#538).
if (isTextFile(filePath)) {
res.header("Accept-Ranges", "none");
}
anonymizer.once("transform", (data) => {
if (!mime && data.isText) {
res.contentType("text/plain");
} else if (!mime && !data.isText) {
res.contentType("application/octet-stream");
}
});
function handleStreamError(error: Error) {
if (!content.closed && !content.destroyed) {
content.destroy();
}
handleError(error, res);
if (res.headersSent && !res.writableEnded) res.destroy();
anonymizer.destroy();
}
content
.on("error", handleStreamError)
.pipe(anonymizer)
.on("error", handleStreamError)
.pipe(res)
.on("error", handleStreamError)
.on("close", () => {
anonymizer.destroy();
if (!content.closed && !content.destroyed) {
content.destroy();
}
});
} catch (error) {
handleError(error, res);
}
});
export default router;