mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-09-13 06:08:58 +02:00
* Add repository name index * Optimize repository file counts * Optimize MongoDB query workloads
410 lines
13 KiB
TypeScript
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;
|