Files
anonymous_github/src/server/routes/repository-public.ts
T
Thomas Durieux 7aa9557905 perf: cut MongoDB scans in repository workflows (#780)
* Add repository name index

* Optimize repository file counts

* Optimize MongoDB query workloads
2026-08-20 12:29:26 +02:00

410 lines
13 KiB
TypeScript

import * as express from "express";
import config from "../../config";
import got from "got";
import { join } from "path";
import { getRepo, getUser, handleError, isCoauthor } from "./route-utils";
import AnonymousError from "../../core/AnonymousError";
import { downloadQueue } from "../../queue";
import { RepositoryStatus } from "../../core/types";
import User from "../../core/User";
import { streamAnonymizedZip } from "../../core/zipStream";
import FileModel from "../../core/model/files/files.model";
import { createLogger, serializeError } from "../../core/logger";
import gh = require("parse-github-url");
const logger = createLogger("repository-public");
const router = express.Router();
router.get(
"/:repoId/zip",
async (req: express.Request, res: express.Response) => {
try {
if (!config.ENABLE_DOWNLOAD) {
throw new AnonymousError("download_not_enabled", {
httpStatus: 403,
object: req.params.repoId,
});
}
const repo = await getRepo(req, res);
if (!repo) return;
let user: User | undefined = undefined;
try {
user = await getUser(req);
} catch { /* not logged in */ }
let download = false;
if (
(!!config.ENABLE_DOWNLOAD && !!config.STREAMER_ENTRYPOINT) ||
user?.isAdmin === true
) {
download = true;
}
if (!download) {
throw new AnonymousError("download_not_enabled", {
httpStatus: 403,
object: req.params.repoId,
});
}
await repo.countView();
if (config.STREAMER_ENTRYPOINT) {
// use the streamer service
const token = await repo.getToken();
const anonymizer = repo.generateAnonymizeTransformer("");
res.attachment(`${repo.repoId}.zip`);
const reqStream = got
.stream(join(config.STREAMER_ENTRYPOINT, "api/download"), {
method: "POST",
json: {
token,
repoFullName: repo.model.source.repositoryName,
commit: repo.model.source.commit,
branch: repo.model.source.branch,
repoId: repo.repoId,
anonymizerOptions: anonymizer.opt,
contentOptions: {
image: repo.options.image,
pdf: repo.options.pdf,
},
},
})
.on("error", (err: Error & { response?: { statusCode?: number; body?: unknown } }) => {
const upstreamStatus = err?.response?.statusCode;
let upstreamBody: string | undefined;
let errCode = "zip_not_available";
let httpStatus = 502;
if (err?.response?.body != null) {
try {
upstreamBody =
typeof err.response.body === "string"
? err.response.body
: Buffer.isBuffer(err.response.body)
? err.response.body.toString("utf8")
: JSON.stringify(err.response.body);
} catch {
/* ignore */
}
if (upstreamBody) {
try {
const parsed = JSON.parse(upstreamBody);
if (parsed && typeof parsed.error === "string") {
errCode = parsed.error;
}
} catch {
/* not JSON */
}
}
}
if (typeof upstreamStatus === "number") {
httpStatus = upstreamStatus >= 500 ? 502 : upstreamStatus;
}
logger.warn("streamer zip fetch failed", {
code: errCode,
httpStatus,
repoId: repo.repoId,
upstreamStatus,
upstreamBody: upstreamBody?.slice(0, 500),
url: config.STREAMER_ENTRYPOINT
? join(config.STREAMER_ENTRYPOINT, "api/zip")
: undefined,
err: serializeError(err),
});
handleError(
new AnonymousError(errCode, {
url: req.originalUrl,
httpStatus,
cause: err,
}),
res
);
});
reqStream.pipe(res);
res.on("close", () => {
reqStream.destroy();
});
res.on("error", () => {
reqStream.destroy();
});
return;
}
res.attachment(`${repo.repoId}.zip`);
// cache the file for 6 hours
res.header("Cache-Control", "max-age=21600");
const parsed = gh(repo.model.source.repositoryName || "");
if (!parsed?.owner || !parsed?.name) {
throw new AnonymousError("repo_not_found", {
httpStatus: 404,
object: repo.model.source.repositoryName,
});
}
const anonymizer = repo.generateAnonymizeTransformer("");
await streamAnonymizedZip(
{
repoId: repo.repoId,
organization: parsed.owner,
repoName: parsed.name,
commit: repo.model.source.commit || "HEAD",
getToken: () => repo.getToken(),
anonymizerOptions: anonymizer.opt,
contentOptions: {
image: repo.options.image,
pdf: repo.options.pdf,
},
},
res
);
} catch (error) {
handleError(error, res, req);
}
}
);
router.get(
"/:repoId/files",
async (req: express.Request, res: express.Response) => {
res.header("Cache-Control", "no-cache");
const repo = await getRepo(req, res);
if (!repo) return;
try {
res.json(
await repo.anonymizedFiles({
includeSha: false,
recursive: false,
path: req.query.path as string,
})
);
} catch (error) {
handleError(error, res, req);
}
}
);
router.get(
"/:repoId/files/counts",
async (req: express.Request, res: express.Response) => {
res.header("Cache-Control", "no-cache");
const repo = await getRepo(req, res);
if (!repo) return;
try {
const repoId = repo.repoId;
const results = await FileModel.aggregate([
{ $match: { repoId, size: { $ne: null } } },
{ $project: { _id: 0, path: 1 } },
{ $group: { _id: "$path", count: { $sum: 1 } } },
]).exec();
const directCounts: Record<string, number> = {};
for (const r of results) {
directCounts[r._id ?? ""] = r.count;
}
const folderCounts: Record<string, number> = {};
for (const [folder, count] of Object.entries(directCounts)) {
let p = folder;
folderCounts[p] = (folderCounts[p] || 0) + count;
while (p) {
const idx = p.lastIndexOf("/");
p = idx >= 0 ? p.substring(0, idx) : "";
folderCounts[p] = (folderCounts[p] || 0) + count;
if (!p) break;
}
}
const terms = repo.options?.terms || [];
if (terms.length > 0) {
const { anonymizePathCompiled, compileTerms } = await import(
"../../core/anonymize-utils"
);
const compiled = compileTerms(terms);
const anonymized: Record<string, number> = {};
for (const [folder, count] of Object.entries(folderCounts)) {
const anonFolder = anonymizePathCompiled(folder, compiled);
anonymized[anonFolder] = (anonymized[anonFolder] || 0) + count;
}
return res.json(anonymized);
}
res.json(folderCounts);
} catch (error) {
handleError(error, res, req);
}
}
);
router.get(
"/:repoId/files/search",
async (req: express.Request, res: express.Response) => {
res.header("Cache-Control", "no-cache");
const repo = await getRepo(req, res);
if (!repo) return;
try {
const query = ((req.query.q as string) || "").trim();
if (!query || query.length < 2) {
return res.json([]);
}
const allFiles = await repo.anonymizedFiles({
includeSha: false,
recursive: true,
});
const q = query.toLowerCase();
// Collect folder paths whose name segment matches the query
const matchingFolders = new Set<string>();
for (const f of allFiles) {
const segments = (f.path || "").split("/").filter(Boolean);
let accumulated = "";
for (const seg of segments) {
accumulated = accumulated ? `${accumulated}/${seg}` : seg;
if (seg.toLowerCase().includes(q)) {
matchingFolders.add(accumulated);
}
}
}
const matches = allFiles.filter((f) => {
// File name matches
if (f.name?.toLowerCase().includes(q)) return true;
// File is inside a matching folder
const fullPath = f.path ? `${f.path}/${f.name}` : f.name;
let found = false;
matchingFolders.forEach((folder) => {
if (fullPath?.startsWith(folder + "/") || fullPath === folder) found = true;
})
if (found) return true;
return false;
});
res.json(
matches.slice(0, 500).map((f) => ({
name: f.name,
path: f.path,
size: f.size,
}))
);
} catch (error) {
handleError(error, res, req);
}
}
);
router.get(
"/:repoId/options",
async (req: express.Request, res: express.Response) => {
try {
res.header("Cache-Control", "no-cache");
const repo = await getRepo(req, res, {
nocheck: true,
});
if (!repo) return;
let user: User | undefined = undefined;
try {
user = await getUser(req);
} catch { /* not logged in */ }
const canEdit =
!!user &&
(user.isAdmin ||
user.id == repo.model.owner ||
isCoauthor(repo, user));
let redirectURL = null;
if (
!canEdit &&
repo.status == RepositoryStatus.EXPIRED &&
repo.options.expirationMode == "redirect" &&
repo.model.source.repositoryName
) {
redirectURL = `https://github.com/${repo.model.source.repositoryName}`;
} else if (!canEdit) {
if (
repo.status == RepositoryStatus.EXPIRED ||
repo.status == RepositoryStatus.EXPIRING ||
repo.status == RepositoryStatus.REMOVING ||
repo.status == RepositoryStatus.REMOVED
) {
throw new AnonymousError("repository_expired", {
object: repo,
httpStatus: 410,
});
}
const fiveMinuteAgo = new Date();
fiveMinuteAgo.setMinutes(fiveMinuteAgo.getMinutes() - 5);
if (repo.status != "ready") {
if (
repo.status != RepositoryStatus.QUEUE &&
repo.model.statusDate < fiveMinuteAgo
) {
await repo.updateStatus(RepositoryStatus.PREPARING);
await downloadQueue.add(repo.repoId, { repoId: repo.repoId }, {
jobId: `repo-${repo.repoId}`,
attempts: 3,
});
}
if (repo.status == "error") {
throw new AnonymousError(
repo.model.statusMessage
? repo.model.statusMessage
: "repository_not_accessible",
{
object: repo,
httpStatus: 500,
}
);
}
const rlMatch = (repo.model.statusMessage || "").match(/^rate_limited:(\d+)$/);
if (rlMatch) {
const resetAt = parseInt(rlMatch[1], 10);
throw new AnonymousError("rate_limited", {
httpStatus: 425,
object: { resetAt },
});
}
throw new AnonymousError("repository_not_ready", {
httpStatus: 425,
object: repo,
});
}
await repo.updateIfNeeded();
}
let download = false;
if (!!config.ENABLE_DOWNLOAD && !!config.STREAMER_ENTRYPOINT) {
download = true;
}
res.json({
url: redirectURL,
download: download || user?.isAdmin === true,
lastUpdateDate: repo.model.source.commitDate
? repo.model.source.commitDate
: repo.model.anonymizeDate,
isAdmin: user?.isAdmin === true,
isOwner: user?.id == repo.model.owner,
hasWebsite: !!repo.options.page && !!repo.options.pageSource,
truncatedFolders: repo.model.truncatedFolders || [],
// Submodule contents are not included in GitHub archives/trees, so
// they end up as empty folders in the anonymized repository. Surface
// a warning in the explorer when the repository uses submodules (#737).
hasSubmodules:
(await FileModel.exists({
repoId: repo.repoId,
name: ".gitmodules",
path: "",
})) != null,
});
} catch (error) {
handleError(error, res, req);
}
}
);
export default router;