merge: reconcile paid hosting with latest main

This commit is contained in:
Thomas Durieux committed 2026-11-02 21:12:29 +00:00
commit ded6f905fd
180 files changed
+21709 -3614

No files matched your search

+4
View File
@@ -20,6 +20,8 @@ interface Config {
PAID_CONFERENCE_REPOSITORIES: number;
PAID_CONFERENCE_STORAGE: number;
RESEND_API_KEY: string;
EMAIL_FROM: string;
GITHUB_APP_ENABLED: boolean;
GITHUB_APP_NEW_CONNECTIONS: boolean;
GITHUB_OAUTH_ENABLED: boolean;
@@ -103,6 +105,8 @@ const config: Config = {
PAID_CONFERENCE_REPOSITORIES: 100,
PAID_CONFERENCE_STORAGE: 50 * 1024 ** 3,
RESEND_API_KEY: "",
EMAIL_FROM: "",
GITHUB_APP_ENABLED: false,
GITHUB_APP_NEW_CONNECTIONS: true,
GITHUB_OAUTH_ENABLED: true,
+34 -7
View File
@@ -1,3 +1,4 @@
import { notifyOwnerAccessProblem } from "./owner-notifications";
import { streamResponse } from "./response-stream";
import { join, basename, dirname } from "path";
import { Response } from "express";
@@ -152,8 +153,18 @@ export default class AnonymizedFile {
return this._file.size;
}
private visibleFile(file: IFile): IFile {
if (this.repository.model?.source?.type !== "Zip" && file.name === ".gitmodules") {
throw new AnonymousError("file_not_found", { httpStatus: 404 });
}
return file;
}
async getFileInfo(): Promise<IFile> {
if (this._file) return this._file;
if (this.repository.model?.source?.type !== "Zip" && basename(this.anonymizedPath) === ".gitmodules") {
throw new AnonymousError("file_not_found", { httpStatus: 404 });
}
if (this._file) return this.visibleFile(this._file);
let fileDir = dirname(this.anonymizedPath);
if (fileDir == ".") fileDir = "";
if (fileDir.endsWith("/")) fileDir = fileDir.slice(0, -1);
@@ -179,7 +190,7 @@ export default class AnonymizedFile {
if (exact) {
if (exact.metadataPending) await this.repository.completeRecoveredFileMetadata(exact);
this._file = exact;
return exact;
return this.visibleFile(exact);
}
const terms = this.repository.options.terms || [];
@@ -212,7 +223,7 @@ export default class AnonymizedFile {
const compiled = compileTerms(terms);
if (typeof this.repository.findAnonymizedPath === "function") {
const indexed = await this.repository.findAnonymizedPath(this.anonymizedPath);
if (indexed) { this._file = indexed; return indexed; }
if (indexed) { this._file = indexed; return this.visibleFile(indexed); }
if (indexed === null) {
if (anonymizePathCompiled(this.anonymizedPath, compiled) === this.anonymizedPath) {
const recovered = await this.recoverTruncatedFile(fileDir);
@@ -236,7 +247,7 @@ export default class AnonymizedFile {
) {
if (candidate.metadataPending) await this.repository.completeRecoveredFileMetadata(candidate);
this._file = candidate;
return candidate;
return this.visibleFile(candidate);
}
}
@@ -270,7 +281,10 @@ export default class AnonymizedFile {
fetchFileInfoFromPath?: (filePath: string) => Promise<IFile | null>;
};
if (typeof source.fetchFileInfoFromPath !== "function") return null;
const recovered = await source.fetchFileInfoFromPath(this.anonymizedPath);
const recovered = await source.fetchFileInfoFromPath(this.anonymizedPath).catch(error => {
void notifyOwnerAccessProblem(this.repository.owner.id, error, { kind: "repository", id: String(this.repository.model._id) });
throw error;
});
if (!recovered) return null;
recovered.repoId = this.repository.repoId;
recovered.treeGeneration = this.repository.model.treeGeneration;
@@ -309,6 +323,7 @@ export default class AnonymizedFile {
if (!this._file) {
this._file = await this.getFileInfo();
}
this.visibleFile(this._file);
return join(this._file.path, this._file.name);
}
extension() {
@@ -356,7 +371,11 @@ export default class AnonymizedFile {
httpStatus: 413,
});
}
const content = await this.repository.source?.getFileContent(this);
const content = await this.repository.source?.getFileContent(this).catch(error => {
void notifyOwnerAccessProblem(this.repository.owner.id, error, { kind: "repository", id: String(this.repository.model._id) });
throw error;
});
content.on("error", error => { void notifyOwnerAccessProblem(this.repository.owner.id, error, { kind: "repository", id: String(this.repository.model._id) }); });
const cacheWasReset = this.repository.model.isReseted;
if (cacheWasReset) {
await this.repository.markCachePresent();
@@ -379,7 +398,7 @@ export default class AnonymizedFile {
}
// use the streamer service
return got.stream(join(config.STREAMER_ENTRYPOINT, "api"), {
const content = got.stream(join(config.STREAMER_ENTRYPOINT, "api"), {
method: "POST",
headers: requestHeaders(),
json: {
@@ -391,11 +410,17 @@ export default class AnonymizedFile {
branch: this.repository.model.source.branch,
repoId: this.repository.repoId,
filePath: this.filePath,
upstream: (await this.getFileInfo()).upstream,
sha: await this.sha(),
size: await this.size(),
anonymizerOptions: anonymizer.opt,
},
});
content.on("error", err => {
const { error } = streamerErrorToAnonymous(err, { repoId: this.repository.repoId, filePath: this.anonymizedPath });
void notifyOwnerAccessProblem(this.repository.owner.id, error, { kind: "repository", id: String(this.repository.model._id) });
});
return content;
}
get filePath() {
@@ -434,6 +459,7 @@ export default class AnonymizedFile {
json: {
sha,
size,
upstream: (await this.getFileInfo()).upstream,
token: await githubTokenForStreamer(token, this.repository.model.source.repositoryName),
repoFullName: this.repository.model.source.repositoryName,
commit: this.repository.model.source.commit,
@@ -473,6 +499,7 @@ export default class AnonymizedFile {
filePath: this.anonymizedPath,
}
);
void notifyOwnerAccessProblem(this.repository.owner.id, error, { kind: "repository", id: String(this.repository.model._id) });
error.value = this;
handleError(error, res);
if (res.headersSent && !res.writableEnded) res.destroy();
+11
View File
@@ -1,3 +1,4 @@
import { notifyOwnerAccessProblem, withOwnerAccessRecovery } from "./owner-notifications";
import { registerGitHubToken } from "./github-token-context";
import { isConnected } from "../server/database";
import { expireEmbeddedContent } from "./content-expiration";
@@ -67,6 +68,16 @@ export default class Gist {
}
async download() {
try { await withOwnerAccessRecovery(this.owner.id,
{ kind: "gist", id: String(this.model._id) }, () => this.downloadWithAccess(),
{ "source.gistId": this.model.source.gistId }); }
catch (error) {
void notifyOwnerAccessProblem(this.owner.id, error, { kind: "gist", id: String(this.model._id) });
throw error;
}
}
private async downloadWithAccess() {
const access = await this.getAccess();
try {
await this.downloadContent(access.token);
+37 -90
View File
@@ -1,3 +1,6 @@
import { isGitHubRateLimitError, OctokitRequestErrorLike } from "./github-rate-limit";
import { refreshLegacyToken } from "./legacy-token-refresh";
import { notifyOwnerAccessProblem } from "./owner-notifications";
import AnonymizedRepositoryModel from "./model/anonymizedRepositories/anonymizedRepositories.model";
import { isConnected } from "../server/database";
import { githubQuotaKey, githubTokenContext } from "./github-token-context";
@@ -8,42 +11,15 @@ import { createClient, RedisClientType } from "redis";
import AnonymousError from "./AnonymousError";
import Repository from "./Repository";
import { getCredential, replaceCredential, getCredentialToken } from "./credentials";
import { getCredential, getCredentialToken } from "./credentials";
import config from "../config";
import { createLogger } from "./logger";
import { measureStage } from "./request-monitoring";
import { setTimeout as delay } from "timers/promises";
const logger = createLogger("github");
// Octokit RequestError shape (subset we care about for rate-limit detection).
interface OctokitRequestErrorLike {
status?: number;
message?: string;
response?: {
headers?: Record<string, string | undefined>;
};
}
/**
* Detect GitHub rate-limit / abuse responses (primary 5k/h or undocumented
* "secondary" limits) and rewrap them as a translatable AnonymousError so
* the UI can show a friendly message instead of a raw HttpError stack. The
* GitHub `x-github-request-id` header is preserved in `detail` so users can
* cite it if they reach out to GitHub Support.
*/
export function isGitHubRateLimitError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const e = err as OctokitRequestErrorLike;
const msg = (e.message ?? "").toLowerCase();
// Primary limits return 403 with "x-ratelimit-remaining: 0"; secondary
// limits return 403 (sometimes 429) with "secondary rate limit" in the
// body. Match on either signal so we catch both.
const status = e.status ?? 0;
if (status !== 403 && status !== 429) return false;
if (msg.includes("rate limit") || msg.includes("abuse")) return true;
const remaining = e.response?.headers?.["x-ratelimit-remaining"];
return remaining === "0";
}
export { isGitHubRateLimitError } from "./github-rate-limit";
function rateLimitDetail(err: OctokitRequestErrorLike): string {
const headers = err.response?.headers ?? {};
@@ -110,7 +86,18 @@ export function getTokenGateResetAt(token: string): number {
return gate.resetAt;
}
async function waitForTokenGate(token: string): Promise<void> {
function abortableGateLookup(lookup: Promise<number>, signal: AbortSignal): Promise<number> {
return new Promise((resolve, reject) => {
const cancelled = () => { signal.removeEventListener("abort", cancelled); reject(signal.reason); };
if (signal.aborted) cancelled();
else signal.addEventListener("abort", cancelled, { once: true });
lookup.then(value => { signal.removeEventListener("abort", cancelled); resolve(value); },
error => { signal.removeEventListener("abort", cancelled); reject(error); });
});
}
async function waitForTokenGate(token: string, signal?: AbortSignal): Promise<void> {
signal?.throwIfAborted();
const key = githubQuotaKey(token);
const localGate = tokenGates.get(key);
let waitMs = 0;
@@ -121,7 +108,9 @@ async function waitForTokenGate(token: string): Promise<void> {
waitMs = resetAt - Date.now();
}
const redisResetAt = await getRedisGateResetAt(key);
const lookup = getRedisGateResetAt(key);
const redisResetAt = await (signal ? abortableGateLookup(lookup, signal) : lookup);
signal?.throwIfAborted();
if (redisResetAt > resetAt) {
resetAt = redisResetAt;
waitMs = resetAt - Date.now();
@@ -137,7 +126,7 @@ async function waitForTokenGate(token: string): Promise<void> {
waitMs,
resetAt: new Date(resetAt).toISOString(),
});
await new Promise((resolve) => setTimeout(resolve, waitMs));
await delay(waitMs, undefined, { signal });
if (localGate) tokenGates.delete(key);
}
@@ -255,7 +244,7 @@ export function octokit(token: string) {
const prefix = `/repos/${context.publicRepository.split("/").map(encodeURIComponent).join("/")}`.toLowerCase();
const path = url.pathname.toLowerCase();
const suffix = path.slice(prefix.length);
const readable = /^(?:\/?|\/branches(?:\/[^/]+)?|\/commits(?:\/[^/]+)?|\/readme|\/pages|\/zipball\/[^/]+|\/git\/(?:trees|blobs)\/[^/]+|\/pulls\/\d+|\/issues\/\d+\/comments)$/.test(suffix);
const readable = /^(?:\/?|\/branches(?:\/[^/]+)?|\/commits(?:\/[^/]+)?|\/contents(?:\/[^/]+)*|\/readme|\/pages|\/zipball\/[^/]+|\/git\/(?:trees|blobs)\/[^/]+|\/pulls\/\d+|\/issues\/\d+\/comments)$/.test(suffix);
if (!readable || (options.method !== "GET" && !(options.method === "HEAD" && suffix.startsWith("/zipball/"))) || url.origin !== "https://api.github.com" ||
(path !== prefix && !path.startsWith(prefix + "/"))) {
throw new AnonymousError("github_app_access_required", { httpStatus: 403 });
@@ -297,12 +286,9 @@ export async function checkToken(token: string) {
else await oct.users.getAuthenticated();
return true;
} catch (err) {
if (
err instanceof AnonymousError &&
err.message === "github_rate_limit_exceeded"
) {
throw err;
}
// Only a confirmed invalid credential permits fallback. Network failures,
// permission failures and rate limits must retain their original meaning.
if ((err as { status?: number })?.status !== 401) throw err;
return false;
}
}
@@ -310,7 +296,12 @@ export async function checkToken(token: string) {
const checkedRepositoryTokens = new WeakMap<Repository, string>();
export async function getToken(repository: Repository) {
return measureStage("authorization", () => resolveRepositoryToken(repository));
try {
return await measureStage("authorization", () => resolveRepositoryToken(repository));
} catch (error) {
void notifyOwnerAccessProblem(repository.owner.id, error, { kind: "repository", id: String(repository.model._id) });
throw error;
}
}
async function resolveRepositoryToken(repository: Repository) {
@@ -337,62 +328,18 @@ async function resolveRepositoryToken(repository: Repository) {
credential?.persisted &&
(!tokenAge || tokenAge < new Date(Date.now() - 1000 * 60 * 60 * 24 * 7))
) {
const url = `https://api.github.com/applications/${config.CLIENT_ID}/token`;
const headers = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
};
const res = await fetch(url, {
method: "PATCH",
body: JSON.stringify({
access_token: ownerAccessToken,
}),
credentials: "include",
headers: {
...headers,
Authorization:
"Basic " +
Buffer.from(
config.CLIENT_ID + ":" + config.CLIENT_SECRET
).toString("base64"),
},
});
// Only persist a refreshed token if GitHub actually returned a
// valid one. Without this guard, a 4xx/5xx error body (revoked
// OAuth, rate limit, transient outage) silently overwrites the
// user's stored token with `undefined`, which then propagates as
// `Authorization: token undefined` to every subsequent API call —
// 401 even on public repos, and the config.GITHUB_TOKEN fallback
// below is unreachable because the token field is no longer falsy.
if (res.ok) {
const resBody = (await res.json().catch(() => null)) as
| { token?: unknown }
| null;
const refreshed =
resBody && typeof resBody.token === "string" && resBody.token.length > 0
? resBody.token
: null;
if (refreshed) {
if (await replaceCredential(repository.owner.id, ownerAccessToken, refreshed)) {
checkedRepositoryTokens.set(repository, refreshed);
return refreshed;
}
return (await getCredentialToken(repository.owner.id)) || config.GITHUB_TOKEN;
}
const refreshed = await refreshLegacyToken(repository.owner.id, ownerAccessToken);
if (refreshed !== null) {
checkedRepositoryTokens.set(repository, refreshed);
return refreshed;
}
logger.warn("token refresh failed; falling back", {
code: "token_refresh_failed",
httpStatus: res.status,
username: repository.owner.model.username,
});
// fall through to the checkToken path / config.GITHUB_TOKEN
}
const check = await checkToken(ownerAccessToken);
if (check) {
checkedRepositoryTokens.set(repository, ownerAccessToken);
return ownerAccessToken;
}
void notifyOwnerAccessProblem(repository.owner.id, "token_expired", { kind: "repository", id: String(repository.model._id) });
return config.GITHUB_TOKEN;
}
return (await getCredentialToken(repository.owner.id, "github", {
+12
View File
@@ -1,3 +1,4 @@
import { notifyOwnerAccessProblem, withOwnerAccessRecovery } from "./owner-notifications";
import { isConnected } from "../server/database";
import { expireEmbeddedContent } from "./content-expiration";
import { boundAppToken } from "./github-app";
@@ -33,6 +34,17 @@ export default class PullRequest {
}
async download() {
try { await withOwnerAccessRecovery(this.owner.id,
{ kind: "pull-request", id: String(this.model._id) }, () => this.downloadContent(),
{ "source.repositoryFullName": this.model.source.repositoryFullName, "source.pullRequestId": this.model.source.pullRequestId,
"githubAccess.revision": this.model.githubAccess?.revision ?? { $exists: false } }); }
catch (error) {
void notifyOwnerAccessProblem(this.owner.id, error, { kind: "pull-request", id: String(this.model._id) });
throw error;
}
}
private async downloadContent() {
logger.info("downloading pull request", {
pullRequestId: this._model.source.pullRequestId,
});
+51 -15
View File
@@ -1,4 +1,5 @@
import { allowance, reserveRepository } from "./billing/quotas";
import { notifyOwnerAccessProblem, withOwnerAccessRecovery } from "./owner-notifications";
import AnonymizedPathModel from "./model/anonymized-path";
import storage from "./storage";
import { contentGenerationPrefix, contentRetirementMarker } from "./content-generation";
@@ -8,6 +9,7 @@ import { RepositoryStatus } from "./types";
import { Readable } from "stream";
import * as sha1 from "crypto-js/sha1";
import User from "./User";
import { SubmoduleMount } from "./source/github-submodules";
import GitHubStream from "./source/GitHubStream";
import Zip from "./source/Zip";
import {
@@ -43,14 +45,14 @@ async function buildPathIndex(repoId: string, key: string, terms: string[], gene
const task = (async () => {
const compiled = compileTerms(terms);
const cursor = FileModel.find({ repoId, treeGeneration: generation || { $exists: false } })
.select("name path sha size").sort({ path: 1, name: 1 }).lean().cursor();
.select("name path sha size upstream").sort({ path: 1, name: 1 }).lean().cursor();
let batch: Parameters<typeof AnonymizedPathModel.bulkWrite>[0] = [];
try {
for await (const file of cursor) {
const name = anonymizePathCompiled(file.name, compiled);
const directory = anonymizePathCompiled(file.path, compiled);
batch.push({ updateOne: { filter: { repoId, key, originalId: file._id }, update: { $set: {
repoId, key, originalId: file._id, name: file.name, path: file.path, sha: file.sha, size: file.size,
repoId, key, originalId: file._id, name: file.name, path: file.path, sha: file.sha, size: file.size, upstream: file.upstream,
anonymousName: name, anonymousDirectory: directory, createdAt: new Date(),
searchName: name.toLowerCase(), searchDirectory: directory.toLowerCase(),
anonymousPath: anonymizePathCompiled(file.path ? `${file.path}/${file.name}` : file.name, compiled),
@@ -202,6 +204,17 @@ export default class Repository {
return await FileModel.find(query).exec();
}
async submoduleMounts(): Promise<SubmoduleMount[]> {
if (this.model.source.type === "Zip") return [];
await this.ensureFileTree();
const mounts = await FileModel.find({ repoId: this.repoId,
treeGeneration: this.model.treeGeneration || { $exists: false }, submodule: "included",
}).select("name path upstream").lean().exec();
return mounts.filter(file => file.upstream).map(file => ({
...file.upstream!, path: [file.path, file.name].filter(Boolean).join("/"),
}));
}
private async ensureFileTree(opt: { force?: boolean; progress?: (status: string) => void } = {}) {
this.assertNotArchived();
let hasFile = this.hasEmptyTree() || await FileModel.exists({ repoId: this.repoId, treeGeneration: this.model.treeGeneration || { $exists: false } }).exec();
@@ -219,12 +232,22 @@ export default class Repository {
hasFile = null;
}
}
if (hasFile && this.source instanceof GitHubStream && await FileModel.exists({
repoId: this.repoId, treeGeneration: this.model.treeGeneration || { $exists: false }, name: ".gitmodules",
}).exec()) hasFile = null;
if (!hasFile || opt.force) {
const previousGeneration = this.model.treeGeneration;
const previousPrefix = contentGenerationPrefix(`${previousGeneration || "legacy"}:${this.model.anonymizeDate?.toISOString() || ""}`);
const streamed = this.model.source.type !== "Zip";
const source = this.source;
const files = await source.getFiles(opt.progress);
const readFiles = () => source.getFiles(opt.progress);
const files = await (streamed ? withOwnerAccessRecovery(this.owner.id,
{ kind: "repository", id: String(this.model._id) }, readFiles,
{ "source.repositoryName": this.model.source.repositoryName, "source.commit": this.model.source.commit,
"githubAccess.revision": this.model.githubAccess?.revision ?? { $exists: false } }) : readFiles()).catch(error => {
void notifyOwnerAccessProblem(this.owner.id, error, { kind: "repository", id: String(this.model._id) });
throw error;
});
if (this.model.billingManaged) await reserveRepository(String(this.model.owner), this.repoId, files.reduce((sum, file) => sum + (file.size || 0), 0), this.model.conference);
const sourceWithTruncation = source as unknown as { truncatedFolderList?: string[] };
const truncatedFolders = Array.isArray(sourceWithTruncation.truncatedFolderList)
@@ -550,8 +573,8 @@ export default class Repository {
async isReady() {
if (this.status !== RepositoryStatus.READY) return false;
if (!this.hasEmptyTree() && !(await FileModel.exists({ repoId: this.repoId, treeGeneration: this.model.treeGeneration || { $exists: false } }).exec())) {
this.model.status = RepositoryStatus.PREPARING;
await this.updateIfNeeded({ force: true });
// Missing-tree recovery competes with owner refreshes for the same job.
await this.refresh();
return false;
}
return true;
@@ -626,6 +649,14 @@ export default class Repository {
/** Update the repository if a new commit exists. */
async updateIfNeeded(opt?: { force: boolean }): Promise<void> {
try { await this.refreshIfNeeded(opt); }
catch (error) {
void notifyOwnerAccessProblem(this.owner.id, error, { kind: "repository", id: String(this.model._id) });
throw error;
}
}
private async refreshIfNeeded(opt?: { force: boolean }): Promise<void> {
this.assertNotArchived();
if (
this._model.options.expirationMode !== "never" &&
@@ -677,12 +708,14 @@ export default class Repository {
this.checkRefreshWrite(result);
}
}
const branches = await ghRepo.branches({
force: true,
accessToken: token,
});
const branchName =
this.model.source.branch || ghRepo.model.defaultBranch;
const branchName = this.model.source.branch || ghRepo.model.defaultBranch;
const branches = await withOwnerAccessRecovery(this.owner.id,
{ kind: "repository", id: String(this.model._id) },
() => ghRepo.branches({ force: true, accessToken: token }),
{ "source.repositoryName": this.model.source.repositoryName,
"githubAccess.revision": this.model.githubAccess?.revision ?? { $exists: false } },
result => !opt?.force && this.status === RepositoryStatus.READY
&& result.some(branch => branch.name === branchName && branch.commit === this.model.source.commit));
const newCommit = branches.filter((f) => f.name == branchName)[0]
?.commit;
@@ -703,7 +736,8 @@ export default class Repository {
}
if (
this.model.source.commit == newCommit &&
this.status == RepositoryStatus.READY
this.status == RepositoryStatus.READY &&
!opt?.force
) {
logger.info("up to date", { repoId: this._model.repoId });
return;
@@ -723,9 +757,11 @@ export default class Repository {
}
}
this._model.source.commit = newCommit;
const commitInfo = await ghRepo.getCommitInfo(newCommit, {
accessToken: token,
});
const commitInfo = await withOwnerAccessRecovery(this.owner.id,
{ kind: "repository", id: String(this.model._id) },
() => ghRepo.getCommitInfo(newCommit, { accessToken: token }),
{ "source.repositoryName": this.model.source.repositoryName,
"githubAccess.revision": this.model.githubAccess?.revision ?? { $exists: false } });
if (
commitInfo.commit?.author?.date ||
commitInfo.commit?.committer?.date
+4 -3
View File
@@ -531,11 +531,12 @@ export class ContentAnonimizer {
const replaced = replaceTerm(content, c, asciiContent);
if (replaced !== content) this.wasAnonymized = true;
content = replaced;
// Masks and changed boundaries can introduce later matches. Re-index
// only after a real change, never once per absent term.
// Masks and changed boundaries can introduce later matches. Invalidate
// the original-text index and check remaining rules directly; rebuilding
// the index after every change costs more than those presence checks.
if (content !== previous) {
asciiContent = asciiContent && /^\p{ASCII}*$/u.test(c.mask);
candidates = this.termPresence?.candidates(content, index);
candidates = undefined;
}
}
return content;
+13 -1
View File
@@ -1,5 +1,17 @@
import { Archiver, EntryData } from "archiver";
import { Archiver, EntryData, ZipEntryData } from "archiver";
import { extname } from "path";
import { Readable } from "stream";
const compressedExtensions = new Set([
".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".heif", ".heic",
".mp4", ".m4v", ".mov", ".webm", ".mp3", ".m4a", ".aac", ".ogg", ".flac",
".zip", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".zst", ".woff", ".woff2",
]);
/** Store already compressed assets; every entry still passes through anonymization. */
export function zipEntryOptions(name: string, originalName = name): ZipEntryData {
return { name, store: compressedExtensions.has(extname(originalName).toLowerCase()) };
}
/** Allow only one active entry; compression and the response provide backpressure. */
export function appendArchiveEntry(archive: Archiver, source: Readable, options: EntryData): Promise<void> {
return new Promise((resolve, reject) => {
+17
View File
@@ -0,0 +1,17 @@
/** A bounded cache for short-lived failures. Keys must include access and content versions. */
export class ExpiringMap<T> {
private entries = new Map<string, { value: T; expires: number }>();
constructor(private capacity: number, private now = () => Date.now()) {}
get(key: string): T | undefined {
const entry = this.entries.get(key);
if (!entry) return undefined;
if (entry.expires <= this.now()) { this.entries.delete(key); return undefined; }
return entry.value;
}
set(key: string, value: T, ttl: number) {
for (const [key, entry] of this.entries) if (entry.expires <= this.now()) this.entries.delete(key);
this.entries.delete(key);
if (this.entries.size >= this.capacity) this.entries.delete(this.entries.keys().next().value!);
this.entries.set(key, { value, expires: this.now() + ttl });
}
}
+26
View File
@@ -0,0 +1,26 @@
// Octokit RequestError shape (subset we care about for rate-limit detection).
export interface OctokitRequestErrorLike {
status?: number;
httpStatus?: number;
message?: string;
response?: {
statusCode?: number;
headers?: Record<string, string | undefined>;
};
}
/** Detect GitHub primary and secondary rate limits in API and stream errors. */
export function isGitHubRateLimitError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const e = err as OctokitRequestErrorLike;
const msg = (e.message ?? "").toLowerCase();
// Primary limits return 403 with "x-ratelimit-remaining: 0"; secondary
// limits return 403 (sometimes 429) with "secondary rate limit" in the
// body. Match on either signal so we catch both.
const status = e.status ?? e.response?.statusCode ?? e.httpStatus ?? 0;
if (status !== 403 && status !== 429) return false;
if (msg.includes("rate limit") || msg.includes("abuse")) return true;
const remaining = e.response?.headers?.["x-ratelimit-remaining"];
return remaining === "0";
}
+52
View File
@@ -0,0 +1,52 @@
import { createHash } from "crypto";
import config from "../config";
import { getCredentialToken, replaceCredential } from "./credentials";
import { ExpiringMap } from "./expiring-map";
import { createLogger } from "./logger";
const logger = createLogger("github");
const cooldowns = new ExpiringMap<boolean>(1024);
const pending = new Map<string, Promise<string | null>>();
/** Only legacy OAuth tokens belong to the OAuth application's reset endpoint. */
export async function refreshLegacyToken(ownerId: string, previous: string): Promise<string | null> {
if (!config.GITHUB_OAUTH_ENABLED || !config.CLIENT_ID || !config.CLIENT_SECRET ||
config.CLIENT_ID === "CLIENT_ID" || config.CLIENT_SECRET === "CLIENT_SECRET" ||
/^(gh[psur]_|github_pat_)/.test(previous)) return null;
const key = createHash("sha256").update(JSON.stringify([ownerId, previous, config.CLIENT_ID, config.CLIENT_SECRET])).digest("hex");
if (cooldowns.get(key)) return null;
const running = pending.get(key);
if (running) return running;
if (pending.size >= 1024) return null;
const refresh = async () => {
let status = 502;
try {
const res = await fetch(`https://api.github.com/applications/${encodeURIComponent(config.CLIENT_ID)}/token`, {
method: "PATCH", body: JSON.stringify({ access_token: previous }),
signal: AbortSignal.timeout(5000),
headers: { Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28",
Authorization: "Basic " + Buffer.from(config.CLIENT_ID + ":" + config.CLIENT_SECRET).toString("base64") },
});
status = res.status;
if (res.ok) {
const body = await res.json().catch(() => null) as { token?: unknown } | null;
if (typeof body?.token === "string" && body.token.length) {
if (await replaceCredential(ownerId, previous, body.token)) return body.token;
return (await getCredentialToken(ownerId)) || config.GITHUB_TOKEN;
}
} else {
// Release the response connection even when only the status matters.
await res.body?.cancel();
}
} catch {
// The existing token can still work during a transient reset-endpoint failure.
}
cooldowns.set(key, true, status === 401 || status === 404 ? 30 * 60_000 : 60_000);
logger.warn("token refresh failed; backing off", { code: "token_refresh_failed", httpStatus: status });
return null;
};
const work = refresh();
pending.set(key, work);
try { return await work; }
finally { if (pending.get(key) === work) pending.delete(key); }
}
+4 -1
View File
@@ -320,7 +320,10 @@ export function serializeError(err: unknown): Record<string, unknown> {
// AnonymousError carries an httpStatus and an inner cause.
if (typeof e.httpStatus === "number") out.httpStatus = e.httpStatus;
if (e.code !== undefined && e.code !== e.message) out.code = e.code;
// Octokit's deprecated `code` getter emits a stack on every read. Only
// inspect data properties so logging never invokes that getter.
const code = Object.getOwnPropertyDescriptor(e, "code")?.value;
if (code !== undefined && code !== e.message) out.code = code;
if (typeof e.url === "function") {
try {
const u = e.url();
+2
View File
@@ -1,7 +1,9 @@
import { UpstreamFileSchema } from "./files/files.schema";
import { IFile } from "./files/files.types";
import { model, Schema } from "mongoose";
const schema = new Schema({ repoId: { type: String, required: true }, key: { type: String, required: true }, originalId: Schema.Types.ObjectId,
name: { type: String, required: true }, path: { type: String, default: "", required: true }, sha: String, size: Number,
upstream: { type: UpstreamFileSchema, default: undefined },
anonymousName: String, anonymousDirectory: String, anonymousPath: String,
searchName: String, searchDirectory: String,
createdAt: { type: Date, default: Date.now } });
@@ -1,6 +1,11 @@
import { ownerAlertLifecycle } from "../owner-alert-lifecycle.schema";
import { Schema } from "mongoose";
const AnonymizedGistSchema = new Schema({
accessAlertGeneration: { type: String, select: false },
accessAlertClaimedAt: { type: Date, select: false },
accessAlertAttemptId: { type: String, select: false },
accessAlertClaimRevision: { type: Number, select: false },
gistId: {
type: String,
index: { unique: true },
@@ -70,4 +75,6 @@ const AnonymizedGistSchema = new Schema({
AnonymizedGistSchema.index({ status: 1, "options.expirationDate": 1 });
AnonymizedGistSchema.plugin(ownerAlertLifecycle);
export default AnonymizedGistSchema;
@@ -1,7 +1,12 @@
import { ownerAlertLifecycle } from "../owner-alert-lifecycle.schema";
import { repositoryAccessSchema } from "../repository-access.schema";
import { Schema } from "mongoose";
const AnonymizedPullRequestSchema = new Schema({
accessAlertGeneration: { type: String, select: false },
accessAlertClaimedAt: { type: Date, select: false },
accessAlertAttemptId: { type: String, select: false },
accessAlertClaimRevision: { type: Number, select: false },
pullRequestId: {
type: String,
index: { unique: true },
@@ -67,4 +72,6 @@ const AnonymizedPullRequestSchema = new Schema({
AnonymizedPullRequestSchema.index({ status: 1, "options.expirationDate": 1 });
AnonymizedPullRequestSchema.plugin(ownerAlertLifecycle);
export default AnonymizedPullRequestSchema;
@@ -1,9 +1,15 @@
import { ownerAlertLifecycle } from "../owner-alert-lifecycle.schema";
import { repositoryAccessSchema } from "../repository-access.schema";
import { Schema } from "mongoose";
const AnonymizedRepositorySchema = new Schema({
billingManaged: Boolean,
billingNeverExpiresUntil: Date,
accessAlertGeneration: { type: String, select: false },
accessAlertClaimedAt: { type: Date, select: false },
accessAlertAttemptId: { type: String, select: false },
accessAlertClaimRevision: { type: Number, select: false },
reviewConsentRevision: { type: Number, select: false },
repoId: {
type: String,
index: { unique: true, collation: { locale: "en", strength: 2 } },
@@ -120,4 +126,6 @@ AnonymizedRepositorySchema.index({
"options.expirationDate": 1,
});
AnonymizedRepositorySchema.plugin(ownerAlertLifecycle);
export default AnonymizedRepositorySchema;
+4
View File
@@ -1,5 +1,7 @@
import { Schema } from "mongoose";
export const UpstreamFileSchema = new Schema({ organization: String, repoName: String, commit: String, path: String }, { _id: false });
const FileSchema = new Schema({
name: { type: String, index: true },
path: { type: String, index: true },
@@ -7,6 +9,8 @@ const FileSchema = new Schema({
treeGeneration: String,
treeStaged: Boolean,
metadataPending: Boolean,
upstream: { type: UpstreamFileSchema, default: undefined },
submodule: { type: String, enum: ["included", "unsupported", "unavailable", "limit", "nested"] },
sha: {
type: String,
},
+10
View File
@@ -1,6 +1,16 @@
import { Document, Model } from "mongoose";
export interface UpstreamFile {
organization: string;
repoName: string;
commit: string;
path: string;
}
export interface IFile {
/** Internal source metadata, never returned by the public tree API. */
upstream?: UpstreamFile;
submodule?: "included" | "unsupported" | "unavailable" | "limit" | "nested";
name: string;
path: string;
repoId: string;
@@ -0,0 +1,54 @@
import { randomUUID } from "crypto";
import { Document, Query, Schema, Types, mongo } from "mongoose";
const inactive = new Set(["archived", "expired", "expiring", "removed", "removing"]);
type Owner = { _id: Types.ObjectId; accessAlertResetRevision?: number; accessAlertDeactivations?: string[] };
type Fence = { users: mongo.Collection<Owner>; owners: Types.ObjectId[]; token: string };
/** Lifecycle mutations and email dispatch compete on the owner document. */
export function ownerAlertLifecycle(schema: Schema): void {
const fences = new WeakMap<object, Fence>();
const begin = async (operation: object, db: { collection(name: string): unknown }, owners: Types.ObjectId[]) => {
if (!owners.length) return;
const users = db.collection("users") as mongo.Collection<Owner>;
const token = randomUUID();
// Do not expire this reservation: an interrupted database mutation may still
// commit. Offline reconciliation can clear it after all writers have stopped.
await users.updateMany({ _id: { $in: owners } }, {
$inc: { accessAlertResetRevision: 1 }, $addToSet: { accessAlertDeactivations: token },
});
fences.set(operation, { users, owners, token });
};
const finish = async (operation: object) => {
const fence = fences.get(operation);
if (!fence) return;
await fence.users.updateMany({ _id: { $in: fence.owners } }, { $pull: { accessAlertDeactivations: fence.token } });
fences.delete(operation);
};
schema.pre(["updateOne", "updateMany", "findOneAndUpdate"], async function (this: Query<unknown, unknown>) {
const update = this.getUpdate();
if (!update) return;
const stages = (Array.isArray(update) ? update : [update]) as { $set?: { status?: unknown }; status?: unknown }[];
if (!stages.some(stage => { const status = stage.$set?.status ?? stage.status; return typeof status === "string" && inactive.has(status); })) return;
const filter = this.getFilter();
const lookup = this.model.distinct("owner", filter);
const collation = this.getOptions().collation;
if (collation) lookup.collation(collation);
const owners = (await lookup.exec()).filter(id => id && Types.ObjectId.isValid(id));
await begin(this, this.model.db, owners);
// Preserve the owner snapshot: reassignment must not move this mutation to
// an account whose dispatch fence was not advanced.
this.setQuery({ $and: [filter, { $or: [{ owner: { $in: owners } }, { owner: null }] }] });
});
schema.post(["updateOne", "updateMany", "findOneAndUpdate"], async function () { await finish(this); });
schema.pre("save", async function (this: Document & { status?: string; owner?: Types.ObjectId; $where?: Record<string, unknown> }) {
if (!inactive.has(this.status || "") || !this.isModified("status")) return;
const current = this.isNew ? null : await this.collection.findOne({ _id: this._id }, { projection: { owner: 1 } });
const owners = [current?.owner, this.owner].filter((id): id is Types.ObjectId => Boolean(id && Types.ObjectId.isValid(id)));
await begin(this, this.db, owners);
if (current) this.$where = { $and: [this.$where || {}, { owner: current.owner ?? null }] };
});
schema.post("save", async function () { await finish(this); });
// Failed or ambiguous mutations retain their reservation. Releasing one here
// could permit dispatch while the resource write subsequently completes.
}
+12
View File
@@ -1,6 +1,7 @@
import { Schema } from "mongoose";
const UserSchema = new Schema({
reviewConsentRevision: { type: Number, select: false },
accessTokens: {
github: { type: String, select: false },
},
@@ -14,6 +15,14 @@ const UserSchema = new Schema({
type: String,
index: { unique: true },
},
notificationEmail: { type: String },
emailPromptNever: { type: Boolean, default: false },
accessAlertAfter: { type: Date, select: false },
accessAlertResetRevision: { type: Number, select: false },
accessAlertResetUntil: { type: Date, select: false },
accessAlertFenceRevision: { type: Number, select: false },
accessAlertDeactivations: { type: [String], default: undefined, select: false },
accessAlertDispatches: { type: Schema.Types.Mixed, select: false },
emails: [
{
email: { type: String },
@@ -27,6 +36,9 @@ const UserSchema = new Schema({
name: { type: String },
createdAt: { type: Date, default: Date.now },
lastUsedAt: { type: Date },
scope: { type: String, enum: ["repository-maintenance"] },
repoIds: { type: [String], default: undefined },
expiresAt: { type: Date },
},
],
photo: String,
+5
View File
@@ -19,7 +19,12 @@ export interface IUser {
name?: string;
createdAt?: Date;
lastUsedAt?: Date;
scope?: "repository-maintenance";
repoIds?: string[];
expiresAt?: Date;
}[];
notificationEmail?: string;
emailPromptNever?: boolean;
emails: {
email: string;
default: boolean;
+623
View File
@@ -0,0 +1,623 @@
import { createHash, randomUUID } from "crypto";
import { mongo, Types } from "mongoose";
import { isGitHubRateLimitError } from "./github-rate-limit";
import config from "../config";
import UserModel from "./model/users/users.model";
import { createLogger } from "./logger";
const logger = createLogger("owner-notifications");
const resourceCollections = {
repository: "anonymizedrepositories",
"pull-request": "anonymizedpullrequests",
gist: "anonymizedgists",
} as const;
export interface AlertResource { kind: keyof typeof resourceCollections; id: string; }
// Only explicit owner actions call this; background fetches never reset alerts.
export async function resetOwnerAccessAlerts(ownerId: string, resource?: AlertResource): Promise<void> {
if (UserModel.db.readyState !== 1 || !Types.ObjectId.isValid(ownerId)) return;
try {
const users = UserModel.db.collection("users");
const owner = new Types.ObjectId(ownerId);
const reset = await users.findOneAndUpdate({ _id: owner }, { $inc: { accessAlertResetRevision: 1 },
$set: { accessAlertResetUntil: new Date(Date.now() + 60000) } }, { returnDocument: "after" });
if (!reset.value) return;
const revision = reset.value.accessAlertResetRevision;
const heartbeat = setInterval(() => {
void users.updateOne({ _id: owner, accessAlertResetRevision: revision, accessAlertResetUntil: { $exists: true } },
{ $set: { accessAlertResetUntil: new Date(Date.now() + 60000) } }).catch(() => {});
}, 10000);
try {
const filter = { owner: new Types.ObjectId(ownerId),
...(resource ? { _id: new Types.ObjectId(resource.id) } : {}) };
const claim = { owner: new Types.ObjectId(ownerId),
...(resource ? { resourceKind: resource.kind, resourceId: new Types.ObjectId(resource.id) } : {}) };
await alertCollection().updateMany({ ...claim, legacyClaims: { $exists: false } }, { $inc: { resetRevision: 1 } });
await alertCollection().updateMany({ legacyClaims: { $elemMatch: claim } }, [{ $set: {
resetRevision: { $add: [{ $ifNull: ["$resetRevision", 0] }, 1] },
legacyClaims: { $map: { input: "$legacyClaims", as: "claim", in: { $cond: [
{ $and: Object.entries(claim).map(([key, value]) => ({ $eq: [`$$claim.${key}`, value] })) },
{ $mergeObjects: ["$$claim", { recovered: false }] }, "$$claim",
] } } },
state: { $cond: [{ $eq: ["$state", "recovered"] }, "legacy", "$state"] },
} }]);
for (const collection of resource ? [resourceCollections[resource.kind]] : Object.values(resourceCollections)) {
await UserModel.db.collection(collection).updateMany(filter, { $unset: { accessAlertClaimedAt: "", accessAlertAttemptId: "" }, $set: { accessAlertGeneration: randomUUID() } });
}
} finally {
clearInterval(heartbeat);
const finished = await users.updateOne({ _id: owner, accessAlertResetRevision: revision }, { $unset: { accessAlertResetUntil: "" } });
if (finished.matchedCount) {
for await (const candidate of alertCollection().find({ deferredRecoveries: { $elemMatch: { owner } } })) {
await applyDeferredRecoveries(candidate._id, candidate.attemptId);
}
}
}
} catch { logger.warn("owner access alert reset unavailable"); }
}
export const RECIPIENT_ALERTS = "owner_access_alert_recipients";
type LegacyClaim = { owner: Types.ObjectId; resourceKind: AlertResource["kind"]; resourceId: Types.ObjectId; generation: string | null; recovered?: boolean };
type DeferredRecovery = { owner: Types.ObjectId; resourceKind: AlertResource["kind"]; resourceId: Types.ObjectId;
generation: string | null; resetRevision: number | null; ownerRevision: number | null; bindingFields: string[]; bindingHash: string };
type AlertRecord = { _id: string; state?: string; lease?: string; leaseUntil?: Date;
owner?: Types.ObjectId; resourceKind?: string; resourceId?: Types.ObjectId;
generation?: string | null; attemptId?: string; claimedAt?: Date; completedAt?: Date;
recipientKey?: string; resetRevision?: number; httpStatus?: number; startedAt?: Date;
progress?: Record<string, { claimedAt: Date; id: Types.ObjectId }>; legacyClaims?: LegacyClaim[];
resourceRevision?: number; deferredRecoveries?: DeferredRecovery[]; dispatchPrepared?: boolean;
deliveryProgress?: { completedAt: Date; id: string }; failureCode?: string };
const alertCollection = (name = RECIPIENT_ALERTS) => UserModel.db.collection(name) as unknown as mongo.Collection<AlertRecord>;
const MIGRATION = "recipient-alerts-v2";
const PENDING_LEASE_MS = 60000;
const expiredPending = () => ({ state: "pending", claimedAt: { $lte: new Date(Date.now() - PENDING_LEASE_MS) } });
const expiredPreparation = () => ({ state: { $in: ["pending", "sending"] }, claimedAt: { $lte: new Date(Date.now() - PENDING_LEASE_MS) } });
const revisionFilter = (revision: number) => revision === 0 ? { $in: [0, null] } : revision;
const generationFilter = (generation: unknown) => generation ?? { $exists: false };
export const recipientAlertKey = (email: string) => createHash("sha256").update(email.trim().toLowerCase()).digest("hex");
const bindingHash = (row: Record<string, unknown>, fields: string[]) => createHash("sha256").update(JSON.stringify(fields.map(field => {
const value = field.split(".").reduce<unknown>((value, key) => value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined, row);
return [field, value !== undefined, value];
}))).digest("hex");
async function prepareOwnerDispatch(record: AlertRecord, revision: number | null): Promise<boolean> {
const path = `accessAlertDispatches.${record.attemptId}`;
const prepared = await UserModel.db.collection("users").updateOne({ _id: record.owner,
accessAlertResetRevision: generationFilter(revision),
"accessAlertDeactivations.0": { $exists: false },
$or: [{ accessAlertResetUntil: { $exists: false } }, { accessAlertResetUntil: { $lte: new Date() } }],
}, [{ $set: { accessAlertDispatches: { $arrayToObject: { $filter: {
input: { $objectToArray: { $ifNull: ["$accessAlertDispatches", {}] } }, as: "dispatch",
cond: { $or: [{ $eq: ["$$dispatch.v.state", "committed"] },
{ $gt: ["$$dispatch.v.claimedAt", new Date(Date.now() - PENDING_LEASE_MS)] }] },
} } } } }, { $set: { [path]: { state: "prepared", claimedAt: record.claimedAt } } }]);
return Boolean(prepared.matchedCount);
}
async function clearOwnerDispatch(record: AlertRecord, state: string): Promise<void> {
if (!record.dispatchPrepared || !record.owner || !record.attemptId) return;
const path = `accessAlertDispatches.${record.attemptId}`;
await UserModel.db.collection("users").updateOne({ _id: record.owner, [`${path}.state`]: state }, { $unset: { [path]: "" } });
}
async function commitOwnerRevision(owner: Types.ObjectId, revision: number | null | undefined, record?: AlertRecord): Promise<boolean> {
const path = `accessAlertDispatches.${record?.attemptId}`;
const committed = await UserModel.db.collection("users").updateOne({ _id: owner,
accessAlertResetRevision: generationFilter(revision),
"accessAlertDeactivations.0": { $exists: false },
...(record ? { [`${path}.state`]: "prepared" } : {}),
$or: [{ accessAlertResetUntil: { $exists: false } }, { accessAlertResetUntil: { $lte: new Date() } }],
}, record ? { $set: { [`${path}.state`]: "committed" } } : { $inc: { accessAlertFenceRevision: 1 } });
return Boolean(committed.matchedCount);
}
async function releaseUnsentAlert(record: AlertRecord, expected: mongo.Filter<AlertRecord>): Promise<boolean> {
if (!record.attemptId) return false;
const alerts = alertCollection();
if (record.dispatchPrepared && record.owner) {
const users = UserModel.db.collection("users");
const path = `accessAlertDispatches.${record.attemptId}`;
// This CAS competes with the final dispatch commitment on the owner document.
const cancelled = await users.updateOne({ _id: record.owner, [`${path}.state`]: "prepared" },
{ $set: { [`${path}.state`]: "cancelled" } });
if (!cancelled.matchedCount) {
const owner = await users.findOne({ _id: record.owner }, { projection: { [path]: 1 } });
if (!owner || owner.accessAlertDispatches?.[record.attemptId]?.state === "committed") return false;
}
}
// A durable cancellation prevents a paused sender from starting dispatch.
const cancelled = await alerts.updateOne({ _id: record._id, attemptId: record.attemptId, ...expected }, { $set: { state: "cancelled" } });
if (!cancelled.matchedCount) return false;
if (record.owner && record.resourceKind && record.resourceKind in resourceCollections && record.resourceId) {
const resource = UserModel.db.collection(resourceCollections[record.resourceKind as AlertResource["kind"]]);
const revision = record.resourceRevision ?? 0;
const matching = {
_id: record.resourceId, owner: record.owner, accessAlertGeneration: generationFilter(record.generation),
$or: [{ accessAlertClaimRevision: revisionFilter(revision) },
{ accessAlertClaimRevision: revision + 1, accessAlertAttemptId: record.attemptId }],
};
await retryAlertWrite(() => resource.updateOne(matching, [{ $set: {
accessAlertClaimRevision: { $add: [{ $ifNull: ["$accessAlertClaimRevision", 0] }, 1] },
accessAlertClaimedAt: { $cond: [{ $eq: ["$accessAlertAttemptId", record.attemptId] }, "$$REMOVE", "$accessAlertClaimedAt"] },
accessAlertAttemptId: { $cond: [{ $eq: ["$accessAlertAttemptId", record.attemptId] }, "$$REMOVE", "$accessAlertAttemptId"] },
} }]));
}
const removed = await alerts.deleteOne({ _id: record._id, attemptId: record.attemptId, state: "cancelled" });
if (removed.deletedCount) await clearOwnerDispatch(record, "cancelled");
return Boolean(removed.deletedCount);
}
async function reconcileUnsentAlerts(writersStopped = false): Promise<void> {
const pending = writersStopped ? { state: "pending" } : expiredPending();
const preparing = { ...(writersStopped ? { state: "sending" } : { ...expiredPreparation(), state: "sending" }), dispatchPrepared: true };
for await (const record of alertCollection().find({ $or: [pending, preparing, { state: "cancelled" }] })) {
await releaseUnsentAlert(record, record.state === "cancelled" ? { state: "cancelled" }
: writersStopped ? { state: record.state } : expiredPreparation());
}
}
/** Call only after all application writers have stopped. */
export async function prepareLegacyOwnerAlertMigration(): Promise<void> {
await reconcileUnsentAlerts(true);
// Interrupted lifecycle mutations cannot resume once every writer is stopped.
await UserModel.db.collection("users").updateMany({ accessAlertDeactivations: { $exists: true } },
{ $unset: { accessAlertDeactivations: "" }, $inc: { accessAlertResetRevision: 1 } });
await alertCollection().updateOne({ _id: MIGRATION }, { $set: { state: "new" },
$unset: { progress: "", deliveryProgress: "", lease: "", leaseUntil: "" } }, { upsert: true });
}
async function reconcileCompletedSend(record: AlertRecord): Promise<void> {
if (record.state !== "sending" || !record.attemptId) return;
const completion = await alertCollection("owner_access_alert_events").findOne({ _id: `${record.attemptId}/completion` });
if (!completion || !["accepted", "failed"].includes(completion.state || "")) return;
await alertCollection().updateOne({ _id: record._id, attemptId: record.attemptId, state: "sending" },
{ $set: { state: completion.state, completedAt: completion.completedAt, httpStatus: completion.httpStatus } });
await clearOwnerDispatch(record, "committed");
await applyDeferredRecoveries(record._id, record.attemptId);
}
async function reconcileCompletedSends(): Promise<void> {
for await (const record of alertCollection().find({ state: "sending" })) await reconcileCompletedSend(record);
for await (const record of alertCollection().find({ state: { $in: ["accepted", "failed", "legacy"] },
$or: [{ deferredRecoveries: { $exists: true } }, { dispatchPrepared: true }] })) {
await clearOwnerDispatch(record, "committed");
await applyDeferredRecoveries(record._id, record.attemptId);
}
}
async function retryAlertWrite(work: () => Promise<unknown>): Promise<void> {
for (let attempt = 0; ; attempt++) {
try { await work(); return; }
catch (error) {
if (attempt === 2) throw error;
await new Promise(resolve => setTimeout(resolve, 50 * (attempt + 1)));
}
}
}
/** Seed the recipient guard once before any new application instance serves traffic. */
export async function initializeOwnerAccessAlerts(): Promise<void> {
const alerts = alertCollection();
await alerts.createIndex({ owner: 1, resourceKind: 1, resourceId: 1 });
await alerts.createIndex({ "legacyClaims.owner": 1, "legacyClaims.resourceKind": 1, "legacyClaims.resourceId": 1 });
await alerts.createIndex({ claimedAt: 1 }, { name: "owner_access_alert_pending", partialFilterExpression: { state: "pending" } });
await alerts.createIndex({ state: 1 });
await alerts.createIndex({ "deferredRecoveries.owner": 1 });
// Clear known-unsent legacy fields before they can be mistaken for deliveries.
await reconcileUnsentAlerts();
try { await alerts.updateOne({ _id: MIGRATION }, { $setOnInsert: { state: "new" } }, { upsert: true }); }
catch (error) { if ((error as { code?: number }).code !== 11000) throw error; }
const lease = randomUUID();
for (;;) {
if ((await alerts.findOne({ _id: MIGRATION }))?.state === "ready") {
await reconcileCompletedSends();
await reconcileUnsentAlerts();
return;
}
const now = new Date();
const claim = await alerts.updateOne({ _id: MIGRATION, state: { $ne: "ready" },
$or: [{ leaseUntil: { $lt: now } }, { leaseUntil: { $exists: false } }],
}, { $set: { lease, leaseUntil: new Date(Date.now() + 60000) } });
if (!claim.modifiedCount) { await new Promise(resolve => setTimeout(resolve, 250)); continue; }
let heartbeatError: unknown;
const heartbeat = setInterval(() => {
void alerts.updateOne({ _id: MIGRATION, lease }, { $set: { leaseUntil: new Date(Date.now() + 60000) } })
.then(result => { if (!result.matchedCount) heartbeatError = new Error("owner_alert_migration_lease_lost"); })
.catch(error => { heartbeatError = error; });
}, 5000);
try {
// Delivery evidence survives v1 guard recovery and resource deletion. Restore
// missing guards once, before traffic, so those addresses cannot be emailed again.
const events = alertCollection("owner_access_alert_events");
const index = "owner_access_alert_delivery_history";
await events.createIndex({ completedAt: 1, _id: 1 }, { name: index,
partialFilterExpression: { completedAt: { $exists: true } } });
const checkpoint = (await alerts.findOne({ _id: MIGRATION, lease }))?.deliveryProgress;
const filter = { state: { $in: ["accepted", "failed"] }, completedAt: { $exists: true },
...(checkpoint ? { $or: [{ completedAt: { $gt: checkpoint.completedAt } },
{ completedAt: checkpoint.completedAt, _id: { $gt: checkpoint.id } }] } : {}) };
let lastDelivery: { completedAt: Date; id: string } | undefined;
let delivered = 0;
const saveDeliveries = async () => {
if (!lastDelivery) return;
const saved = await alerts.updateOne({ _id: MIGRATION, lease }, { $set: { deliveryProgress: lastDelivery } });
if (!saved.matchedCount) throw new Error("owner_alert_migration_lease_lost");
};
for await (const event of events.find(filter).sort({ completedAt: 1, _id: 1 }).hint(index).batchSize(100)) {
if (heartbeatError) throw heartbeatError;
if (event.recipientKey && /^[0-9a-f]{64}$/.test(event.recipientKey)) {
const previous = await alerts.findOne({ _id: event.recipientKey });
if (previous && (["pending", "cancelled"].includes(previous.state || "")
|| (previous.state === "sending" && previous.dispatchPrepared))) {
// A v1 repeat may still be preparing after its earlier guard was
// released. Cancel known-unsent work even while its lease is fresh;
// committed or ambiguous dispatches remain blocked instead.
await releaseUnsentAlert(previous, { state: previous.state });
}
await alerts.updateOne({ _id: event.recipientKey }, { $setOnInsert: {
state: "recipient_notified", attemptId: event.attemptId ?? randomUUID(),
claimedAt: event.completedAt, completedAt: event.completedAt, httpStatus: event.httpStatus,
} }, { upsert: true });
}
lastDelivery = { completedAt: event.completedAt!, id: event._id };
if (++delivered % 100 === 0) await saveDeliveries();
}
await saveDeliveries();
for (const [kind, name] of Object.entries(resourceCollections)) {
const resources = UserModel.db.collection(name);
const index = "owner_access_alert_backfill";
await resources.createIndex({ accessAlertClaimedAt: 1, _id: 1 },
{ name: index, partialFilterExpression: { accessAlertClaimedAt: { $exists: true } } });
const checkpoint = (await alerts.findOne({ _id: MIGRATION, lease }))?.progress?.[kind];
const filter = { accessAlertClaimedAt: { $exists: true }, ...(checkpoint ? { $or: [
{ accessAlertClaimedAt: { $gt: checkpoint.claimedAt } },
{ accessAlertClaimedAt: checkpoint.claimedAt, _id: { $gt: checkpoint.id } },
] } : {}) };
const cursor = resources.find(filter,
{ projection: { owner: 1, accessAlertGeneration: 1, accessAlertClaimedAt: 1 } })
.sort({ accessAlertClaimedAt: 1, _id: 1 }).hint(index).batchSize(100);
let count = 0;
let last: { claimedAt: Date; id: Types.ObjectId } | undefined;
const saveProgress = async () => {
if (!last) return;
const saved = await alerts.updateOne({ _id: MIGRATION, lease }, { $set: { [`progress.${kind}`]: last } });
if (!saved.matchedCount) throw new Error("owner_alert_migration_lease_lost");
};
for await (const row of cursor) {
if (heartbeatError) throw heartbeatError;
last = { claimedAt: row.accessAlertClaimedAt, id: row._id };
const owner = await UserModel.findById(row.owner).select("emails notificationEmail status").lean();
const email = notificationEmail(owner?.emails, owner?.notificationEmail);
const legacyClaim: LegacyClaim = { owner: row.owner, resourceKind: kind as AlertResource["kind"],
resourceId: row._id, generation: row.accessAlertGeneration ?? null, recovered: false };
if (email && owner && !["removed", "banned"].includes(owner.status || "")) {
const key = recipientAlertKey(email);
const previous = await alerts.findOne({ _id: key });
const claims = [legacyClaim];
if (previous && !previous.legacyClaims && previous.owner && previous.resourceKind && previous.resourceId) {
claims.push({ owner: previous.owner, resourceKind: previous.resourceKind as AlertResource["kind"],
resourceId: previous.resourceId, generation: previous.generation ?? null, recovered: false });
}
await alerts.updateOne({ _id: key, legacyClaims: { $elemMatch: { owner: legacyClaim.owner,
resourceKind: legacyClaim.resourceKind, resourceId: legacyClaim.resourceId, generation: legacyClaim.generation } } },
{ $set: { "legacyClaims.$.recovered": false } });
await alerts.updateOne({ _id: key }, { $setOnInsert: { owner: legacyClaim.owner, resourceKind: kind,
resourceId: row._id, generation: legacyClaim.generation, attemptId: randomUUID(),
claimedAt: row.accessAlertClaimedAt, state: "legacy",
}, $addToSet: { legacyClaims: { $each: claims } } }, { upsert: true });
await alerts.updateOne({ _id: key, state: { $in: ["recovered", "pending"] } }, { $set: { state: "legacy" } });
}
if (++count % 100 === 0) await saveProgress();
}
await saveProgress();
}
if (heartbeatError) throw heartbeatError;
const completed = await alerts.updateOne({ _id: MIGRATION, lease }, { $set: { state: "ready" }, $unset: { lease: "", leaseUntil: "" } });
if (!completed.matchedCount) throw new Error("owner_alert_migration_lease_lost");
await reconcileCompletedSends();
await reconcileUnsentAlerts();
return;
} finally { clearInterval(heartbeat); }
}
}
async function completeRecovery(candidate: AlertRecord, recovery: DeferredRecovery): Promise<void> {
if (candidate.state !== "pending") await clearOwnerDispatch(candidate, "committed");
const fence = { _id: candidate._id, attemptId: candidate.attemptId, resetRevision: generationFilter(recovery.resetRevision),
state: { $in: ["pending", "accepted", "failed", "legacy"] } };
if (candidate.legacyClaims) {
await alertCollection().updateOne(fence, [{ $set: { legacyClaims: { $map: {
input: "$legacyClaims", as: "claim", in: { $cond: [{ $and: [
{ $eq: ["$$claim.owner", recovery.owner] }, { $eq: ["$$claim.resourceKind", recovery.resourceKind] },
{ $eq: ["$$claim.resourceId", recovery.resourceId] }, { $ne: ["$$claim.generation", recovery.generation] },
] }, { $mergeObjects: ["$$claim", { recovered: true }] }, "$$claim"] },
} } } }, { $set: { state: { $cond: [{ $allElementsTrue: [{ $map: {
input: "$legacyClaims", as: "claim", in: { $eq: ["$$claim.recovered", true] },
} }] }, "recovered", "$state"] } } }]);
} else if (candidate.state === "pending") {
await releaseUnsentAlert(candidate, { ...fence, state: "pending" });
} else {
// Recovery does not authorize another generic email to the same address.
await alertCollection().updateOne(fence, { $set: { state: "recovered" } });
}
}
const delayedRecoveries = new Map<string, NodeJS.Timeout>();
function deferRecoveryUntil(key: string, attemptId: string | undefined, until: Date): void {
const timerKey = `${key}/${attemptId}`;
if (delayedRecoveries.has(timerKey)) return;
const timer = setTimeout(() => {
delayedRecoveries.delete(timerKey);
void retryAlertWrite(() => applyDeferredRecoveries(key, attemptId))
.catch(() => logger.warn("owner access email deferred recovery unavailable"));
}, Math.max(1, until.getTime() - Date.now() + 1));
timer.unref();
delayedRecoveries.set(timerKey, timer);
}
async function applyDeferredRecoveries(key: string, attemptId?: string): Promise<void> {
const timerKey = `${key}/${attemptId}`;
clearTimeout(delayedRecoveries.get(timerKey));
delayedRecoveries.delete(timerKey);
const candidate = await alertCollection().findOne({ _id: key, attemptId });
if (!candidate || !["pending", "accepted", "failed", "legacy"].includes(candidate.state || "")) return;
for (const recovery of candidate.deferredRecoveries ?? []) {
if (recovery.resetRevision !== (candidate.resetRevision ?? null)) continue;
const row = await UserModel.db.collection(resourceCollections[recovery.resourceKind]).findOne({
_id: recovery.resourceId, owner: recovery.owner, accessAlertGeneration: generationFilter(recovery.generation),
});
if (!row || bindingHash(row, recovery.bindingFields) !== recovery.bindingHash) continue;
if (!await commitOwnerRevision(recovery.owner, recovery.ownerRevision)) {
const owner = await UserModel.db.collection("users").findOne({ _id: recovery.owner,
accessAlertResetRevision: generationFilter(recovery.ownerRevision) }, { projection: { accessAlertResetUntil: 1 } });
if (!owner) continue;
if (owner.accessAlertResetUntil?.getTime() > Date.now()) {
deferRecoveryUntil(key, attemptId, owner.accessAlertResetUntil);
continue;
}
// The lease may have cleared between the failed CAS and this read.
if (!await commitOwnerRevision(recovery.owner, recovery.ownerRevision)) continue;
}
await completeRecovery(candidate, recovery);
}
}
/** Confirm recovery after owner action; only known-unsent claims may be released. */
export async function withOwnerAccessRecovery<T>(ownerId: string, resource: AlertResource, work: () => Promise<T>, expectedSource: Record<string, unknown> = {}, confirmed: (result: T) => boolean = () => true): Promise<T> {
let snapshot: { accessAlertGeneration?: string } | null = null;
let candidates: AlertRecord[] = [];
let ownerRevision: number | null = null;
const valid = UserModel.db.readyState === 1 && Types.ObjectId.isValid(ownerId) && Types.ObjectId.isValid(resource.id);
const identity = valid ? { _id: new Types.ObjectId(resource.id), owner: new Types.ObjectId(ownerId), ...expectedSource } : null;
if (identity) {
try {
const row = await UserModel.db.collection(resourceCollections[resource.kind]).findOne(identity, { projection: { accessAlertGeneration: 1 } });
snapshot = row ? { accessAlertGeneration: row.accessAlertGeneration } : null;
if (snapshot) {
const claim = { owner: identity.owner, resourceKind: resource.kind, resourceId: identity._id,
generation: { $ne: snapshot.accessAlertGeneration ?? null } };
candidates = await alertCollection().find({ state: { $in: ["pending", "accepted", "failed", "legacy", "sending"] },
$or: [{ ...claim, legacyClaims: { $exists: false } }, { legacyClaims: { $elemMatch: { ...claim, recovered: { $ne: true } } } }],
}).toArray();
if (candidates.length) ownerRevision = (await UserModel.db.collection("users").findOne({ _id: identity.owner },
{ projection: { accessAlertResetRevision: 1 } }))?.accessAlertResetRevision ?? null;
}
}
catch { /* Email bookkeeping must not change upstream behavior. */ }
}
const result = await work();
if (!identity || !snapshot || !confirmed(result)) return result;
try {
const current = await UserModel.db.collection(resourceCollections[resource.kind]).findOne({ ...identity,
accessAlertGeneration: generationFilter(snapshot.accessAlertGeneration),
});
if (!current) return result;
let recorded = 0;
for (const candidate of candidates) {
const fence = { _id: candidate._id, attemptId: candidate.attemptId, resetRevision: generationFilter(candidate.resetRevision),
state: { $in: ["pending", "accepted", "failed", "legacy", "sending"] } };
const fields = Object.keys(expectedSource);
const recovery: DeferredRecovery = { owner: identity.owner, resourceKind: resource.kind, resourceId: identity._id,
generation: snapshot.accessAlertGeneration ?? null, resetRevision: candidate.resetRevision ?? null, ownerRevision,
bindingFields: fields, bindingHash: bindingHash(current, fields) };
const saved = await alertCollection().updateOne(fence, [{ $set: { deferredRecoveries: { $concatArrays: [
{ $filter: { input: { $ifNull: ["$deferredRecoveries", []] }, as: "recovery", cond: { $not: [{ $and: [
{ $eq: ["$$recovery.owner", identity.owner] }, { $eq: ["$$recovery.resourceKind", resource.kind] },
{ $eq: ["$$recovery.resourceId", identity._id] },
] }] } } }, [recovery],
] } } }]);
if (saved.matchedCount) recorded++;
await applyDeferredRecoveries(candidate._id, candidate.attemptId);
}
if (recorded) logger.info("owner access email recovery recorded", { resources: recorded });
} catch { logger.warn("owner access email recovery unavailable"); }
return result;
}
const accessErrors = new Set([
"token_expired", "github_oauth_required", "github_app_reconnect_required",
"github_app_access_required", "repo_not_found", "repository_not_found",
"file_not_accessible", "file_not_found", "pull_request_not_found", "gist_not_found", "repo_not_accessible", "repo_access_limited", "repo_saml_enforcement",
]);
export function normalizeEmail(value: unknown): string | null {
if (typeof value !== "string") return null;
const email = value.trim();
// Accept one plain mailbox, never a display name or recipient list.
if (email.length > 254 || !/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?\.[a-zA-Z]{2,}$/.test(email)) return null;
const local = email.split("@")[0];
if (local.length > 64 || local.startsWith(".") || local.endsWith(".") || email.includes("..")) return null;
return email;
}
export function notificationEmail(emails?: { email: string; default: boolean }[], preferred?: string): string {
return normalizeEmail(preferred) || normalizeEmail(emails?.find(e => e.default)?.email) ||
emails?.map(e => normalizeEmail(e.email)).find(Boolean) || "";
}
export function isAccessFailure(error: unknown): boolean {
if (isGitHubRateLimitError(error)) return false;
const code = typeof error === "string" ? error : error instanceof Error ? error.message : "";
const rawStatus = error instanceof Error ? (error as Error & { status?: number }).status : undefined;
if (!accessErrors.has(code) && ![401, 403, 404].includes(rawStatus || 0)) return false;
if (error instanceof Error) {
const upstream = (error as Error & { cause?: { status?: number; httpStatus?: number; response?: { statusCode?: number } } }).cause;
// Some legacy paths wrap timeouts or 5xx responses as repo_not_found.
// Only a confirmed access failure should prompt the owner to reconnect.
if (isGitHubRateLimitError(upstream)) return false;
if (upstream && ![401, 403, 404].includes(upstream.status || upstream.response?.statusCode || upstream.httpStatus || 0)) return false;
const status = (error as Error & { httpStatus?: number }).httpStatus;
if (status && (status === 429 || status >= 500)) return false;
}
return true;
}
/** Best effort; mail and database failures must not change repository behavior. */
export async function notifyOwnerAccessProblem(ownerId: string, error: unknown, resource: AlertResource): Promise<void> {
if (!config.RESEND_API_KEY || !config.EMAIL_FROM || !isAccessFailure(error)) return;
let unsent: AlertRecord | undefined;
let dispatchRecord: AlertRecord | undefined;
let providerStarted = false;
try {
if (!resource || !Types.ObjectId.isValid(resource.id) || !Types.ObjectId.isValid(ownerId)) return;
const collection = UserModel.db.collection(resourceCollections[resource.kind]);
const identity = { _id: new Types.ObjectId(resource.id), owner: new Types.ObjectId(ownerId),
status: { $nin: ["archived", "expired", "expiring", "removed", "removing"] } };
const ownerState = await UserModel.db.collection("users").findOne({ _id: identity.owner },
{ projection: { accessAlertResetRevision: 1, accessAlertResetUntil: 1, accessAlertDeactivations: 1 } });
if (!ownerState || ownerState.accessAlertDeactivations?.length || ownerState.accessAlertResetUntil?.getTime() > Date.now()) return;
const ownerRevision = ownerState.accessAlertResetRevision ?? null;
const snapshot = await collection.findOne(identity, { projection: { accessAlertGeneration: 1, accessAlertClaimedAt: 1, accessAlertClaimRevision: 1 } });
if (!snapshot) return;
const owner = await UserModel.findOne({ _id: ownerId, status: { $nin: ["removed", "banned"] } }).lean();
if (!owner) return;
const to = notificationEmail(owner.emails, owner.notificationEmail);
if (!to) return;
const alerts = alertCollection();
const key = recipientAlertKey(to);
const attemptId = randomUUID();
const record: AlertRecord = { _id: key, owner: identity.owner, resourceKind: resource.kind,
resourceId: identity._id, generation: snapshot.accessAlertGeneration ?? null,
resourceRevision: snapshot.accessAlertClaimRevision ?? 0, attemptId, claimedAt: new Date(), state: "pending", dispatchPrepared: true };
dispatchRecord = record;
if (!await prepareOwnerDispatch(record, ownerRevision)) return;
unsent = record;
try { await alerts.insertOne(record); }
catch (error) {
if ((error as { code?: number }).code !== 11000) throw error;
unsent = undefined;
let previous = await alerts.findOne({ _id: key });
if (previous?.state === "sending") {
await reconcileCompletedSend(previous);
previous = await alerts.findOne({ _id: key });
}
if (previous && ["accepted", "failed"].includes(previous.state || "")) {
await clearOwnerDispatch(previous, "committed");
await applyDeferredRecoveries(previous._id, previous.attemptId);
previous = await alerts.findOne({ _id: key });
}
if (previous?.state === "sending" && previous.dispatchPrepared && previous.claimedAt
&& previous.claimedAt.getTime() <= Date.now() - PENDING_LEASE_MS) {
if (await releaseUnsentAlert(previous, expiredPreparation())) previous = await alerts.findOne({ _id: key });
}
if (previous && (previous.state === "sending" || !previous.owner || !previous.resourceId
|| !previous.resourceKind || !(previous.resourceKind in resourceCollections))) return;
if (previous) {
const retirement: mongo.Filter<AlertRecord> = { _id: key, attemptId: previous.attemptId, state: previous.state,
resetRevision: generationFilter(previous.resetRevision) };
let retired: boolean;
if (previous.state === "pending" || previous.state === "cancelled") {
if (previous.state === "pending") {
if (!previous.claimedAt || previous.claimedAt.getTime() > Date.now() - PENDING_LEASE_MS) return;
}
retired = await releaseUnsentAlert(previous, previous.state === "cancelled" ? retirement : { ...retirement, ...expiredPending() });
} else {
// Delivered, legacy, recovered and ambiguous sends stay blocked permanently,
// even if their original resource or account no longer exists.
return;
}
if (!retired) return;
}
unsent = record;
try { await alerts.insertOne(record); }
catch (replacementError) { if ((replacementError as { code?: number }).code === 11000) { unsent = undefined; return; } throw replacementError; }
}
// An earlier claim may have been reclaimed after the initial snapshot, even
// when insertOne succeeded. Persist the current fence before resource preparation.
const current = await collection.findOne({ ...identity, accessAlertGeneration: generationFilter(record.generation) },
{ projection: { accessAlertClaimRevision: 1 } });
if (!current) return;
const revision = current.accessAlertClaimRevision ?? 0;
const refreshed = await alerts.updateOne({ _id: key, attemptId, state: "pending",
resetRevision: generationFilter(record.resetRevision) }, { $set: { resourceRevision: revision } });
if (!refreshed.matchedCount) return;
record.resourceRevision = revision;
const base = /^https?:\/\//.test(config.APP_HOSTNAME) ? config.APP_HOSTNAME : `https://${config.APP_HOSTNAME}`;
const url = new URL("/connections", base).href;
await alertCollection("owner_access_alert_events").insertOne({ _id: attemptId, attemptId,
recipientKey: key, state: "prepared", startedAt: new Date(), owner: identity.owner,
resourceKind: resource.kind, resourceId: identity._id, generation: record.generation,
failureCode: accessErrors.has(typeof error === "string" ? error : error instanceof Error ? error.message : "")
? typeof error === "string" ? error : (error as Error).message : "upstream_access_failure" });
const claim = await collection.updateOne({ ...identity,
accessAlertGeneration: generationFilter(record.generation),
accessAlertClaimRevision: revisionFilter(record.resourceRevision ?? 0),
}, { $set: { accessAlertClaimedAt: new Date(), accessAlertAttemptId: attemptId }, $inc: { accessAlertClaimRevision: 1 } });
if (!claim.matchedCount || !await collection.findOne({ ...identity,
accessAlertGeneration: generationFilter(record.generation),
accessAlertClaimRevision: (record.resourceRevision ?? 0) + 1, accessAlertAttemptId: attemptId,
})) return;
// Reserve the recipient transition, but it remains reclaimable until the owner CAS commits dispatch.
const dispatch = await alerts.updateOne({ _id: key, attemptId, state: "pending",
resetRevision: generationFilter(record.resetRevision) }, { $set: { state: "sending" } });
if (!dispatch.matchedCount) return;
// Reset and dispatch commit on the same owner document. There is no asynchronous
// preparation between this authoritative commitment and the provider request.
if (!await commitOwnerRevision(identity.owner, ownerRevision, record)) return;
let accepted = false;
let httpStatus: number | undefined;
try {
providerStarted = true;
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { Authorization: `Bearer ${config.RESEND_API_KEY}`, "Content-Type": "application/json",
"Idempotency-Key": `owner-access-alert/${attemptId}` },
signal: AbortSignal.timeout(15000),
body: JSON.stringify({
from: config.EMAIL_FROM, to: [to],
subject: "Your Anonymous GitHub connection needs attention",
text: `Anonymous GitHub could not access GitHub using your connection, or could not read a repository.\n\nOpen your connections page to reconnect your account or restore repository access:\n${url}\n\nYou are receiving this email because you own content on Anonymous GitHub. You can change your alert address on the connections page.\n`,
}),
});
httpStatus = response.status;
accepted = response.ok;
} catch { /* Delivery can be ambiguous; retain the recipient guard. */ }
const state = accepted ? "accepted" : "failed";
if (accepted) logger.info("owner access email accepted", { attemptId, httpStatus });
else logger.warn("owner access email delivery failed", { attemptId, httpStatus });
const completedAt = new Date();
try {
await retryAlertWrite(async () => {
try { await alertCollection("owner_access_alert_events").insertOne({ _id: `${attemptId}/completion`, attemptId,
recipientKey: key, state, httpStatus, completedAt, owner: identity.owner,
resourceKind: resource.kind, resourceId: identity._id, generation: record.generation }); }
catch (error) { if ((error as { code?: number }).code !== 11000) throw error; }
});
} catch { logger.warn("owner access email audit unavailable", { attemptId }); }
try {
await retryAlertWrite(async () => {
await alerts.updateOne({ _id: key, attemptId, state: "sending" }, { $set: { state, completedAt, httpStatus } });
await clearOwnerDispatch(record, "committed");
await applyDeferredRecoveries(key, attemptId);
});
} catch { logger.warn("owner access email result reconciliation required", { attemptId }); }
} catch {
logger.warn("owner access email unavailable");
} finally {
if (unsent && !providerStarted) {
await releaseUnsentAlert(unsent, { state: { $in: ["pending", "sending", "cancelled"] } })
.catch(() => logger.warn("owner access email claim cleanup unavailable"));
}
if (dispatchRecord) await clearOwnerDispatch(dispatchRecord, "prepared")
.catch(() => logger.warn("owner access email preparation cleanup unavailable"));
}
}
+11
View File
@@ -0,0 +1,11 @@
import { createLogger, serializeError } from "./logger";
/** Observe fatal errors without overriding Node's default exit behavior. */
export function installFatalErrorLogging(service: "api" | "streamer") {
const logger = createLogger("process");
const report = (error: Error, origin: string) => logger.error("process fatal error", {
...serializeError(error), code: "process_fatal_error", service, origin,
});
process.on("uncaughtExceptionMonitor", report);
return () => process.removeListener("uncaughtExceptionMonitor", report);
}
+1 -1
View File
@@ -1,4 +1,4 @@
const sensitive = /^(?:authorization|proxy-authorization|cookie|set-cookie|token|access_?tokens?|refresh_?token|encryptedToken|encryptedRefreshToken|private_?key|GITHUB_APP_PRIVATE_KEY|GITHUB_APP_CLIENT_SECRET|GITHUB_APP_WEBHOOK_SECRET|ciphertext|nonce|tag|password|client_?secret|CREDENTIAL_KEYS)$/i;
const sensitive = /^(?:authorization|proxy-authorization|cookie|set-cookie|token|access_?tokens?|refresh_?token|encryptedToken|encryptedRefreshToken|private_?key|GITHUB_APP_PRIVATE_KEY|GITHUB_APP_CLIENT_SECRET|GITHUB_APP_WEBHOOK_SECRET|ciphertext|nonce|tag|password|client_?secret|CREDENTIAL_KEYS|RESEND_API_KEY)$/i;
export function redactSecrets(value: unknown, seen = new WeakSet<object>()): unknown {
if (typeof value === "string") return value
.replace(/\b(?:gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+)\b/g, "[REDACTED]")
+41
View File
@@ -0,0 +1,41 @@
import UserModel from "./model/users/users.model";
import { IUserDocument } from "./model/users/users.types";
import { normalizeEmail, notificationEmail } from "./owner-notifications";
function usableEmail(value: unknown): string | null {
const email = normalizeEmail(value);
if (!email) return null;
const domain = email.split("@")[1].toLowerCase();
return ["users.noreply.github.com", "noreply.github.com"].includes(domain) ? null : email;
}
/** Fill a missing address after the GitHub identity has been authenticated. */
export async function saveRegistrationEmail(user: IUserDocument, token: string): Promise<void> {
if (notificationEmail(user.emails, user.notificationEmail)) return;
// Compare the original fields so a concurrent settings save always wins.
const previousEmails = user.toObject().emails;
const previousPreference = user.notificationEmail ?? null;
try {
const response = await fetch("https://api.github.com/user/emails?per_page=100", {
headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28", "User-Agent": "Anonymous-GitHub" },
signal: AbortSignal.timeout(5000),
});
if (!response.ok) return;
const data: unknown = await response.json();
if (!Array.isArray(data)) return;
const verified = data.filter((entry): entry is { email: string; primary?: boolean } =>
!!entry && entry.verified === true && !!usableEmail(entry.email));
const email = usableEmail((verified.find(entry => entry.primary === true) || verified[0])?.email);
if (!email) return;
const emails = [{ email, default: true }];
const result = await UserModel.updateOne({ _id: user._id, notificationEmail: previousPreference,
$or: [{ emails: previousEmails ?? [] },
...(!previousEmails?.length ? [{ emails: { $exists: false } }] : [])],
}, { $set: { emails } });
if (result.modifiedCount) user.emails = emails;
} catch {
// Missing App email permission or an upstream outage must not prevent login.
// The existing email form remains available when automatic collection fails.
}
}
+12
View File
@@ -0,0 +1,12 @@
/** Persisted repository failures need the same client status as the original error. */
export function repositoryFailureStatus(code?: string): number {
switch (code) {
case "repo_not_found": case "branch_not_found": case "commit_not_found": return 404;
case "repo_not_accessible": case "repository_not_accessible": return 403;
case "token_expired": case "not_connected": return 401;
case "github_rate_limit_exceeded": return 429;
case "github_unavailable": return 502;
case "repo_empty": return 409;
default: return 500;
}
}
+5 -1
View File
@@ -148,8 +148,12 @@ export function monitorRequests(service: Service, metrics = requestMetrics,
const slow = ms > (transfer ? 10000 : 1000);
metrics.observe("request", route, method, ms, { bytes, aborted, error: res.statusCode >= 500, slow });
if (firstByteMs != null) metrics.observe("first_byte", route, method, firstByteMs);
const responseMs = firstByteMs == null ? null : Math.max(0, ms - firstByteMs);
const detail = { requestId: id, service, method, route, status: res.statusCode,
outcome: aborted ? "interrupted" : "completed", ms: Math.round(ms), firstByteMs: firstByteMs == null ? null : Math.round(firstByteMs), bytes, stages: trace.stages };
outcome: aborted ? "interrupted" : "completed", ms: Math.round(ms), firstByteMs: firstByteMs == null ? null : Math.round(firstByteMs),
responseMs: responseMs == null ? null : Math.round(responseMs),
bytesPerSecond: responseMs != null && responseMs >= 1 && bytes > 0 ? Math.round(bytes * 1000 / responseMs) : null,
bytes, stages: trace.stages };
if (slow || aborted) log.warn(aborted ? "request interrupted" : "slow request", detail);
else if (!route.includes("healthcheck")) log.info("request", detail);
};
+6 -2
View File
@@ -52,7 +52,9 @@ export async function classifyGitHubMissError(
repo: data.repoName,
});
return "commit_not_found";
} catch {
} catch (probeError) {
if ((probeError as { status?: number; httpStatus?: number }).status !== 404 &&
(probeError as { httpStatus?: number }).httpStatus !== 404) throw probeError;
// Repo no longer exists at owner/repo. Try to recover via the cached
// numeric GitHub id — if the repo was renamed, GET /repositories/{id}
// resolves to its new full_name. See #409.
@@ -87,7 +89,9 @@ export async function classifyGitHubMissError(
return "repo_renamed";
}
return "repo_not_found";
} catch {
} catch (probeError) {
if ((probeError as { status?: number; httpStatus?: number }).status !== 404 &&
(probeError as { httpStatus?: number }).httpStatus !== 404) throw probeError;
return "repo_not_found";
}
}
+2 -1
View File
@@ -77,7 +77,8 @@ export class GitHubRepository {
try {
await oct.repos.get({ owner: this.owner, repo: this.repo });
repoExists = true;
} catch {
} catch (probeError) {
if ((probeError as { status?: number }).status !== 404) throw probeError;
repoExists = false;
}
throw new AnonymousError(
+244 -127
View File
@@ -1,3 +1,5 @@
import { withOwnerAccessRecovery } from "../owner-notifications";
import { ExpiringMap } from "../expiring-map";
import { contentGenerationPrefix, contentRetirementMarker } from "../content-generation";
import { createHash } from "crypto";
import { coordinatedFill } from "../cache-coordination";
@@ -14,21 +16,30 @@ import { basename, dirname } from "path";
import * as stream from "stream";
import AnonymousError from "../AnonymousError";
import { octokit, waitForTokenGate } from "../GitHubUtils";
import { octokit, waitForTokenGate, RateLimitDelayError } from "../GitHubUtils";
import FileModel from "../model/files/files.model";
import { IFile } from "../model/files/files.types";
import { expandSubmodules, validUpstream } from "./github-submodules";
import { IFile, UpstreamFile } from "../model/files/files.types";
import { createLogger, serializeError } from "../logger";
import { measureStage, recordCacheHit } from "../request-monitoring";
import config from "../../config";
import { isConnected } from "../../server/database";
import AnonymizedRepositoryModel from "../model/anonymizedRepositories/anonymizedRepositories.model";
import { RepositoryStatus } from "../types";
import { cachedContentMiss, classifyContentMiss } from "./github-content-miss";
const logger = createLogger("gh-stream");
const GH_API_CONCURRENCY = 6;
const cacheFills = new Map<string, Promise<void>>();
const missingContent = new ExpiringMap<string>(2048);
/** Stop upstream before Readable.from waits for the pending iterator to finish. */
function stopSourceOnDestroy(output: stream.Readable, stop: () => void): void {
const destroy = output._destroy;
output._destroy = (error, callback) => { stop(); destroy.call(output, error, callback); };
}
async function pMap<T, R>(
items: T[],
@@ -64,6 +75,16 @@ export function githubRawFileUrl(
)}/raw/${encodeURIComponent(commit)}/${encodedPath}`;
}
function downloadError(error: Error, filePath: string): AnonymousError {
if (error instanceof AnonymousError) return error;
if (error instanceof RateLimitDelayError) return new AnonymousError("github_rate_limit_exceeded", { httpStatus: 429, cause: error });
const response = error as Error & { response?: { statusCode?: number }; status?: number; httpStatus?: number };
const status = response.response?.statusCode ?? response.status ?? response.httpStatus;
const code = status === 401 || status === 403 ? "file_not_accessible"
: status === 404 ? "file_not_found" : status === 422 ? "file_too_big" : "upstream_error";
return new AnonymousError(code, { httpStatus: Number.isInteger(status) && status! >= 400 && status! < 500 ? status : 502, cause: error, object: filePath });
}
export default class GitHubStream extends GitHubBase {
type: "GitHubDownload" | "GitHubStream" | "Zip" = "GitHubStream";
@@ -104,10 +125,9 @@ export default class GitHubStream extends GitHubBase {
}
}
// GitHub's web raw URL auto-resolves Git LFS pointers via redirect to
// media.githubusercontent.com, with the auth header carried through. The
// blob endpoint above returns the raw pointer text instead, so we use this
// as the fallback for LFS files (#95).
// The web URL resolves public LFS pointers. Private web URLs can return
// 404 even when the REST API can read the file, so use the scoped contents
// API to obtain a short-lived download URL in that case.
private downloadFileViaRaw(token: string, filePath: string) {
const url = githubRawFileUrl(
this.data.organization,
@@ -116,11 +136,84 @@ export default class GitHubStream extends GitHubBase {
filePath
);
logger.debug("downloading via raw URL (LFS)", { url });
return got.stream(url, {
hooks: { beforeRequest: [async () => { await githubTokenContext(token)?.renew(); }] },
headers: !token || githubTokenContext(token)?.publicRepository ? {} : { authorization: `token ${token}` },
followRedirect: true,
});
const data = this.data;
const cancellation = new AbortController();
let active: stream.Readable | undefined;
const output = stream.Readable.from((async function* () {
try {
active = got.stream(url, {
hooks: { beforeRequest: [async () => { await githubTokenContext(token)?.renew(); }] },
headers: !token || githubTokenContext(token)?.publicRepository ? {} : { authorization: `token ${token}` },
followRedirect: true,
});
for await (const chunk of active) yield chunk;
return;
} catch (error) {
if (!token || githubTokenContext(token)?.publicRepository ||
(error as { response?: { statusCode?: number } }).response?.statusCode !== 404) throw error;
await waitForTokenGate(token, cancellation.signal);
const response = await octokit(token).repos.getContent({
owner: data.organization, repo: data.repoName,
path: filePath, ref: data.commit || "HEAD", mediaType: { format: "object" },
request: { signal: cancellation.signal, timeout: 30_000 },
}).catch(apiError => {
if (apiError instanceof AnonymousError) throw apiError;
const upstream = apiError as { status?: number; response?: { data?: { message?: string } } };
if (upstream.status === 422) {
const missingCommit = /^No commit found for SHA\b/.test(upstream.response?.data?.message || "");
throw new AnonymousError(missingCommit ? "commit_not_found" : "upstream_error", {
httpStatus: missingCommit ? 404 : 502, cause: apiError as Error,
});
}
throw apiError;
});
if (cancellation.signal.aborted) return;
const metadata = response.data as { type?: string; download_url?: string };
if (metadata.type !== "file" || !metadata.download_url) throw error;
const assertDownloadUrl = (value: URL) => {
if (value.protocol !== "https:" || value.username || value.password || value.port ||
!["raw.githubusercontent.com", "media.githubusercontent.com"].includes(value.hostname)) {
throw new AnonymousError("upstream_error", { httpStatus: 502 });
}
};
assertDownloadUrl(new URL(metadata.download_url));
// The API URL supplies its own authorization. Never forward the
// owner's token to the download host or a redirect destination.
active = got.stream(metadata.download_url, {
hooks: { beforeRedirect: [options => { assertDownloadUrl(options.url); }] },
});
let probe = Buffer.alloc(0);
let checked = false;
for await (const chunk of active) {
if (checked) { yield chunk; continue; }
probe = Buffer.concat([probe, Buffer.from(chunk)]);
if (probe.length < 150) continue;
if (probe.toString("utf8").startsWith("version https://git-lfs.github.com/spec/")) {
throw new AnonymousError("upstream_error", { httpStatus: 502 });
}
checked = true; yield probe;
}
if (!checked && probe.length) {
if (probe.toString("utf8").startsWith("version https://git-lfs.github.com/spec/")) {
throw new AnonymousError("upstream_error", { httpStatus: 502 });
}
yield probe;
}
}
})(), { objectMode: false });
stopSourceOnDestroy(output, () => { cancellation.abort(); active?.destroy(); });
return output;
}
private deferredRawDownload(token: string, filePath: string): stream.Readable {
let active: stream.Readable | undefined;
const download = () => this.downloadFileViaRaw(token, filePath);
const output = stream.Readable.from((async function* () {
active = download();
for await (const chunk of active) yield chunk;
})(), { objectMode: false });
stopSourceOnDestroy(output, () => active?.destroy());
return output;
}
// Try the blob API, then fall back to the raw URL on statuses where the
@@ -135,9 +228,9 @@ export default class GitHubStream extends GitHubBase {
// Public raw downloads need no bearer token and do not consume the
// unauthenticated REST API quota. GitHub also resolves LFS pointers here.
if (!token || githubTokenContext(token)?.publicRepository) {
return Promise.resolve(this.downloadFileViaRaw(token, filePath));
return Promise.resolve(this.deferredRawDownload(token, filePath));
}
return new Promise<stream.Readable>((resolve) => {
return new Promise<stream.Readable>((resolve, reject) => {
const blobStream = this.downloadFile(token, sha);
let settled = false;
@@ -148,7 +241,7 @@ export default class GitHubStream extends GitHubBase {
filePath,
statusCode,
});
resolve(this.downloadFileViaRaw(token, filePath));
resolve(this.deferredRawDownload(token, filePath));
};
blobStream.on("error", (err) => {
@@ -160,13 +253,10 @@ export default class GitHubStream extends GitHubBase {
fallbackToRaw(statusCode);
return;
}
// Other errors: let the normal pipeline handle them.
// Defer destroy so callers can attach error listeners before
// the error event fires, avoiding an uncaughtException crash.
// Reject before handing a stream to the caller. nextTick runs before
// await continuations, so emitting on a returned stream here can crash.
settled = true;
const passthrough = new stream.PassThrough();
resolve(passthrough);
process.nextTick(() => passthrough.destroy(err));
reject(err);
});
blobStream.on("response", (response) => {
@@ -191,9 +281,7 @@ export default class GitHubStream extends GitHubBase {
token: string,
filePath: string
): stream.Readable {
const out = new stream.PassThrough();
let active = blobStream;
out.once("close", () => active.destroy());
// The generator only reads upstream when the output has capacity.
const downloadRaw = () => this.downloadFileViaRaw(token, filePath);
async function* resolve() {
@@ -208,7 +296,7 @@ export default class GitHubStream extends GitHubBase {
probe = Buffer.concat([probe, bytes]);
if (probe.length < 150) continue;
decided = true;
if (probe.toString("utf8", 0, 39) === "version https://git-lfs.github.com/spec/") {
if (probe.toString("utf8").startsWith("version https://git-lfs.github.com/spec/")) {
active = downloadRaw();
for await (const raw of active) yield raw;
return;
@@ -225,28 +313,37 @@ export default class GitHubStream extends GitHubBase {
}
}
}
const resolved = stream.Readable.from(resolve());
out.once("close", () => resolved.destroy());
resolved.on("error", (error) => out.destroy(error));
resolved.pipe(out);
return out;
const resolved = stream.Readable.from(resolve(), { objectMode: false });
// Keep the generator lazy until the consumer has installed its handlers.
stopSourceOnDestroy(resolved, () => active.destroy());
return resolved;
}
async getFileContentCache(
filePath: string,
repoId: string,
fileMeta: () =>
| Promise<{ sha: string; size?: number }>
| { sha: string; size?: number }
| Promise<{ sha: string; size?: number; upstream?: UpstreamFile }>
| { sha: string; size?: number; upstream?: UpstreamFile }
| Promise<string>
| string
) {
const meta = await fileMeta();
const expected: { sha: string; size?: number } =
const expected: { sha: string; size?: number; upstream?: UpstreamFile } =
typeof meta === "string" ? { sha: meta } : meta;
if (expected.upstream && !validUpstream(expected.upstream)) throw new AnonymousError("invalid_request", { httpStatus: 400 });
// Hydrated Mongoose subdocuments expose source fields through getters,
// so spreading them would retain the parent's repository coordinates.
const origin = expected.upstream ? new GitHubStream({
...this.data,
organization: expected.upstream.organization,
repoName: expected.upstream.repoName,
commit: expected.upstream.commit,
getToken: () => "",
}) : this;
const sourcePath = expected.upstream?.path || filePath;
const generation = createHash("sha256").update(JSON.stringify([
this.data.commit, expected.sha, this.data.cacheGeneration, this.data.cacheRevision,
this.data.commit, expected.sha, this.data.cacheGeneration, this.data.cacheRevision, ...(expected.upstream ? [expected.upstream] : []),
])).digest("hex");
const cachePath = this.data.cacheGeneration ? `${contentGenerationPrefix(this.data.cacheGeneration)}/${generation}/${filePath}` : filePath;
const assertActive = async () => {
@@ -298,35 +395,51 @@ export default class GitHubStream extends GitHubBase {
if (cacheFills.size >= 64) throw new AnonymousError("cache_busy", { httpStatus: 503 });
filling = coordinatedFill(key, cached, async () => {
await assertActive();
const token = await this.data.getToken();
return measureStage("upstream", async () => {
const content = await this.downloadWithFallback(token, expected.sha, filePath);
let count = 0;
const limited = new stream.Transform({ transform(chunk, encoding, callback) {
count += chunk.length;
callback(count > config.MAX_FILE_SIZE ? new AnonymousError("file_too_big", { httpStatus: 413 }) : null, chunk);
} });
content.on("error", error => {
if (error instanceof AnonymousError) { limited.destroy(error); return; }
const status = (error as { response?: { statusCode?: number }; status?: number; httpStatus?: number });
const httpStatus = status.response?.statusCode ?? status.status ?? status.httpStatus;
const code = httpStatus === 403 ? "file_not_accessible"
: httpStatus === 404 ? "file_not_found"
: httpStatus === 422 ? "file_too_big" : "upstream_error";
limited.destroy(new AnonymousError(code, { httpStatus: httpStatus || 502, cause: error, object: filePath }));
const parentToken = await this.data.getToken();
const token = expected.upstream ? "" : parentToken;
const sourceMiss = cachedContentMiss(this.data, parentToken);
if (sourceMiss && sourceMiss !== "file_not_found") throw new AnonymousError(sourceMiss, { httpStatus: 404 });
const missKey = createHash("sha256").update(JSON.stringify([
key, this.data.organization, this.data.repoName, token,
])).digest("hex");
const missed = missingContent.get(missKey);
if (missed) throw new AnonymousError(missed, { httpStatus: 404 });
try {
const fetchContent = () => measureStage("upstream", async () => {
const content = await origin.downloadWithFallback(token, expected.sha, sourcePath)
.catch(error => { throw downloadError(error, filePath); });
let count = 0;
const limited = new stream.Transform({ transform(chunk, encoding, callback) {
count += chunk.length;
callback(count > config.MAX_FILE_SIZE ? new AnonymousError("file_too_big", { httpStatus: 413 }) : null, chunk);
} });
content.on("error", error => limited.destroy(downloadError(error, filePath)));
limited.once("close", () => content.destroy());
limited.on("error", () => {});
content.pipe(limited);
const deadline = setTimeout(() => limited.destroy(new AnonymousError("upstream_error", { httpStatus: 502 })), 120_000);
try {
await storage.write(repoId, cachePath, limited, this.type, expected.size);
try { await assertActive(); }
catch (error) { await storage.rm(repoId, cachePath); throw error; }
}
finally { clearTimeout(deadline); limited.destroy(); content.destroy(); }
return true;
});
limited.once("close", () => content.destroy());
limited.on("error", () => {});
content.pipe(limited);
const deadline = setTimeout(() => limited.destroy(new AnonymousError("upstream_error", { httpStatus: 502 })), 120_000);
try {
await storage.write(repoId, cachePath, limited, this.type, expected.size);
try { await assertActive(); }
catch (error) { await storage.rm(repoId, cachePath); throw error; }
return expected.upstream ? await fetchContent() : await this.confirmOwnerAccess(repoId, fetchContent);
} catch (error) {
if (expected.upstream && error instanceof AnonymousError &&
["file_not_found", "file_not_accessible"].includes(error.message)) {
// A removed public dependency is not a lost parent credential.
throw new AnonymousError("submodule_not_available", { httpStatus: 404, cause: error });
}
finally { clearTimeout(deadline); limited.destroy(); content.destroy(); }
return true;
});
if (!expected.upstream && error instanceof AnonymousError && error.message === "file_not_found" && error.httpStatus === 404) {
const code = await classifyContentMiss(this.data, token);
missingContent.set(missKey, code, 30_000);
if (code !== "file_not_found") throw new AnonymousError(code, { httpStatus: 404, cause: error });
}
throw error;
}
}).then(() => {});
cacheFills.set(key, filling);
void filling.finally(() => { if (cacheFills.get(key) === filling) cacheFills.delete(key); }).catch(() => {});
@@ -353,14 +466,14 @@ export default class GitHubStream extends GitHubBase {
object: file,
});
}
return { sha: fileSha, size: await file.size() };
return { sha: fileSha, size: await file.size(), upstream: (await file.getFileInfo()).upstream };
}
);
}
async getFiles(progress?: (status: string) => void) {
this._truncatedFolders = [];
return this.getTruncatedTree(this.data.commit, progress);
return expandSubmodules(await this.getTruncatedTree(this.data.commit, progress), this.data);
}
/**
@@ -371,6 +484,26 @@ export default class GitHubStream extends GitHubBase {
* files above the 1MB contents-API inline limit.
*/
async fetchFileInfoFromPath(filePath: string): Promise<IFile | null> {
return this.confirmOwnerAccess(this.data.repoId, () => this.fetchFileMetadata(filePath), result => result !== null);
}
private async confirmOwnerAccess<T>(repoId: string, work: () => Promise<T>, confirmed?: (result: T) => boolean): Promise<T> {
let repository;
try {
repository = isConnected ? await AnonymizedRepositoryModel.findOne({ repoId })
.select("owner source githubAccess").lean().exec() : null;
} catch {
// Email recovery bookkeeping must not prevent the upstream read.
return work();
}
return repository?.owner ? withOwnerAccessRecovery(String(repository.owner),
{ kind: "repository", id: String(repository._id) }, work,
{ "source.repositoryName": `${this.data.organization}/${this.data.repoName}`, "source.commit": this.data.commit,
"githubAccess.revision": repository.githubAccess?.revision ?? { $exists: false } }, confirmed) : work();
}
private async fetchFileMetadata(filePath: string): Promise<IFile | null> {
if (basename(filePath) === ".gitmodules") return null;
const token = await this.data.getToken();
const oct = octokit(token);
try {
@@ -393,10 +526,13 @@ export default class GitHubStream extends GitHubBase {
size: data.size,
};
} catch (error) {
logger.debug("fetchFileInfoFromPath miss", {
filePath,
error: serializeError(error),
});
if ((error as { status?: number }).status !== 404) throw error;
// A missing file and lost repository access both return 404. Only
// report a normal miss after the repository probe remains readable.
const code = await classifyGitHubMissError(error, this.data);
if (code === "repo_not_found") {
throw new AnonymousError(code, { httpStatus: 404, cause: error as Error });
}
return null;
}
}
@@ -408,19 +544,37 @@ export default class GitHubStream extends GitHubBase {
count = { request: 0, file: 0 },
opt = { recursive: true, callback: () => {} }
) {
await waitForTokenGate(token);
const ghRes = await oct.git.getTree({
owner: this.data.organization,
repo: this.data.repoName,
tree_sha: sha,
recursive: opt.recursive === true ? "1" : undefined,
});
count.request++;
count.file += ghRes.data.tree.length;
if (opt.callback) {
opt.callback();
try {
await waitForTokenGate(token);
const ghRes = await oct.git.getTree({
owner: this.data.organization,
repo: this.data.repoName,
tree_sha: sha,
recursive: opt.recursive === true ? "1" : undefined,
});
count.request++;
count.file += ghRes.data.tree.length;
if (opt.callback) opt.callback();
return ghRes.data;
} catch (error) {
// Keep quota, credential and lifecycle errors intact. A failed tree
// request does not imply that the repository has disappeared.
if (error instanceof AnonymousError || error instanceof RateLimitDelayError) throw error;
const upstream = error as { status?: number; response?: { data?: { message?: string } } };
const status = upstream.status;
const invalidObject = status === 422 && /^Invalid object requested\. SHA must identify a commit or a tree\b/.test(upstream.response?.data?.message || "");
const code = status === 409 ? "repo_empty"
: status === 404 ? await classifyGitHubMissError(error, this.data)
: invalidObject ? "commit_not_found"
: status === 401 ? "token_expired"
: status === 403 ? "repo_not_accessible" : "github_unavailable";
throw new AnonymousError(code, {
httpStatus: status === 409 ? 409 : status === 404 || invalidObject ? 404
: status === 401 || status === 403 ? status : 502,
object: this.data,
cause: error as Error,
});
}
return ghRes.data;
}
private async getTruncatedTree(
@@ -435,55 +589,17 @@ export default class GitHubStream extends GitHubBase {
file: 0,
};
const output: IFile[] = [];
let data;
try {
data = await this.getGHTree(oct, token, sha, count, {
recursive: true,
callback: () => {
if (progress) {
progress("List file: " + count.file);
}
},
});
if (!data.truncated) return this.tree2Tree(data.tree, parentPath);
data = await this.getGHTree(oct, token, sha, count, {
recursive: false,
callback: () => {
if (progress) {
progress("List file: " + count.file);
}
},
});
if (data.truncated) {
this._truncatedFolders.push(parentPath);
}
output.push(...this.tree2Tree(data.tree, parentPath));
} catch (error) {
const status = (error as { status?: number }).status;
if (status === 409) {
logger.debug("getTree empty repo", serializeError(error));
throw new AnonymousError("repo_empty", {
httpStatus: 409,
object: this.data,
cause: error as Error,
});
}
if (status === 404) {
logger.debug("getTree miss", serializeError(error));
const code = await classifyGitHubMissError(error, this.data);
throw new AnonymousError(code, {
httpStatus: 404,
object: this.data,
cause: error as Error,
});
}
logger.warn("getTree failed", serializeError(error));
throw new AnonymousError("repo_not_found", {
httpStatus: status || 404,
object: this.data,
cause: error as Error,
});
}
let data = await this.getGHTree(oct, token, sha, count, {
recursive: true,
callback: () => { progress?.("List file: " + count.file); },
});
if (!data.truncated) return this.tree2Tree(data.tree, parentPath);
data = await this.getGHTree(oct, token, sha, count, {
recursive: false,
callback: () => { progress?.("List file: " + count.file); },
});
if (data.truncated) this._truncatedFolders.push(parentPath);
output.push(...this.tree2Tree(data.tree, parentPath));
const subtrees: { sha: string; parentPath: string }[] = [];
for (const file of data.tree) {
if (file.type == "tree" && file.path && file.sha) {
@@ -568,6 +684,7 @@ export default class GitHubStream extends GitHubBase {
path: pathFile,
repoId: this.data.repoId,
size: elem.size,
...(elem.type === "commit" ? { submodule: "unsupported" } : {}),
sha: elem.sha,
});
});
+65
View File
@@ -0,0 +1,65 @@
import { createHash } from "crypto";
import AnonymousError from "../AnonymousError";
import { ExpiringMap } from "../expiring-map";
import { octokit, waitForTokenGate, RateLimitDelayError } from "../GitHubUtils";
import { GitHubBaseData } from "./GitHubBase";
type Miss = "file_not_found" | "repo_not_found" | "commit_not_found";
const results = new ExpiringMap<Miss>(2048);
const pending = new Map<string, Promise<Miss>>();
function missKey(data: GitHubBaseData, token: string): string {
return createHash("sha256").update(JSON.stringify([
data.repoId, data.organization, data.repoName, data.commit, data.cacheGeneration, data.cacheRevision, token,
])).digest("hex");
}
export function cachedContentMiss(data: GitHubBaseData, token: string): Miss | undefined {
return results.get(missKey(data, token));
}
/** A raw 404 can mean a missing file, lost repository access, or a removed revision. */
export async function classifyContentMiss(data: GitHubBaseData, token: string): Promise<Miss> {
const key = missKey(data, token);
const cached = results.get(key);
if (cached) return cached;
const active = pending.get(key);
if (active) return active;
if (pending.size >= 64) throw new AnonymousError("cache_busy", { httpStatus: 503 });
const probe = (async (): Promise<Miss> => {
const oct = octokit(token);
const options = { owner: data.organization, repo: data.repoName, request: { timeout: 15_000 } };
try {
await waitForTokenGate(token);
await oct.repos.get(options);
} catch (error) {
if ((error as { status?: number }).status === 404) return "repo_not_found";
throw probeError(error);
}
try {
await waitForTokenGate(token);
// Only check that the revision resolves; do not download a potentially
// large commit diff just to classify a missing file.
await oct.repos.getCommit({ ...options, ref: data.commit || "HEAD", mediaType: { format: "sha" } });
} catch (error) {
const upstream = error as { status?: number; response?: { data?: { message?: string } } };
if (upstream.status === 404 || upstream.status === 409 || (upstream.status === 422 &&
/^No commit found for SHA\b/.test(upstream.response?.data?.message || ""))) return "commit_not_found";
throw probeError(error);
}
return "file_not_found";
})().then(result => { results.set(key, result, 30_000); return result; });
pending.set(key, probe);
try { return await probe; }
finally { if (pending.get(key) === probe) pending.delete(key); }
}
function probeError(error: unknown): AnonymousError {
if (error instanceof AnonymousError) return error;
if (error instanceof RateLimitDelayError) return new AnonymousError("github_rate_limit_exceeded", { httpStatus: 429, cause: error });
const status = (error as { status?: number }).status;
return new AnonymousError(status === 401 ? "token_expired" : status === 403 ? "repo_not_accessible" : "github_unavailable", {
httpStatus: status === 401 || status === 403 ? status : 502,
cause: error as Error,
});
}
+99
View File
@@ -0,0 +1,99 @@
import { octokit, waitForTokenGate } from "../GitHubUtils";
import AnonymousError from "../AnonymousError";
import { IFile, UpstreamFile } from "../model/files/files.types";
import { GitHubBaseData } from "./GitHubBase";
export const MAX_SUBMODULES = 20;
export const MAX_SUBMODULE_ENTRIES = 10000;
export interface SubmoduleMount extends Omit<UpstreamFile, "path"> { path: string; }
export function safeRepositoryPath(value: unknown): value is string {
return typeof value === "string" && value.length > 0 &&
!value.includes("\\") && !value.includes("\0") &&
value.split("/").every(part => part !== "" && part !== "." && part !== "..");
}
export function validUpstream(value: unknown): value is UpstreamFile {
if (!value || typeof value !== "object") return false;
const v = value as UpstreamFile;
return typeof v.organization === "string" && /^[a-zA-Z0-9-]+$/.test(v.organization) &&
typeof v.repoName === "string" && /^[a-zA-Z0-9_.-]+$/.test(v.repoName) && ![".", ".."].includes(v.repoName) &&
typeof v.commit === "string" && /^[a-f0-9]{40}$/.test(v.commit) && safeRepositoryPath(v.path);
}
export function submoduleRepository(url: unknown, parent: GitHubBaseData): { organization: string; repoName: string } | null {
if (typeof url !== "string") return null;
const normalized = url.replace(/^git@github\.com:/, "https://github.com/").replace(/^ssh:\/\/git@github\.com\//, "https://github.com/").replace(/^git:\/\/github\.com\//, "https://github.com/");
try {
const parsed = new URL(normalized, url.startsWith("./") || url.startsWith("../")
? `https://github.com/${parent.organization}/${parent.repoName}/` : undefined);
if (parsed.protocol !== "https:" || parsed.hostname !== "github.com" || parsed.port || parsed.username || parsed.password || parsed.search || parsed.hash) return null;
const parts = parsed.pathname.replace(/\.git\/?$/, "").replace(/\/$/, "").split("/");
if (parts.length !== 3) return null;
const candidate = { organization: parts[1], repoName: parts[2], commit: "a".repeat(40), path: "file" };
return validUpstream(candidate) ? { organization: candidate.organization, repoName: candidate.repoName } : null;
} catch { return null; }
}
/** Resolve only direct public GitHub dependencies. Never send the parent token to a child. */
export async function expandSubmodules(files: IFile[], data: GitHubBaseData): Promise<IFile[]> {
const output = files.filter(file => file.name !== ".gitmodules");
const modules = output.filter(file => file.submodule);
let remaining = MAX_SUBMODULE_ENTRIES;
for (const [index, mount] of modules.entries()) {
if (index >= MAX_SUBMODULES) { mount.submodule = "limit"; continue; }
const mountPath = [mount.path, mount.name].filter(Boolean).join("/");
if (!safeRepositoryPath(mountPath) || !mount.sha || !/^[a-f0-9]{40}$/.test(mount.sha)) continue;
const token = await data.getToken();
await waitForTokenGate(token);
try {
const response = await octokit(token).repos.getContent({
owner: data.organization, repo: data.repoName, path: mountPath, ref: data.commit,
});
const metadata = response.data as { submodule_git_url?: string; sha?: string };
const repository = submoduleRepository(metadata.submodule_git_url, data);
if (!repository || metadata.sha !== mount.sha) continue;
// An unauthenticated request proves public access, even for an owner whose
// parent token can also read private dependencies. No credentials cross repos.
await waitForTokenGate("");
const child = await octokit("").git.getTree({
owner: repository.organization, repo: repository.repoName, tree_sha: mount.sha, recursive: "1",
});
if (child.data.truncated || child.data.tree.length > remaining) { mount.submodule = "limit"; continue; }
const entries = child.data.tree;
if (entries.some(entry => !safeRepositoryPath(entry.path))) { mount.submodule = "unsupported"; continue; }
remaining -= entries.length;
mount.submodule = "included";
mount.upstream = { ...repository, commit: mount.sha, path: "" };
for (const entry of entries) {
const relative = entry.path!;
if (relative.split("/").pop() === ".gitmodules") continue;
const fullPath = `${mountPath}/${relative}`;
const slash = fullPath.lastIndexOf("/");
output.push({
repoId: data.repoId, name: fullPath.slice(slash + 1), path: fullPath.slice(0, slash),
sha: entry.sha, size: entry.size,
...(entry.type === "commit" ? { submodule: "nested" as const } : {}),
...(entry.type === "blob" ? { upstream: { ...repository, commit: mount.sha, path: relative } } : {}),
});
}
} catch (error) {
if ((error as { status?: number }).status === 404) { mount.submodule = "unavailable"; continue; }
// Do not persist a partial tree on rate limits, outages or credential errors.
throw error;
}
}
return output;
}
export function validateMounts(value: unknown): SubmoduleMount[] {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length > MAX_SUBMODULES || value.some(mount => !validUpstream(mount))) {
throw new AnonymousError("invalid_request", { httpStatus: 400 });
}
const paths = value.map(mount => mount.path as string);
if (paths.some((path, i) => paths.some((other, j) => i !== j && (path === other || path.startsWith(other + "/"))))) {
throw new AnonymousError("invalid_request", { httpStatus: 400 });
}
return value;
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { appendArchiveEntry } from "../archive-entry";
import { appendArchiveEntry, zipEntryOptions } from "../archive-entry";
import config from "../../config";
import * as fs from "fs";
import { Extract } from "unzip-stream";
@@ -280,7 +280,7 @@ export default class FileSystem extends StorageBase {
output = source.pipe(transformer);
active = output;
}
await appendArchiveEntry(archive, output, { name: filename.slice(dir ? dir.length + 1 : 0) });
await appendArchiveEntry(archive, output, zipEntryOptions(filename.slice(dir ? dir.length + 1 : 0), filename));
}
if (!stopped) await archive.finalize();
})().catch(error => archive.destroy(error));
+2 -2
View File
@@ -1,4 +1,4 @@
import { appendArchiveEntry } from "../archive-entry";
import { appendArchiveEntry, zipEntryOptions } from "../archive-entry";
import {
GetObjectCommand,
ListObjectsV2CommandOutput,
@@ -398,7 +398,7 @@ export default class S3Storage extends StorageBase {
output = source.pipe(transformer);
active = output;
}
await appendArchiveEntry(archive, output, { name: filename.slice(dir.length) });
await appendArchiveEntry(archive, output, zipEntryOptions(filename.slice(dir.length), filename));
}
if (listing.IsTruncated && !next) throw new Error("Invalid S3 continuation token");
} while (next && !stopped);
+11 -17
View File
@@ -4,6 +4,7 @@
*/
export class TermPresenceIndex {
private groups: { indices: number[]; characters: string[] | null }[] = [];
private searches: { indices: number[]; pattern: RegExp }[];
constructor(private readonly patterns: RegExp[]) {
for (let index = 0; index < patterns.length; index++) {
@@ -23,27 +24,20 @@ export class TermPresenceIndex {
}
this.groups.push(group);
}
this.searches = this.groups.map(({ indices }) => ({
indices,
pattern: new RegExp(indices.map(index => `(?:${patterns[index].source})`).join("|"), "iu"),
}));
}
candidates(content: string, after = -1): Set<number> {
const found = new Set<number>();
const remaining = this.groups.map(group => group.indices.filter(index => index > after))
.filter(indices => indices.length);
let offset = 0;
while (remaining.length) {
// Fixed-width inputs contain no captures or quantifiers. Removing a
// discovered group avoids revisiting millions of identical CSV values.
const search = new RegExp(remaining.map(indices =>
`(${indices.map(index => this.patterns[index].source).join("|")})`).join("|"), "giu");
search.lastIndex = offset;
const match = search.exec(content);
if (!match) break;
const selected = match.slice(1).findIndex(value => value !== undefined);
for (const index of remaining[selected]) found.add(index);
remaining.splice(selected, 1);
// Another group's match may start inside this one. Search from the next
// code point, not from the end of the match, to retain nested candidates.
offset = match.index + (content.codePointAt(match.index)! > 0xffff ? 2 : 1);
// Each immutable group is tested independently, so overlapping matches
// cannot hide candidates. Non-global regexes retain no search position.
for (const { indices, pattern } of this.searches) {
if (indices.some(index => index > after) && pattern.test(content)) {
for (const index of indices) if (index > after) found.add(index);
}
}
return found;
}
+87 -57
View File
@@ -1,4 +1,6 @@
import got from "got";
import config from "../config";
import { MAX_SUBMODULE_ENTRIES, SubmoduleMount, safeRepositoryPath, validateMounts } from "./source/github-submodules";
import { Readable, Transform, Writable } from "stream";
import { Parse } from "unzip-stream";
import archiver = require("archiver");
@@ -13,11 +15,13 @@ import {
} from "./anonymize-utils";
import { createLogger, serializeError } from "./logger";
import { startStage } from "./request-monitoring";
import { zipEntryOptions } from "./archive-entry";
const logger = createLogger("zip-stream");
export interface StreamAnonymizedZipOptions {
repoId: string;
submodules?: SubmoduleMount[];
organization: string;
repoName: string;
commit: string;
@@ -77,6 +81,7 @@ export async function streamAnonymizedZip(
on(event: string, listener: (...args: unknown[]) => void): unknown;
}
): Promise<void> {
const submodules = validateMounts(opt.submodules);
let response;
try {
const token = await opt.getToken();
@@ -112,14 +117,10 @@ export async function streamAnonymizedZip(
},
});
}
const upstreamDone = startStage("upstream");
const downloadStream = got.stream(response.url);
downloadStream.once("end", () => upstreamDone());
downloadStream.once("error", () => upstreamDone(true));
downloadStream.once("close", () => upstreamDone(!downloadStream.readableEnded));
let downloadStream: Readable;
const archive = archiver("zip", {});
const parser = Parse() as Transform;
let parser: Transform;
let childEntries = 0;
const activeStreams = new Set<Readable>();
const compiledTerms = compileTerms(opt.anonymizerOptions.terms || []);
let stopped = false;
@@ -159,8 +160,8 @@ export async function streamAnonymizedZip(
stopped = true;
cleanupDrain?.();
cleanupDrain = undefined;
downloadStream.destroy();
parser.destroy();
downloadStream?.destroy();
parser?.destroy();
for (const stream of activeStreams) stream.destroy();
activeStreams.clear();
output.destroy();
@@ -186,53 +187,82 @@ export async function streamAnonymizedZip(
archive.on("error", fail);
archive.pipe(output);
downloadStream
.on("error", fail)
.pipe(parser)
.on("entry", (entry: Readable & { type: string; path: string; autodrain: () => void }) => {
if (stopped) {
entry.destroy();
return;
}
entry.on("error", fail);
activeStreams.add(entry);
entry.once("close", () => activeStreams.delete(entry));
if (entry.type === "File") {
try {
const fileName = anonymizePathCompiled(
entry.path.substring(entry.path.indexOf("/") + 1),
compiledTerms
);
if (!isEntryAllowed(entry.path, opt.contentOptions)) {
entry.autodrain();
return;
}
// Pass filePath via the constructor — AnonymizeTransformer reads it
// there to decide whether the entry is text (and therefore should be
// anonymized) vs binary (passthrough). Assigning afterwards leaves
// isText=false for every file, so the zip ships unanonymized.
parser.pause();
const onArchived = () => { entryWaiting = true; setImmediate(resumeParser); };
archive.once("entry", onArchived);
const anonymizer = new AnonymizeTransformer({
...opt.anonymizerOptions,
filePath: entry.path,
});
anonymizer.on("error", fail);
activeStreams.add(anonymizer);
anonymizer.once("close", () => activeStreams.delete(anonymizer));
const st = entry.pipe(anonymizer);
archive.append(st, { name: fileName });
} catch (error) {
fail(error as Error);
const startArchive = (url: string, prefix: string, next: number) => {
if (stopped) return;
const upstreamDone = startStage("upstream");
const input = got.stream(url);
downloadStream = input;
input.once("end", () => upstreamDone());
input.once("error", () => upstreamDone(true));
input.once("close", () => upstreamDone(!input.readableEnded));
parser = Parse() as Transform;
downloadStream
.on("error", fail)
.pipe(parser)
.on("entry", (entry: Readable & { type: string; path: string; autodrain: () => void }) => {
if (stopped) {
entry.destroy();
return;
}
} else {
entry.autodrain();
}
})
.on("error", fail)
.on("end", () => {
if (stopped) return;
void archive.finalize().catch(fail);
});
entry.on("error", fail);
activeStreams.add(entry);
entry.once("close", () => activeStreams.delete(entry));
if (entry.type === "File") {
try {
const relativePath = entry.path.substring(entry.path.indexOf("/") + 1);
if (!safeRepositoryPath(relativePath)) throw new AnonymousError("invalid_file_path", { httpStatus: 502 });
if (relativePath.split("/").pop() === ".gitmodules") { entry.autodrain(); return; }
if (prefix && ++childEntries > MAX_SUBMODULE_ENTRIES) throw new AnonymousError("file_too_big", { httpStatus: 413 });
const mountedPath = prefix ? `${prefix}/${relativePath}` : relativePath;
const fileName = anonymizePathCompiled(mountedPath, compiledTerms);
if (!isEntryAllowed(entry.path, opt.contentOptions)) {
entry.autodrain();
return;
}
// Pass filePath via the constructor — AnonymizeTransformer reads it
// there to decide whether the entry is text (and therefore should be
// anonymized) vs binary (passthrough). Assigning afterwards leaves
// isText=false for every file, so the zip ships unanonymized.
parser.pause();
const onArchived = () => { entryWaiting = true; setImmediate(resumeParser); };
archive.once("entry", onArchived);
const anonymizer = new AnonymizeTransformer({
...opt.anonymizerOptions,
filePath: mountedPath,
});
anonymizer.on("error", fail);
activeStreams.add(anonymizer);
anonymizer.once("close", () => activeStreams.delete(anonymizer));
let source: Readable = entry;
if (prefix) {
let bytes = 0;
const limited = new Transform({ transform(chunk, encoding, callback) {
bytes += chunk.length;
callback(bytes > config.MAX_FILE_SIZE ? new AnonymousError("file_too_big", { httpStatus: 413 }) : null, chunk);
} });
limited.on("error", fail);
activeStreams.add(limited);
limited.once("close", () => activeStreams.delete(limited));
source = entry.pipe(limited);
}
const st = source.pipe(anonymizer);
archive.append(st, zipEntryOptions(fileName, mountedPath));
} catch (error) {
fail(error as Error);
}
} else {
entry.autodrain();
}
})
.on("error", fail)
.on("end", () => {
if (stopped) return;
const mount = submodules[next];
if (mount) {
// Public child archives never receive parent credentials.
startArchive(`https://codeload.github.com/${encodeURIComponent(mount.organization)}/${encodeURIComponent(mount.repoName)}/zip/${mount.commit}`, mount.path, next + 1);
} else void archive.finalize().catch(fail);
});
};
startArchive(response.url, "", 0);
}
+145
View File
@@ -0,0 +1,145 @@
import { geoPath, geoMercator, geoCentroid, geoArea } from "d3-geo";
import { feature } from "topojson-client";
import type { Topology, GeometryObject } from "topojson-specification";
import type { GeoPermissibleObjects } from "d3-geo";
import type { GeoJSON } from "geojson";
const MAX_SOURCE_LENGTH = 1_000_000;
const MAX_POINTS = 60_000;
function validateMap(input: unknown, topology: boolean): void {
let nodes = 0;
const inspect = (value: unknown, depth = 0): void => {
if (++nodes > 300_000 || depth > 32) throw new Error("Map complexity limit exceeded");
if (value && typeof value === "object") for (const child of Object.values(value)) inspect(child, depth + 1);
};
inspect(input);
const array = (value: unknown): unknown[] => {
if (!Array.isArray(value)) throw new Error("Expected a map array");
return value;
};
const object = (value: unknown): Record<string, unknown> => {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Expected a map object");
return value as Record<string, unknown>;
};
let points = 0;
const position = (value: unknown): void => {
const numbers = array(value);
if (numbers.length < 2 || numbers.some(n => typeof n !== "number" || !Number.isFinite(n) || Math.abs(n) > 1e100)) throw new Error("Invalid map position");
if (++points > MAX_POINTS) throw new Error("Map point limit exceeded");
};
const positions = (value: unknown, depth: number): void => {
if (depth === 0) position(value);
else for (const child of array(value)) positions(child, depth - 1);
};
const root = object(input);
let arcLengths: number[] = [];
if (topology) {
if (root.type !== "Topology") throw new Error("Expected Topology");
arcLengths = array(root.arcs).map(arc => {
const coordinates = array(arc);
if (!coordinates.length) throw new Error("Empty topology arc");
coordinates.forEach(position);
return coordinates.length;
});
if (root.transform != null) {
const transform = object(root.transform);
position(transform.scale); position(transform.translate);
}
}
let expandedPoints = 0;
const arcs = (value: unknown, depth: number): void => {
for (const child of array(value)) {
if (depth > 0) arcs(child, depth - 1);
else {
if (typeof child !== "number" || !Number.isInteger(child) || child < -2147483648 || child > 2147483647) throw new Error("Invalid arc index");
const length = arcLengths[child < 0 ? ~child : child];
if (length === undefined) throw new Error("Missing topology arc");
expandedPoints += Math.max(4, length);
if (expandedPoints > MAX_POINTS) throw new Error("Topology expansion limit exceeded");
}
}
};
const geometry = (value: unknown): void => {
const item = object(value);
switch (item.type) {
case "FeatureCollection": array(item.features).forEach(geometry); break;
case "Feature": if (item.geometry != null) geometry(item.geometry); break;
case "GeometryCollection": array(item.geometries).forEach(geometry); break;
case "Point": position(item.coordinates); break;
case "MultiPoint": positions(item.coordinates, 1); break;
case "LineString": if (topology) arcs(item.arcs, 0); else positions(item.coordinates, 1); break;
case "MultiLineString":
case "Polygon": if (topology) arcs(item.arcs, 1); else positions(item.coordinates, 2); break;
case "MultiPolygon": if (topology) arcs(item.arcs, 2); else positions(item.coordinates, 3); break;
default: throw new Error("Unsupported map geometry");
}
};
if (topology) Object.values(object(root.objects)).forEach(geometry);
else geometry(root);
}
// D3's spherical polygons use the opposite winding from RFC 7946 GeoJSON.
function orientPolygons(value: GeoJSON): void {
if (value.type === "FeatureCollection") value.features.forEach(orientPolygons);
else if (value.type === "Feature" && value.geometry) orientPolygons(value.geometry);
else if (value.type === "GeometryCollection") value.geometries.forEach(orientPolygons);
else if (value.type === "Polygon" || value.type === "MultiPolygon") {
const polygons = value.type === "Polygon" ? [value.coordinates] : value.coordinates;
for (const polygon of polygons) polygon.forEach((ring, index) => {
const large = geoArea({ type: "Polygon", coordinates: [ring] }) > 2 * Math.PI;
if (large === (index === 0)) ring.reverse();
});
}
}
type Vertex = [number, number, number];
export function stlPaths(vertices: Vertex[], angle = 0.6): string {
const points = vertices.map(([x, y, z]) => {
const u = x * Math.cos(angle) - z * Math.sin(angle);
const depth = x * Math.sin(angle) + z * Math.cos(angle);
return [u, -y * Math.cos(0.45) + depth * Math.sin(0.45), depth];
});
const bounds = points.reduce((b, p) => [Math.min(b[0], p[0]), Math.min(b[1], p[1]), Math.max(b[2], p[0]), Math.max(b[3], p[1])], [Infinity, Infinity, -Infinity, -Infinity]);
const scale = 260 / (Math.max(bounds[2] - bounds[0], bounds[3] - bounds[1]) || 1);
const triangles: { depth: number; path: string }[] = [];
for (let i = 0; i < points.length; i += 3) {
const triangle = points.slice(i, i + 3);
const coordinates = triangle.map(p => `${(320 + (p[0] - (bounds[0] + bounds[2]) / 2) * scale).toFixed(3)},${(160 + (p[1] - (bounds[1] + bounds[3]) / 2) * scale).toFixed(3)}`);
triangles.push({ depth: triangle.reduce((sum, p) => sum + p[2], 0), path: `<path d="M${coordinates.join("L")}Z"/>` });
}
return triangles.sort((a, b) => a.depth - b.depth).map(t => t.path).join("");
}
export function renderDiagram(source: string, language: string): string {
if (source.length > MAX_SOURCE_LENGTH) throw new Error("Diagram source limit exceeded");
let paths: string;
let data = "";
if (language === "stl") {
if (!/^\s*solid\b/.test(source) || !/\bendsolid\b/.test(source)) throw new Error("Invalid ASCII STL");
const vertices: Vertex[] = [...source.matchAll(/\bvertex\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)/g)].map(match => match.slice(1).map(Number) as Vertex);
if (!vertices.length || vertices.length % 3 || vertices.length > 60000 || vertices.some(v => v.some(n => !Number.isFinite(n) || Math.abs(n) > 1e100))) throw new Error("Invalid STL vertices");
paths = stlPaths(vertices);
data = ` data-vertices="${JSON.stringify(vertices)}"`;
} else {
const input = JSON.parse(source);
validateMap(input, language === "topojson");
const geometry = language === "topojson"
? { type: "FeatureCollection", features: Object.values((input as Topology).objects).flatMap(object => {
const converted = feature(input as Topology, object as GeometryObject);
return converted.type === "FeatureCollection" ? converted.features : [converted];
}) }
: input;
if (language === "topojson") validateMap(geometry, false);
orientPolygons(geometry);
const projection = geoMercator().fitExtent([[20, 20], [620, 300]], geometry as GeoPermissibleObjects);
// A lone point has zero extent; center it at a useful finite scale.
if (!Number.isFinite(projection.scale())) projection.scale(100).center(geoCentroid(geometry as GeoPermissibleObjects)).translate([320, 160]);
const path = geoPath(projection).pointRadius(5)(geometry as GeoPermissibleObjects);
if (!path || /NaN|Infinity/.test(path)) throw new Error("Invalid map geometry");
paths = `<path d="${path}"/>`;
}
const label = language === "stl" ? "3D model" : "Map";
return `<figure class="markdown-diagram" data-diagram="${language}"${data}><svg viewBox="0 0 640 320" role="img" aria-label="${label}"><g>${paths}</g></svg><figcaption>${label}</figcaption><div class="diagram-controls"><button type="button" data-diagram-action="in" aria-label="Zoom in">+</button><button type="button" data-diagram-action="out" aria-label="Zoom out">−</button>${language === "stl" ? '<button type="button" data-diagram-action="rotate">Rotate</button>' : ''}<button type="button" data-diagram-action="reset">Reset</button></div></figure>`;
}
+173
View File
@@ -0,0 +1,173 @@
import { Marked, Tokens } from "marked";
const markedFootnote: typeof import("marked-footnote").default = require("marked-footnote");
const markedAlert: typeof import("marked-alert").default = require("marked-alert");
const markedKatex: typeof import("marked-katex-extension").default = require("marked-katex-extension");
import { gfmHeadingId } from "marked-gfm-heading-id";
import * as katex from "katex";
import sanitizeHtml = require("sanitize-html");
const hljs: typeof import("highlight.js/lib/common").default = require("highlight.js/lib/common");
import { renderDiagram } from "./markdown-diagrams";
const emojis: Record<string, string> = require("../public/script/external/github-emojis");
export const escapeHtml = (value: string): string => value.replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]!));
function resolveUrl(url: string, base?: string): string {
if (!base || !url || /^(?:#|\/|[a-z][a-z\d+.-]*:)/i.test(url)) return url;
try {
const resolved = new URL(url, new URL(base, "https://markdown.invalid"));
return /^https?:/i.test(base) ? resolved.href : resolved.pathname + resolved.search + resolved.hash;
} catch { return url; }
}
/** The browser and project-page route use this same parser and sanitizer. */
export function renderMarkdown(source: string, base?: string): string {
// Only renderer-generated math/SVG bypasses HTML sanitization. An unpredictable,
// per-document marker prevents raw HTML from impersonating generated output.
const nonce = Array.from(globalThis.crypto.getRandomValues(new Uint32Array(4)), n => n.toString(16)).join("-");
const generated: string[] = [];
const trusted = (html: string) => `<span data-render-token="${nonce}-${generated.push(html) - 1}"></span>`;
const math = (text: string, displayMode: boolean) => trusted(katex.renderToString(text, {
displayMode, throwOnError: false, trust: false, maxExpand: 1000, maxSize: 20,
}));
const parser = new Marked({ gfm: true, breaks: false, async: false });
const footnotes = markedFootnote({ prefixId: `footnote-${nonce}-` });
for (const extension of footnotes.extensions || []) {
if ("renderer" in extension && extension.renderer) {
const render = extension.renderer;
extension.renderer = function (token) {
const html = render.call(this, token);
return typeof html === "string" ? html.replaceAll(`data-footnote-${nonce}-`, "data-footnote-") : html;
};
}
}
parser.use(footnotes, gfmHeadingId());
const alerts = markedAlert();
const topLevelQuotes = new WeakSet();
const alertWalk = alerts.walkTokens!;
alerts.walkTokens = function (token) {
if (topLevelQuotes.has(token)) return alertWalk.call(this, token);
};
alerts.hooks = { processAllTokens(tokens) {
for (const token of tokens) if (token.type === "blockquote") topLevelQuotes.add(token);
return tokens;
} };
// Preserve the extension's fixed icons without admitting arbitrary source SVG.
for (const extension of alerts.extensions || []) {
if ("renderer" in extension && extension.renderer) {
const render = extension.renderer;
extension.renderer = function (token) {
const icon = token.meta.icon;
token.meta.icon = trusted(icon);
return render.call(this, token);
};
}
}
parser.use(alerts);
const mathExtension = markedKatex({ throwOnError: false, nonStandard: false });
for (const extension of mathExtension.extensions || []) {
if ("renderer" in extension) extension.renderer = token => math(token.text, token.displayMode);
}
parser.use(mathExtension, {
extensions: [{
name: "protocolEmail", level: "inline",
start: src => {
const match = /(?:^|[\s/])(mailto:|xmpp:)/.exec(src);
return match ? match.index + match[0].length - match[1].length : undefined;
},
tokenizer(src) {
if (this.lexer.state.inLink) return;
const match = /^(mailto:|xmpp:)([\w.+-]+@[\w.-]+\.[\w-]+)(\/[^\s<>]*)?/.exec(src);
if (!match) return;
const text = (match[1] + match[2] + (match[1] === "xmpp:" ? match[3] || "" : "")).replace(/[.,!?]+$/, "");
return { type: "protocolEmail", raw: text, text };
},
renderer: token => `<a href="${escapeHtml(token.text)}">${escapeHtml(token.text)}</a>`,
}, {
name: "githubMath", level: "inline",
start: src => src.indexOf("$`"),
tokenizer(src) {
const match = /^\$`([^\n]*?)`\$/.exec(src);
if (match) return { type: "githubMath", raw: match[0], text: match[1] };
},
renderer: token => math(token.text, false),
}, {
name: "emoji", level: "inline", start: src => src.search(/:[\w+-]+:/),
tokenizer(src) {
const match = /^:([\w+-]+):/.exec(src);
if (match && Object.prototype.hasOwnProperty.call(emojis, match[1])) return { type: "emoji", raw: match[0], name: match[1] };
},
renderer: token => `<img class="emoji" alt=":${token.name}:" width="20" height="20" src="${emojis[token.name]}">`,
}],
tokenizer: {
url(src) {
const match = /^(?:https?:\/\/|www\.)[^\s<>]+/.exec(src);
if (!match) return false;
const host = match[0].replace(/^https?:\/\//, "").split(/[/?#]/)[0].split(".");
if (host.slice(-2).some(part => part.includes("_"))) return { type: "link", raw: match[0], text: match[0], href: "", invalidAutolink: true, tokens: [] };
const unicodeHost = /^https?:\/\//.test(match[0]) && match[0].slice(match[0].indexOf("://") + 3).charCodeAt(0) > 127;
if (!match[0].startsWith("www._") && !unicodeHost) return false;
const text = match[0].replace(/[?!.,:*_'"~]+$/, "");
return { type: "link", raw: text, href: encodeURI(text.startsWith("www.") ? `http://${text}` : text), text, tokens: [{ type: "text", raw: text, text: escapeHtml(text) }] };
},
},
renderer: {
link(token: Tokens.Link & { invalidAutolink?: boolean }) {
if (token.invalidAutolink) return escapeHtml(token.text);
if (/\.(mp4|mov)(?:[?#]|$)/i.test(token.href)) return `<video controls src="${escapeHtml(token.href)}" title="${escapeHtml(token.title || token.text)}">${this.parser.parseInline(token.tokens)}</video>`;
return false;
},
code(token: Tokens.Code) {
const language = (token.lang || "").split(/\s+/)[0].toLowerCase();
if (language === "math") return math(token.text, true);
if (language === "mermaid") return `<pre class="mermaid">${escapeHtml(token.text)}</pre>`;
if (["geojson", "topojson", "stl"].includes(language)) {
try { return trusted(renderDiagram(token.text, language)); }
catch { /* Invalid diagrams retain their readable source. */ }
}
if (language && hljs.getLanguage(language)) {
return `<pre><code class="language-${escapeHtml(language)} hljs">${hljs.highlight(token.text, { language, ignoreIllegals: true }).value}</code></pre>\n`;
}
return false;
},
},
});
const raw = parser.parse(source) as string;
const clean = sanitizeMarkdownHtml(raw, base);
return clean.replace(new RegExp(`<span data-render-token="${nonce}-(\\d+)"></span>`, "g"), (_match, index) => generated[Number(index)]);
}
export function sanitizeMarkdownHtml(raw: string, base?: string): string {
return sanitizeHtml(raw, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat([
"img", "picture", "source", "video", "input", "details", "summary", "del", "ins", "sup", "sub", "kbd", "samp", "var", "ruby", "rt", "rp",
]),
allowedAttributes: {
"*": ["id", "class", "dir", "lang", "title", "aria-label", "aria-labelledby", "aria-describedby", "aria-hidden", "align"],
a: ["href", "name", "rel", "data-footnote-ref", "data-footnote-backref"],
img: ["src", "srcset", "alt", "width", "height", "loading", "align"],
source: ["src", "srcset", "media", "type"],
video: ["src", "controls", "width", "height", "poster"],
input: ["type", "checked", "disabled"],
details: ["open"], ol: ["start", "reversed"], li: ["value"],
td: ["align", "colspan", "rowspan"], th: ["align", "colspan", "rowspan", "scope"],
span: ["data-render-token"], section: ["data-footnotes"],
},
allowedSchemes: ["http", "https", "mailto", "xmpp"],
transformTags: {
input: (_tag, attrs) => ({ tagName: "input", attribs: { type: "checkbox", disabled: "", ...(Object.prototype.hasOwnProperty.call(attrs, "checked") ? { checked: "" } : {}) } }),
"*": (tagName, attribs) => {
if (tagName === "img" && /#gh-(dark|light)-mode-only$/.test(attribs.src || "")) {
attribs.class = `${attribs.class || ""} ${attribs.src.endsWith("#gh-dark-mode-only") ? "gh-dark-mode-only" : "gh-light-mode-only"}`.trim();
}
for (const attr of ["href", "src", "poster"]) if (attribs[attr]) attribs[attr] = resolveUrl(attribs[attr], base);
if (attribs.srcset) attribs.srcset = attribs.srcset.split(",").map(candidate => {
const [url, ...descriptor] = candidate.trim().split(/\s+/);
return [resolveUrl(url, base), ...descriptor].join(" ");
}).join(", ");
return { tagName, attribs };
},
},
});
}
@@ -2,6 +2,7 @@ import { githubQuotaKey } from "../../core/github-token-context";
import { SandboxedJob } from "bullmq";
import { config } from "dotenv";
config();
import { notifyOwnerAccessProblem } from "../../core/owner-notifications";
import { getRepository as getRepositoryImport } from "../../server/database";
import { RepositoryStatus } from "../../core/types";
import { RepoJobData } from "../index";
@@ -142,6 +143,7 @@ export default async function (job: SandboxedJob<RepoJobData, void>) {
if (error instanceof DelayedError || (error instanceof Error && error.name === "DelayedError")) {
throw error;
}
await notifyOwnerAccessProblem(repo.owner.id, error, { kind: "repository", id: String(repo.model._id) });
logger.error("finished with error", {
...serializeError(error),
repoId: job.data.repoId,
@@ -0,0 +1,17 @@
import mongoose from "mongoose";
import config from "../config";
import { getMongoUrl } from "../server/database";
import { prepareLegacyOwnerAlertMigration } from "../core/owner-notifications";
async function main(): Promise<void> {
await mongoose.connect(getMongoUrl(), config.MONGODB_URI ? {} : { authSource: "admin" });
try {
await prepareLegacyOwnerAlertMigration();
process.stdout.write("Legacy owner alert reconciliation prepared\n");
} finally { await mongoose.disconnect(); }
}
void main().catch(() => {
process.stderr.write("Could not prepare legacy owner alert reconciliation\n");
process.exitCode = 1;
});
+2
View File
@@ -4,6 +4,7 @@ import CredentialModel from "../core/model/credentials/credentials.model";
import mongoose, { ConnectOptions } from "mongoose";
import Repository from "../core/Repository";
import config from "../config";
import { initializeOwnerAccessAlerts } from "../core/owner-notifications";
import AnonymizedRepositoryModel from "../core/model/anonymizedRepositories/anonymizedRepositories.model";
import AnonymousError from "../core/AnonymousError";
import AnonymizedPullRequestModel from "../core/model/anonymizedPullRequests/anonymizedPullRequests.model";
@@ -34,6 +35,7 @@ export async function connect(overrides: Pick<ConnectOptions, "appName" | "maxPo
await mongoose.connect(getMongoUrl(), options);
await CredentialModel.createIndexes();
if (config.GITHUB_APP_ENABLED) await InstallationModel.createIndexes();
await initializeOwnerAccessAlerts();
isConnected = true;
return database;
+55 -13
View File
@@ -1,5 +1,7 @@
import { startBillingMaintenance } from "../core/billing/maintenance";
import billing, { billingWebhook } from "./routes/billing";
import { sendRequestRateLimit } from "./request-rate-limit";
import { installFatalErrorLogging } from "../core/process-monitoring";
import { githubAppRouter, githubAppWebhook } from "./routes/github-app";
import { config as dotenv } from "dotenv";
dotenv();
@@ -16,7 +18,10 @@ import * as passport from "passport";
import { connect } from "./database";
import { startTemporaryStorageMaintenance } from "../core/temporary-storage";
import { initSession, router as connectionRouter } from "./routes/connection";
import maintenanceRouter from "./routes/maintenance";
import maintenanceTokens from "./routes/maintenance-tokens";
import { bearerTokenAuth } from "./routes/token-auth";
import { tokenAttemptLimitOptions, maintenanceRefreshLimitOptions } from "./routes/maintenance-limits";
import router from "./routes";
import {
conferenceStatusCheck,
@@ -39,11 +44,18 @@ import config from "../config";
import { resolveTrustProxy } from "./trustProxy";
import { requestRateLimitKey } from "./rate-limit-key";
import { createLogger, serializeError } from "../core/logger";
import { serverOptions } from "./server-options";
import { monitorRequests } from "../core/request-monitoring";
import { startPerformanceMonitoring } from "../core/performance-monitoring";
import { getAnonymizationPoolStats } from "../core/anonymization-pool";
import { createReviewCapabilities } from "./service/review-capabilities";
import { createReviewConsentPage, isReviewConsentPagePath } from "./review-consent-page";
const reviewConsentPage = createReviewConsentPage();
const logger = createLogger("server");
installFatalErrorLogging("api");
// Lazily build the templated index.html on first request so the server
// works even when started before `gulp` finishes.
@@ -80,6 +92,7 @@ function getIndexHtml(): string {
}
function indexResponse(req: express.Request, res: express.Response) {
if (isReviewConsentPagePath(req.path)) return reviewConsentPage(req, res);
if (
req.path.startsWith("/script") ||
req.path.startsWith("/css") ||
@@ -106,8 +119,10 @@ function indexResponse(req: express.Request, res: express.Response) {
export default async function start() {
const app = express();
app.use(monitorRequests("api"));
app.use(serverOptions);
startPerformanceMonitoring("api", getAnonymizationPoolStats);
app.set("query parser", "extended");
app.use("/service", createReviewCapabilities(process.env.REVIEW_SERVICE_KEYS));
app.use("/github/app/webhook", githubAppWebhook);
app.use("/api/billing/webhook", billingWebhook);
app.use(express.json());
@@ -140,12 +155,6 @@ export default async function start() {
// client-spoofable header.
app.set("trust proxy", resolveTrustProxy(config.TRUST_PROXY));
// handle session and connection
app.use(initSession());
app.use(passport.initialize());
app.use(passport.session());
app.use(bearerTokenAuth);
startWorker();
const redisClient = createClient({
@@ -160,6 +169,32 @@ export default async function start() {
await redisClient.connect();
// Charge failed and forbidden bearer requests before their database lookup.
app.use(rateLimit({
...tokenAttemptLimitOptions,
store: new RedisStore({
prefix: "token-attempt:",
sendCommand: (...args: string[]) => redisClient.sendCommand(args),
}),
}));
// Bearer attempts are limited before session deserialization as well.
app.use(initSession());
app.use(passport.initialize());
app.use(passport.session());
app.use(bearerTokenAuth);
app.use(rateLimit({
...maintenanceRefreshLimitOptions,
store: new RedisStore({
prefix: "maintenance-refresh:",
sendCommand: (...args: string[]) => redisClient.sendCommand(args),
}),
}));
// All maintenance tokens for an account share a budget across client IPs.
const apiRateKey = (req: express.Request, res: express.Response) =>
res.locals.maintenanceOwnerId
? `maintenance:${res.locals.maintenanceOwnerId}`
: requestRateLimitKey(req);
const rate = rateLimit({
store: new RedisStore({
sendCommand: (...args: string[]) => redisClient.sendCommand(args),
@@ -168,7 +203,7 @@ export default async function start() {
skip: async (request: express.Request, _response: express.Response) => {
try {
const user = await getUser(request);
if (user && user.isAdmin) return true;
if (user && user.isAdmin && !_response.locals.maintenanceToken) return true;
} catch {
// ignore: user not connected
}
@@ -184,26 +219,24 @@ export default async function start() {
// if not logged in, limit to half the rate
return config.RATE_LIMIT / 2;
},
keyGenerator: requestRateLimitKey,
keyGenerator: apiRateKey,
standardHeaders: "draft-6",
legacyHeaders: false,
message: (_request: express.Request, _response: express.Response) => {
return `You can only make ${config.RATE_LIMIT} requests every 15min. Please try again later.`;
},
handler: sendRequestRateLimit,
});
const speedLimiter = slowDown({
windowMs: 15 * 60 * 1000, // 15 minutes
delayAfter: 50,
delayMs: () => 150,
maxDelayMs: 5000,
keyGenerator: requestRateLimitKey,
keyGenerator: apiRateKey,
});
const webViewSpeedLimiter = slowDown({
windowMs: 15 * 60 * 1000, // 15 minutes
delayAfter: 200,
delayMs: () => 150,
maxDelayMs: 5000,
keyGenerator: requestRateLimitKey,
keyGenerator: apiRateKey,
});
app.use("/github", rate, speedLimiter, githubAppRouter);
@@ -214,6 +247,8 @@ export default async function start() {
app.use("/api", rate, apiRouter);
apiRouter.use("/billing", billing);
apiRouter.use("/maintenance", speedLimiter, maintenanceRouter);
apiRouter.use("/user/maintenance-tokens", maintenanceTokens);
apiRouter.use("/admin", router.admin);
apiRouter.use("/options", router.option);
apiRouter.use("/conferences", router.conference);
@@ -283,6 +318,13 @@ export default async function start() {
app.use(
express.static(join("public"), {
etag: true,
setHeaders(res, filePath) {
// Sandboxed Markdown pages have an opaque origin; public math fonts
// must permit anonymous cross-origin requests without relaxing CSP.
if (/[/\\]css[/\\]fonts[/\\]KaTeX_[^/\\]+\.(?:woff2?|ttf)$/.test(filePath)) {
res.setHeader("Access-Control-Allow-Origin", "*");
}
},
lastModified: true,
maxAge: 86400000, // 1 day (fonts, images, partials)
index: false, // don't serve index.html for "/" — indexResponse handles it
+9
View File
@@ -0,0 +1,9 @@
import { Request, Response } from "express";
/** Give clients a retry time instead of an unparseable plain-text failure. */
export function sendRequestRateLimit(req: Request, res: Response) {
const rate = (req as Request & { rateLimit?: { resetTime?: Date } }).rateLimit;
const resetAt = rate?.resetTime?.getTime() || Date.now() + 15 * 60_000;
res.setHeader("Retry-After", String(Math.max(1, Math.ceil((resetAt - Date.now()) / 1000))));
res.status(429).json({ error: "rate_limited", resetAt });
}
+60
View File
@@ -0,0 +1,60 @@
import { ReviewCallbacks } from "./service/review-callbacks";
import { Request, Response } from "express";
import { existsSync, readFileSync } from "fs";
import { resolve } from "path";
// Reserve aliases too: they must never fall through to the ordinary page,
// which loads third-party scripts. Only the canonical URL serves this page.
export function isReviewConsentPagePath(path: string): boolean {
try {
return (
decodeURIComponent(path).toLowerCase().replace(/\/+$/, "") ===
"/review-link"
);
} catch {
return false;
}
}
export function createReviewConsentPage(
manifestPath = resolve("public", "asset-manifest.json"),
callbacks?: ReviewCallbacks,
) {
return (req: Request, res: Response): void => {
res.set({
"Cache-Control": "no-store",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Content-Security-Policy":
"default-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action " + (callbacks?.formAction || "'self'") + "; frame-ancestors 'none'",
});
if (
req.originalUrl !== "/review-link" ||
(req.method !== "GET" && req.method !== "HEAD")
) {
res.status(404).json({ error: "not_found" });
return;
}
let manifest: Record<string, unknown> = {};
try {
if (existsSync(manifestPath)) {
const parsed: unknown = JSON.parse(readFileSync(manifestPath, "utf8"));
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
manifest = parsed as Record<string, unknown>;
}
} catch {
// A missing or malformed manifest uses the ordinary unhashed assets.
}
const asset = (name: string): string => {
const value = manifest[name];
const extension = name.endsWith(".js") ? "js" : "css";
return typeof value === "string" &&
new RegExp(`^[A-Za-z0-9][A-Za-z0-9_.-]*\\.${extension}$`).test(value)
? value
: name;
};
res.type("html").send(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="referrer" content="no-referrer"><meta name="review-consent-page" content="1"><title>Artifact consent · Anonymous GitHub</title><link rel="stylesheet" href="/css/${asset("all.min.css")}"></head><body><div id="app"></div><script src="/script/${asset("core.min.js")}"></script><script defer src="/script/${asset("vendor.min.js")}"></script></body></html>`);
};
}
+10 -1
View File
@@ -381,7 +381,16 @@ router.get("/queues", dashboardCache, async (req, res) => {
...QUEUE_STATES.map(async (state) => {
const jobs = await targetQueue.getJobs([state], 0, 199);
return jobs.map((j) => {
const json: Record<string, unknown> = { ...j.asJSON(), _state: state };
// asJSON() serializes these fields to strings; the dashboard needs
// the objects (payload repoId, stack lines, failure reason).
const json: Record<string, unknown> = {
...j.asJSON(),
data: j.data,
failedReason: j.failedReason,
stacktrace: j.stacktrace,
returnvalue: j.returnvalue,
_state: state,
};
if (state === "delayed" && j.delay > 0) {
json.delayUntil = j.timestamp + j.delay;
}
+11 -6
View File
@@ -1,3 +1,5 @@
import { resetOwnerAccessAlerts } from "../../core/owner-notifications";
import { saveRegistrationEmail } from "../../core/registration-email";
import { createClient } from "redis";
import * as passport from "passport";
import * as session from "express-session";
@@ -99,12 +101,9 @@ export const verify = async (
externalIDs: {
github: profile.id,
},
emails: profile.emails?.map((email) => {
return { email: email.value, default: false };
}),
emails: [],
photo,
});
if (user.emails?.length) user.emails[0].default = true;
await user.save();
}
}
@@ -120,6 +119,8 @@ export const verify = async (
return;
}
await setCredential(String(user!._id), accessToken);
await saveRegistrationEmail(user!, accessToken);
if (flow.ownerId) await resetOwnerAccessAlerts(String(user!._id));
done(null, { username: user!.username, user });
} catch (error) {
logger.error("verify failed", serializeError(error));
@@ -206,7 +207,7 @@ router.get(
returnTo: safeAuthReturnTo(req.query.returnTo, current ? "/connections" : "/dashboard"), expires: Date.now() + 10 * 60000 };
next();
},
passport.authenticate("github", { scope: ["repo"] }), // Note the scope here
passport.authenticate("github", { scope: ["repo", "user:email"] }),
function (req: express.Request, res: express.Response) {
res.redirect("/");
}
@@ -251,7 +252,11 @@ router.post(
const model = await UserModel.findOne({
"apiTokens.tokenHash": hashToken(token),
});
if (!model) return res.status(401).json({ error: "invalid_token" });
const entry = model?.apiTokens?.find(t => t.tokenHash === hashToken(token));
if (!model || !entry || entry.scope ||
(entry.expiresAt && entry.expiresAt.getTime() <= Date.now())) {
return res.status(401).json({ error: "invalid_token" });
}
if (isDisabledAccount(model.status)) {
return res.status(model.status === "banned" ? 403 : 401).json({
error: model.status === "banned" ? "user_banned" : "not_connected",
+9 -2
View File
@@ -21,8 +21,8 @@ function decodePathSegment(segment: string): string {
}
}
// Extensions the browser will execute script from when it renders the
// response as a document (directly, or via the "Raw" action).
// Keep explicit fallbacks for markup types not consistently mapped by MIME
// databases. MIME classification below also covers aliases such as svgz/shtml.
const SCRIPTABLE_EXTENSIONS = new Set([
"html",
"htm",
@@ -33,10 +33,16 @@ const SCRIPTABLE_EXTENSIONS = new Set([
"xsl",
"xslt",
"mhtml",
"mht",
]);
export function isScriptableDocument(anonymizedPath: string): boolean {
const name = anonymizedPath.substring(anonymizedPath.lastIndexOf("/") + 1);
const mime = lookup(name);
if (mime && (
mime === "text/html" || mime === "text/xml" || mime === "application/xml" ||
mime.endsWith("+xml") || mime === "message/rfc822" || mime === "multipart/related"
)) return true;
const dot = name.lastIndexOf(".");
if (dot < 0) return false;
return SCRIPTABLE_EXTENSIONS.has(name.substring(dot + 1).toLowerCase());
@@ -115,6 +121,7 @@ router.get(
object: f,
});
}
res.header("X-Content-Type-Options", "nosniff");
if (req.query.download) {
res.attachment(
anonymizedPath.substring(anonymizedPath.lastIndexOf("/") + 1)
+4
View File
@@ -1,3 +1,4 @@
import { resetOwnerAccessAlerts } from "../../core/owner-notifications";
import * as express from "express";
import { ensureAuthenticated } from "./connection";
@@ -29,6 +30,7 @@ router.post(
const user = await getUser(req);
isOwnerOrAdmin([gist.owner.id], user);
if (user.id === gist.owner.id) await resetOwnerAccessAlerts(user.id, { kind: "gist", id: String(gist.model._id) });
await gist.updateIfNeeded({ force: true });
res.json({ status: gist.status });
} catch (error) {
@@ -68,6 +70,7 @@ router.post(
{ $set: { "options.expirationDate": newExpiration } }
).exec();
if (user.id === gist.owner.id) await resetOwnerAccessAlerts(user.id, { kind: "gist", id: String(gist.model._id) });
await gist.updateIfNeeded({ force: true });
res.json({ status: gist.status, expirationDate: newExpiration });
} catch (error) {
@@ -222,6 +225,7 @@ router.post(
}
).exec();
await gist.updateStatus(RepositoryStatus.PREPARING);
if (user.id === gist.owner.id) await resetOwnerAccessAlerts(user.id, { kind: "gist", id: String(gist.model._id) });
await gist.updateIfNeeded({ force: true });
res.json(gist.toJSON());
} catch (error) {
+44 -4
View File
@@ -1,3 +1,5 @@
import { notificationEmail, normalizeEmail, resetOwnerAccessAlerts } from "../../core/owner-notifications";
import { saveRegistrationEmail } from "../../core/registration-email";
import * as express from "express";
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
import config from "../../config";
@@ -16,7 +18,7 @@ import { isDisabledAccount, safeAuthReturnTo } from "./auth-utils";
type Flow = { state: string; expires: number; ownerId?: string; returnTo: string; repository?: string; install?: boolean };
declare module "express-session" {
interface SessionData { githubAppFlow?: Flow; githubInstallFlow?: Flow; githubConnectionCSRF?: string; }
interface SessionData { githubAppFlow?: Flow; githubInstallFlow?: Flow; githubConnectionCSRF?: string; emailPromptDismissed?: boolean; }
}
export function safeReturnTo(value: unknown): string {
return safeAuthReturnTo(value, "/connections");
@@ -28,6 +30,12 @@ export function consumeFlow(flow: Flow | undefined, state: unknown): Flow {
function newFlow(ownerId: string | undefined, returnTo: unknown): Flow {
return { state: randomBytes(32).toString("hex"), expires: Date.now() + 10 * 60000, ownerId, returnTo: safeReturnTo(returnTo) };
}
// Parallel user/connections reads must initialize the same token even when
// both loaded the session before either request saved it.
export function connectionCSRF(req: express.Request): string {
return req.session.githubConnectionCSRF ||= createHmac("sha256", config.SESSION_SECRET)
.update(`github-connections:${req.sessionID}`).digest("hex");
}
function saveSession(req: express.Request) { return new Promise<void>((resolve, reject) => req.session.save(err => err ? reject(err) : resolve())); }
function enabled(req: express.Request, res: express.Response, next: express.NextFunction) {
if (!config.GITHUB_APP_ENABLED || !config.GITHUB_APP_NEW_CONNECTIONS) return res.status(503).json({ error: "github_app_disabled" });
@@ -87,6 +95,8 @@ router.get("/app/callback", enabled, async (req, res) => {
}
if (isDisabledAccount(user.status)) throw appError("not_connected", 403);
await saveAppGrant(user.id, tokens);
await saveRegistrationEmail(user, tokens.access_token);
if (flow.ownerId) await resetOwnerAccessAlerts(user.id);
await new Promise<void>((resolve, reject) => req.login({ username: user!.username, user }, err => err ? reject(err) : resolve()));
res.redirect(flow.install ? `/github/app/install?returnTo=${encodeURIComponent(flow.returnTo)}&repository=${encodeURIComponent(flow.repository || "")}` : flow.returnTo);
} catch (error) { handleError(error, res, req); }
@@ -141,6 +151,7 @@ router.get("/app/setup", enabled, async (req, res) => {
if (req.query.setup_action !== "request") {
const installations = await userInstallations(user.id);
if (!installations.some(i => String(i.id) === req.query.installation_id)) throw appError("github_app_access_required");
await resetOwnerAccessAlerts(user.id);
}
await UserModel.updateOne({ _id: user.id }, { $set: { repositories: [] } });
res.redirect(flow.returnTo);
@@ -150,7 +161,7 @@ router.get("/app/setup", enabled, async (req, res) => {
router.get("/connections", async (req, res) => {
try {
const user = await getUser(req);
req.session.githubConnectionCSRF ||= randomBytes(32).toString("hex");
connectionCSRF(req);
await saveSession(req);
const credentials = await CredentialModel.find({ ownerId: user.id }).select("provider revoked").lean();
const appConnected = credentials.some(c => c.provider === APP_PROVIDER && !c.revoked);
@@ -167,7 +178,8 @@ router.get("/connections", async (req, res) => {
const repos = await RepositoryModel.find({ owner: user.id, status: { $ne: "removed" } }).select("repoId source.repositoryName githubAccess status statusDate").lean();
const prs = await PullRequestModel.find({ owner: user.id, status: { $ne: "removed" } }).select("pullRequestId source.repositoryFullName githubAccess status statusDate").lean();
const gistCount = await GistModel.countDocuments({ owner: user.id, status: { $ne: "removed" } });
res.json({ csrf: req.session.githubConnectionCSRF, appEnabled: config.GITHUB_APP_ENABLED && config.GITHUB_APP_NEW_CONNECTIONS,
res.json({ notificationEmail: notificationEmail(user.model.emails, user.model.notificationEmail), emailNotificationsEnabled: !!(config.RESEND_API_KEY && config.EMAIL_FROM),
csrf: req.session.githubConnectionCSRF, appEnabled: config.GITHUB_APP_ENABLED && config.GITHUB_APP_NEW_CONNECTIONS,
gistConnection: config.GITHUB_APP_ENABLED && credentials.some(c => c.provider === APP_PROVIDER) ? "github-app" : "oauth",
oauthEnabled: config.GITHUB_OAUTH_ENABLED, oauthConnected: !!(await getCredentialToken(user.id)), appConnected, appError: appErrorCode,
installations, gistCount, resources: [
@@ -184,6 +196,33 @@ router.use("/connections", (req, res, next) => {
next();
});
router.get("/connections/email", async (req, res) => {
try {
const user = await getUser(req);
const csrf = connectionCSRF(req);
await saveSession(req);
res.json({ csrf, notificationEmail: notificationEmail(user.model.emails, user.model.notificationEmail) });
} catch (error) { handleError(error, res, req); }
});
router.post("/connections/email", async (req, res) => {
try {
const user = await getUser(req);
const action = req.body?.action || "save";
if (!["save", "later", "never"].includes(action)) return res.status(400).json({ error: "Invalid email preference." });
if (action === "later" || action === "never") {
if (action === "never") await UserModel.updateOne({ _id: user.id }, { $set: { emailPromptNever: true } });
req.session.emailPromptDismissed = true;
await saveSession(req);
return res.json({ ok: true });
}
const email = normalizeEmail(req.body?.email);
if (!email) return res.status(400).json({ error: "Enter a valid email address." });
await UserModel.updateOne({ _id: user.id }, { $set: { notificationEmail: email } });
res.json({ notificationEmail: email });
} catch (error) { handleError(error, res, req); }
});
router.post("/connections/migrate", async (req, res) => {
try {
const user = await getUser(req);
@@ -225,7 +264,8 @@ router.post("/connections/migrate", async (req, res) => {
} };
const result = isRepo ? await RepositoryModel.updateOne(filter, change) : await PullRequestModel.updateOne(filter, change);
if (!result.modifiedCount) throw appError("connection_changed", 409);
res.json({ connection });
await resetOwnerAccessAlerts(user.id, { kind: isRepo ? "repository" : "pull-request", id: String(model._id) });
res.json({ connection, fullName: selected.fullName || name });
} catch (error) { handleError(error, res, req); }
});
+28
View File
@@ -0,0 +1,28 @@
import { Options } from "express-rate-limit";
import { requestRateLimitKey } from "../rate-limit-key";
// Redis stores are supplied by the server. These policies also run in tests
// with the in-memory store, through real HTTP requests and the auth middleware.
export const tokenAttemptLimitOptions: Partial<Options> = {
windowMs: 60_000,
limit: 120,
skip: req => !req.headers.authorization,
keyGenerator: requestRateLimitKey,
standardHeaders: "draft-6",
legacyHeaders: false,
message: { error: "token_attempts_rate_limited" },
};
export const maintenanceRefreshLimitOptions: Partial<Options> = {
windowMs: 60_000,
limit: 1,
skip: (req, res) => !res.locals.maintenanceToken || req.method !== "POST" ||
!/^\/api\/repo\/[a-zA-Z0-9_-]+\/refresh\/?$/.test(req.path),
// Share the cooldown across this account's tokens and client IPs. Keep
// different accounts separate so a removed coauthor cannot block the owner.
keyGenerator: (req, res) =>
`${res.locals.maintenanceOwnerId}:${req.path.split("/")[3].toLowerCase()}`,
standardHeaders: "draft-6",
legacyHeaders: false,
message: { error: "refresh_rate_limited" },
};
+62
View File
@@ -0,0 +1,62 @@
import * as express from "express";
import { Types } from "mongoose";
import { getUser, handleError, isOwnerCoauthorOrAdmin } from "./route-utils";
import { generateToken, hashToken } from "./token-auth";
import UserModel from "../../core/model/users/users.model";
import { getRepository } from "../database";
import AnonymousError from "../../core/AnonymousError";
const router = express.Router();
router.use((req, res, next) => {
res.set("Cache-Control", "no-store");
// A restricted token must never mint credentials or become a session.
if (req.headers.authorization || !req.isAuthenticated()) {
return res.status(401).json({ error: "session_required" });
}
next();
});
router.get("/", async (req, res) => {
try {
const user = await getUser(req);
res.json((user.model.apiTokens || []).filter(t => t.scope === "repository-maintenance")
.map(t => ({ id: t._id, name: t.name, repoIds: t.repoIds, expiresAt: t.expiresAt,
createdAt: t.createdAt, lastUsedAt: t.lastUsedAt })));
} catch (error) { handleError(error, res, req); }
});
router.post("/", async (req, res) => {
try {
const user = await getUser(req);
const { name, repoIds, expiresInDays = 30 } = req.body;
if (typeof name !== "string" || !name.trim() || name.length > 80 ||
!Array.isArray(repoIds) || !repoIds.length || repoIds.length > 20 ||
!repoIds.every(id => typeof id === "string" && /^[a-zA-Z0-9_-]{3,200}$/.test(id)) ||
!Number.isInteger(expiresInDays) || expiresInDays < 1 || expiresInDays > 90) {
throw new AnonymousError("invalid_token_options", { httpStatus: 400 });
}
for (const id of new Set<string>(repoIds)) {
isOwnerCoauthorOrAdmin(await getRepository(id), user);
}
const token = generateToken();
const entry = { _id: new Types.ObjectId(), name: name.trim(), scope: "repository-maintenance",
repoIds: [...new Set(repoIds)], tokenHash: hashToken(token), createdAt: new Date(),
expiresAt: new Date(Date.now() + expiresInDays * 86400_000) };
const saved = await UserModel.updateOne({ _id: user.model._id,
"apiTokens.50": { $exists: false } }, { $push: { apiTokens: entry } });
if (!saved.matchedCount) throw new AnonymousError("token_limit_reached", { httpStatus: 409 });
res.status(201).json({ id: entry._id, name: entry.name, repoIds: entry.repoIds,
expiresAt: entry.expiresAt, token });
} catch (error) { handleError(error, res, req); }
});
router.delete("/:id", async (req, res) => {
try {
const user = await getUser(req);
if (!Types.ObjectId.isValid(req.params.id)) throw new AnonymousError("invalid_token_id", { httpStatus: 400 });
const result = await UserModel.updateOne({ _id: user.model._id },
{ $pull: { apiTokens: { _id: req.params.id, scope: "repository-maintenance" } } });
res.json({ removed: result.modifiedCount });
} catch (error) { handleError(error, res, req); }
});
export default router;
+59
View File
@@ -0,0 +1,59 @@
import * as express from "express";
import { getRepo, getUser, handleError, isOwnerCoauthorOrAdmin } from "./route-utils";
import AnonymousError from "../../core/AnonymousError";
import RepoModel from "../../core/model/anonymizedRepositories/anonymizedRepositories.model";
const router = express.Router();
const toggles = ["update", "image", "pdf", "notebook", "link"];
/** Only in-place anonymization settings; never accept source or connection input. */
export function maintenanceSettings(body: unknown): Record<string, unknown> {
const invalid = () => new AnonymousError("invalid_settings", { httpStatus: 400 });
if (!body || typeof body !== "object" || Array.isArray(body)) throw invalid();
const entries = Object.entries(body);
if (!entries.length) throw invalid();
const updates: Record<string, unknown> = {};
for (const [key, value] of entries) {
if (key === "terms") {
if (!Array.isArray(value) || value.length > 1000 ||
!value.every(t => typeof t === "string" && t.length > 0 && t.length <= 1000)) throw invalid();
} else if (!toggles.includes(key) || typeof value !== "boolean") throw invalid();
updates[`options.${key}`] = value;
}
return updates;
}
router.route("/repositories/:repoId")
.all((_req, res, next) => { res.set("Cache-Control", "no-store"); next(); })
.get(async (req, res) => {
try {
const user = await getUser(req);
const repo = await getRepo(req, res, { nocheck: true });
if (!repo) return;
isOwnerCoauthorOrAdmin(repo, user);
// Read saved metadata without refreshing GitHub credentials or content.
res.json({ repoId: repo.repoId, status: repo.status, statusMessage: repo.model.statusMessage,
statusDate: repo.model.statusDate, settingsSavedAt: repo.model.settingsSavedAt,
source: { branch: repo.model.source.branch, commit: repo.model.source.commit },
options: repo.model.options });
} catch (error) { handleError(error, res, req); }
})
.patch(async (req, res) => {
try {
const user = await getUser(req);
const repo = await getRepo(req, res, { nocheck: true });
if (!repo) return;
isOwnerCoauthorOrAdmin(repo, user);
if (!["ready", "error"].includes(repo.status || "")) throw new AnonymousError("invalid_status", { httpStatus: 409 });
repo.assertNotArchived();
const updates = maintenanceSettings(req.body);
const settingsSavedAt = new Date();
const saved = await RepoModel.updateOne({ _id: repo.model._id, status: repo.model.status,
statusDate: repo.model.statusDate,
settingsSavedAt: repo.model.settingsSavedAt || { $exists: false } },
{ $set: { ...updates, settingsSavedAt } }).exec();
if (!saved.matchedCount) throw new AnonymousError("invalid_status", { httpStatus: 409 });
res.json({ repoId: repo.repoId, status: repo.status, settingsSavedAt });
} catch (error) { handleError(error, res, req); }
});
export default router;
+4
View File
@@ -1,3 +1,4 @@
import { resetOwnerAccessAlerts } from "../../core/owner-notifications";
import { selectRepositoryAccess } from "../../core/github-app";
import * as express from "express";
import { ensureAuthenticated } from "./connection";
@@ -30,6 +31,7 @@ router.post(
const user = await getUser(req);
isOwnerOrAdmin([pullRequest.owner.id], user);
if (user.id === pullRequest.owner.id) await resetOwnerAccessAlerts(user.id, { kind: "pull-request", id: String(pullRequest.model._id) });
await pullRequest.updateIfNeeded({ force: true });
res.json({ status: pullRequest.status });
} catch (error) {
@@ -71,6 +73,7 @@ router.post(
{ $set: { "options.expirationDate": newExpiration } }
).exec();
if (user.id === pullRequest.owner.id) await resetOwnerAccessAlerts(user.id, { kind: "pull-request", id: String(pullRequest.model._id) });
await pullRequest.updateIfNeeded({ force: true });
res.json({ status: pullRequest.status, expirationDate: newExpiration });
} catch (error) {
@@ -242,6 +245,7 @@ router.post(
}
).exec();
await pullRequest.updateStatus(RepositoryStatus.PREPARING);
if (user.id === pullRequest.owner.id) await resetOwnerAccessAlerts(user.id, { kind: "pull-request", id: String(pullRequest.model._id) });
await pullRequest.updateIfNeeded({ force: true });
res.json({ ...pullRequest.toJSON(), connection: pullRequest.model.githubAccess?.kind || "oauth" });
} catch (error) {
+41 -15
View File
@@ -1,6 +1,7 @@
import { neverExpirationDeadline, validateBillingExpiration } from "../../core/billing/expiration";
import config from "../../config";
import { assertConferenceAdmission, reserveRepository } from "../../core/billing/quotas";
import { resetOwnerAccessAlerts } from "../../core/owner-notifications";
import { randomUUID } from "crypto";
import { githubQuotaKey } from "../../core/github-token-context";
import { selectRepositoryAccess, boundAppToken, appError } from "../../core/github-app";
@@ -35,6 +36,24 @@ const router = express.Router();
// user needs to be connected for all user API
router.use(ensureAuthenticated);
async function enqueueSavedSnapshot(model: IAnonymizedRepositoryDocument) {
try {
await downloadQueue.add(model.repoId, { repoId: model.repoId }, {
jobId: `repo-${model.repoId}`, attempts: 3,
});
} catch (error) {
// Leave a failed enqueue retryable without overwriting a worker or a
// lifecycle change that happened after this save.
await AnonymizedRepositoryModel.updateOne({
_id: model._id, status: RepositoryStatus.PREPARING,
statusDate: model.statusDate, anonymizeDate: model.anonymizeDate,
"githubAccess.revision": model.githubAccess?.revision || { $exists: false },
}, { $set: { status: RepositoryStatus.ERROR, statusDate: new Date(),
statusMessage: "preparation_interrupted" } }).exec();
throw error;
}
}
async function previewToken(req: express.Request) {
const user = await getUser(req);
if (typeof req.query.anonymizedRepoId === "string") {
@@ -117,7 +136,8 @@ router.post("/claim", async (req, res) => {
await AnonymizedRepositoryModel.updateOne(
{ repoId: repoConfig.repoId },
{ $set: { owner: user.model.id, githubAccess: selectedAccess.binding } }
{ $set: { owner: user.model.id, githubAccess: selectedAccess.binding, accessAlertGeneration: randomUUID() },
$unset: { accessAlertClaimedAt: "" } }
).collation({ locale: "en", strength: 2 });
return res.send("Ok");
} catch (error) {
@@ -149,6 +169,7 @@ router.post(
throw new AnonymousError("invalid_status", { httpStatus: 409 });
}
if (user.id === repo.owner.id) await resetOwnerAccessAlerts(user.id, { kind: "repository", id: String(repo.model._id) });
await repo.refresh();
res.json({ status: repo.status });
} catch (error) {
@@ -185,33 +206,35 @@ router.post(
repo.model.options.expirationDate
);
const reactivating = repo.status === RepositoryStatus.EXPIRED;
const updates: Record<string, Date> = {
const updates: Record<string, Date | string | null> = {
"options.expirationDate": newExpiration,
settingsSavedAt: new Date(),
};
repo.model.options.expirationDate = newExpiration;
repo.model.settingsSavedAt = updates.settingsSavedAt;
repo.model.settingsSavedAt = updates.settingsSavedAt as Date;
if (reactivating) {
repo.model.anonymizeDate = new Date();
updates.anonymizeDate = repo.model.anonymizeDate;
updates.status = RepositoryStatus.PREPARING;
updates.statusDate = new Date();
updates.statusMessage = null;
}
const saved = await AnonymizedRepositoryModel.updateOne(
{ _id: repo.model._id, status: repo.status, statusDate: repo.model.statusDate },
{ $set: updates }
).exec();
if (saved && saved.matchedCount === 0) throw new AnonymousError("invalid_status", { httpStatus: 409 });
if (user.id === repo.owner.id) await resetOwnerAccessAlerts(user.id, { kind: "repository", id: String(repo.model._id) });
if (reactivating) {
// Expiration removes the cached files. Rebuild the saved commit
// directly instead of asking GitHub for the latest branch head first;
// that lookup can fail after the new date has already been persisted
// and leave the repository stuck in the expired state.
await repo.updateStatus(RepositoryStatus.PREPARING);
await downloadQueue.add(
repo.repoId,
{ repoId: repo.repoId },
{ jobId: `repo-${repo.repoId}`, attempts: 3 }
);
repo.model.status = RepositoryStatus.PREPARING;
repo.model.statusDate = updates.statusDate as Date;
repo.model.statusMessage = undefined;
await enqueueSavedSnapshot(repo.model);
}
res.json({ status: repo.status, expirationDate: newExpiration });
} catch (error) {
@@ -620,6 +643,9 @@ router.post(
}
}
repo.model.conference = repoUpdate.conference;
const preparation = sourceChanged || reactivating ? {
status: RepositoryStatus.PREPARING, statusDate: new Date(), statusMessage: null,
} : undefined;
const saved = await AnonymizedRepositoryModel.updateOne(
{ _id: repo.model._id, status: repo.status, statusDate: repo.model.statusDate,
"githubAccess.revision": previousAccessRevision || { $exists: false } },
@@ -632,22 +658,22 @@ router.post(
githubAccess: repo.model.githubAccess,
conference: repo.model.conference,
anonymizeDate: repo.model.anonymizeDate,
...preparation,
},
...(!repo.model.billingNeverExpiresUntil ? { $unset: { billingNeverExpiresUntil: 1 } } : {}),
}
).exec();
if (!saved.matchedCount) throw appError("connection_changed", 409);
if (user.id === repo.owner.id) await resetOwnerAccessAlerts(user.id, { kind: "repository", id: String(repo.model._id) });
if (!sourceChanged && !reactivating) {
return res.json({ status: repo.status });
}
await repo.updateStatus(RepositoryStatus.PREPARING);
repo.model.status = preparation!.status;
repo.model.statusDate = preparation!.statusDate;
repo.model.statusMessage = undefined;
await enqueueSavedSnapshot(repo.model);
res.json({ status: repo.status });
await downloadQueue.add(
repo.repoId,
{ repoId: repo.repoId },
{ jobId: `repo-${repo.repoId}` }
);
} catch (error) {
return handleError(error, res, req);
}
+15 -6
View File
@@ -1,3 +1,4 @@
import { repositoryFailureStatus } from "../../core/repository-failure-status";
import { anonymizePath } from "../../core/anonymize-utils";
import * as express from "express";
import config from "../../config";
@@ -57,6 +58,7 @@ router.get(
res.attachment(`${repo.repoId}.zip`);
if (req.method === "HEAD") { res.end(); return; }
await repo.countView();
const submodules = await repo.submoduleMounts();
if (config.STREAMER_ENTRYPOINT) {
// use the streamer service
@@ -75,6 +77,7 @@ router.get(
branch: repo.model.source.branch,
repoId: repo.repoId,
anonymizerOptions: anonymizer.opt,
submodules,
contentOptions: {
image: repo.options.image,
pdf: repo.options.pdf,
@@ -169,6 +172,7 @@ router.get(
commit: repo.model.source.commit || "HEAD",
getToken: () => repo.getToken(),
anonymizerOptions: anonymizer.opt,
submodules,
contentOptions: {
image: repo.options.image,
pdf: repo.options.pdf,
@@ -335,7 +339,7 @@ router.get(
: "repository_not_accessible",
{
object: repo,
httpStatus: 500,
httpStatus: repositoryFailureStatus(repo.model.statusMessage),
}
);
}
@@ -372,21 +376,26 @@ router.get(
sourceCommitDate: repo.model.source.commitDate,
settingsSavedAt: repo.model.settingsSavedAt,
publishedAt: repo.model.publishedAt,
// Lets reviewers see how long the link stays available.
expirationDate:
repo.options.expirationMode !== "never"
? repo.options.expirationDate || null
: null,
isAdmin: user?.isAdmin === true,
isOwner: user?.id == repo.model.owner,
hasWebsite: !!repo.options.page && !!repo.options.pageSource,
truncatedFolders: (repo.model.truncatedFolders || []).map((path) =>
anonymizePath(path, repo.options.terms || [])
),
// 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).
// Legacy trees retain .gitmodules until their next tree refresh.
hasSubmodules:
(await FileModel.exists({
repoId: repo.repoId,
treeGeneration: repo.model.treeGeneration || { $exists: false },
name: ".gitmodules",
path: "",
$or: [
{ submodule: { $exists: true, $ne: "included" } },
{ name: ".gitmodules", path: "" },
],
})) != null,
});
} catch (error) {
+4 -4
View File
@@ -233,10 +233,10 @@ export function handleError(
errorCode = String(error);
}
let status = 500;
if (error.httpStatus) {
status = error.httpStatus;
} else if (error.$metadata?.httpStatusCode) {
status = error.$metadata.httpStatusCode;
const explicitStatus = [error?.httpStatus, error?.status, error?.response?.statusCode,
error?.$metadata?.httpStatusCode].find(value => Number.isInteger(value) && value >= 400 && value <= 599);
if (explicitStatus !== undefined) {
status = explicitStatus;
} else if (
errorCode &&
(errorCode.indexOf("not_found") > -1 ||
+30 -6
View File
@@ -16,21 +16,35 @@ export function generateToken(): string {
export async function bearerTokenAuth(
req: express.Request,
_res: express.Response,
res: express.Response,
next: express.NextFunction
): Promise<void> {
if (req.user) return next();
const header = req.headers["authorization"];
if (!header || typeof header !== "string") return next();
const match = header.match(/^Bearer\s+(.+)$/i);
if (!match) return next();
if (!match || !match[1].trim() || match[1].length > 512) {
res.status(401).json({ error: "invalid_token" });
return;
}
const tokenHash = hashToken(match[1].trim());
try {
const model = await UserModel.findOne({ "apiTokens.tokenHash": tokenHash });
if (!model) return next();
if (isDisabledAccount(model.status)) return next();
const token = model?.apiTokens?.find(t => t.tokenHash === tokenHash);
if (!model || !token || isDisabledAccount(model.status) ||
(token.expiresAt && token.expiresAt.getTime() <= Date.now()) ||
(token.scope && (!token.expiresAt || token.scope !== "repository-maintenance"))) {
res.status(401).json({ error: "invalid_token" });
return;
}
if (token.scope) {
if (!maintenanceTokenAllows(req.method, req.path, token.repoIds || [])) {
res.status(403).json({ error: "token_scope_forbidden" });
return;
}
res.locals.maintenanceToken = true;
res.locals.maintenanceOwnerId = String(model._id);
}
// Mirror the shape produced by passport's verify() in connection.ts
// so existing getUser()/route code works unchanged.
@@ -48,6 +62,16 @@ export async function bearerTokenAuth(
);
} catch (err) {
logger.error("lookup failed", serializeError(err));
res.status(503).json({ error: "authentication_unavailable" });
return;
}
return next();
}
/** Exact paths keep scoped tokens out of GitHub proxy and session routes. */
export function maintenanceTokenAllows(method: string, path: string, repoIds: string[]): boolean {
const readOrEdit = path.match(/^\/api\/maintenance\/repositories\/([a-zA-Z0-9_-]+)\/?$/);
if (readOrEdit && ["GET", "PATCH"].includes(method)) return repoIds.includes(readOrEdit[1]);
const action = path.match(/^\/api\/repo\/([a-zA-Z0-9_-]+)\/(refresh|extend)\/?$/);
return !!action && method === "POST" && repoIds.includes(action[1]);
}
+16 -1
View File
@@ -1,9 +1,10 @@
import { BillingQuota, BillingDraft } from "../../core/billing/models";
import { cancelAccountBilling } from "../../core/billing/stripe";
import { allowance } from "../../core/billing/quotas";
import { notificationEmail } from "../../core/owner-notifications";
import DashboardName from "../../core/model/dashboard-name";
import { dashboardSummary } from "./dashboard-summary";
import { revokeGrant } from "./github-app";
import { connectionCSRF, revokeGrant } from "./github-app";
import CredentialModel from "../../core/model/credentials/credentials.model";
import * as express from "express";
import config from "../../config";
@@ -43,7 +44,18 @@ router.get("/logout", async (req, res) => {
router.get("/", async (req, res) => {
try {
const user = await getUser(req);
const showEmailPrompt = !!(config.RESEND_API_KEY && config.EMAIL_FROM) &&
!notificationEmail(user.model.emails, user.model.notificationEmail) && !user.model.emailPromptNever && !req.session.emailPromptDismissed;
if (showEmailPrompt) {
connectionCSRF(req);
await new Promise<void>((resolve, reject) => req.session.save(error => error ? reject(error) : resolve()));
}
res.set("Cache-Control", "no-store");
res.json({
showEmailPrompt,
emailPromptCSRF: showEmailPrompt ? req.session.githubConnectionCSRF : undefined,
notificationEmail: notificationEmail(user.model.emails, user.model.notificationEmail),
emailNotificationsEnabled: !!(config.RESEND_API_KEY && config.EMAIL_FROM),
username: user.username,
photo: user.photo,
isAdmin: user.isAdmin,
@@ -234,6 +246,9 @@ router.delete("/", async (req, res) => {
repositories: [],
},
$unset: {
notificationEmail: "",
emailPromptNever: "",
accessAlertAfter: "",
accessTokens: "",
accessTokenDates: "",
externalIDs: "",
+5 -33
View File
@@ -3,8 +3,7 @@ import { getRepo, handleError } from "./route-utils";
import * as path from "path";
import AnonymizedFile from "../../core/AnonymizedFile";
import AnonymousError from "../../core/AnonymousError";
import * as marked from "marked";
import * as sanitizeHtml from "sanitize-html";
import { renderMarkdown } from "../../markdown";
import { streamToString, isTextFile } from "../../core/anonymize-utils";
import { IFile } from "../../core/model/files/files.types";
import { fileETag } from "./file-etag";
@@ -20,33 +19,6 @@ function escapeHtml(str: string): string {
.replace(/'/g, "&#039;");
}
const sanitizeOptions: sanitizeHtml.IOptions = {
allowedTags: sanitizeHtml.defaults.allowedTags.concat([
"img",
"video",
"input",
"details",
"summary",
"del",
"ins",
"sup",
"sub",
]),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ["src", "srcset", "alt", "title", "width", "height", "loading"],
video: ["src", "controls", "title"],
input: ["type", "checked", "disabled"],
code: ["class"],
span: ["class"],
div: ["class"],
pre: ["class"],
td: ["align"],
th: ["align"],
},
allowedSchemes: ["http", "https", "mailto"],
};
const router = express.Router();
const indexPriority = [
@@ -161,7 +133,7 @@ async function webView(
}">${escapeHtml(c.name) + (c.size == null ? "/" : "")}</a>`
)
.join("")}</div></div>`;
const html = `<!DOCTYPE html><html><head><title>Content</title></head><link rel="stylesheet" href="/css/all.min.css" /><body>${body}</body></html>`;
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Content</title></head><link rel="stylesheet" href="/css/all.min.css" /><body>${body}</body></html>`;
return res.contentType("text/html").send(html);
}
}
@@ -174,7 +146,7 @@ async function webView(
});
}
res.set("Cache-Control", "private, no-cache, must-revalidate");
const pageETag = fileETag(JSON.stringify(["web-v1", repo.model.source.commit, repo.model.treeGeneration, repo.model.contentCacheRevision,
const pageETag = fileETag(JSON.stringify(["web-v2", repo.model.source.commit, repo.model.treeGeneration, repo.model.contentCacheRevision,
await f.sha(), repo.model.source.repositoryName, repo.model.source.branch, config.APP_HOSTNAME, config.ANONYMIZATION_MASK]),
requestPath, repo.options);
res.set("ETag", pageETag);
@@ -189,8 +161,8 @@ async function webView(
if (f.extension() == "md") {
try {
const content = await streamToString(await f.anonymizedContent());
const body = sanitizeHtml(marked.marked.parse(content, { async: false }), sanitizeOptions);
const html = `<!DOCTYPE html><html><head><title>Content</title></head><link rel="stylesheet" href="/css/all.min.css" /><body><div class="container p-3 file-content markdown-body">${body}</div></body></html>`;
const body = renderMarkdown(content);
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Content</title></head><link rel="stylesheet" href="/css/all.min.css" /><body><div class="container p-3 file-content markdown-body">${body}</div><script defer src="/script/markdown.min.js"></script></body></html>`;
res.contentType("text/html").send(html);
} catch {
// Raw Markdown must re-enter rendering when a transient failure clears.
+9
View File
@@ -0,0 +1,9 @@
import { RequestHandler } from "express";
/** The asterisk request target asks about the server, not a session or route. */
export const serverOptions: RequestHandler = (req, res, next) => {
if (req.originalUrl !== "*") return next();
if (req.method !== "OPTIONS") { res.status(400).json({ error: "invalid_request" }); return; }
res.setHeader("Allow", "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS");
res.status(204).end();
};
+28
View File
@@ -0,0 +1,28 @@
import { decodeReviewJSON } from "./review-json";
const invalid = (): never => { throw new Error("Invalid review callback registry"); };
export function createReviewCallbacks(raw: string) {
let value: unknown;
try { value = decodeReviewJSON(Buffer.from(raw, "utf8")); } catch { return invalid(); }
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join(",") !== "callbacks,version") return invalid();
const config = value as { version: unknown; callbacks: unknown };
if (config.version !== 1 || !Array.isArray(config.callbacks) || !config.callbacks.length || config.callbacks.length > 32) return invalid();
const callbacks = new Map<string, string>(), destinations = new Set<string>();
for (const item of config.callbacks) {
if (!item || typeof item !== "object" || Array.isArray(item) || Object.keys(item).sort().join(",") !== "callbackId,clientId,url") return invalid();
const { clientId, callbackId, url } = item;
if (![clientId, callbackId].every(id => typeof id === "string" && /^[a-f0-9]{32}$/.test(id)) || typeof url !== "string" || url.length > 2048) return invalid();
let parsed: URL;
try { parsed = new URL(url); } catch { return invalid(); }
if (parsed.protocol !== "https:" || parsed.href !== url || parsed.username || parsed.password || parsed.search || parsed.hash || url.includes("?") || url.includes("#") || !/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(parsed.pathname)) return invalid();
const key = clientId + ":" + callbackId;
if (callbacks.has(key)) return invalid();
callbacks.set(key, url); destinations.add(url);
}
const formAction = "'self' " + [...destinations].join(" ");
if (Buffer.byteLength(formAction) > 4096) return invalid();
return Object.freeze({
formAction,
resolve(clientId: string, callbackId: string): string | undefined { return callbacks.get(clientId + ":" + callbackId); },
});
}
export type ReviewCallbacks = ReturnType<typeof createReviewCallbacks>;
+200
View File
@@ -0,0 +1,200 @@
import { createHash, randomBytes, timingSafeEqual } from "crypto";
import { RequestHandler } from "express";
const contract = "4open.artifacts/1";
const clientPattern = /^[a-f0-9]{32}$/;
const keyPattern = /^[A-Za-z0-9_-]{1,64}$/;
const secretPattern = /^[a-f0-9]{64}$/;
type Key = { clientId: string; digest: Buffer; from: number; until: number };
function invalid(): never {
// Never include private configuration or request values in diagnostic errors.
throw new Error("Invalid REVIEW_SERVICE_KEYS configuration");
}
function record(value: unknown, fields: string[]): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) invalid();
const data = value as Record<string, unknown>;
if (
Object.keys(data).length !== fields.length ||
fields.some((k) => !Object.prototype.hasOwnProperty.call(data, k))
)
invalid();
return data;
}
function timestamp(value: unknown): number {
if (
typeof value !== "string" ||
!/^20[0-9]{2}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/.test(value)
)
invalid();
const time = Date.parse(value);
if (
!Number.isFinite(time) ||
new Date(time).toISOString() !== value.replace("Z", ".000Z")
)
invalid();
return time;
}
function registry(raw: string): Map<string, Key> {
if (Buffer.byteLength(raw, "utf8") > 65536) invalid();
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
invalid();
}
const config = record(parsed, ["version", "keys"]);
if (
config.version !== 1 ||
!Array.isArray(config.keys) ||
config.keys.length < 1 ||
config.keys.length > 64
)
invalid();
const keys = new Map<string, Key>(),
digests = new Set<string>(),
clients = new Map<string, number>();
for (const entry of config.keys) {
const k = record(entry, [
"clientId",
"keyId",
"tokenSHA256",
"notBefore",
"notAfter",
]);
if (
typeof k.clientId !== "string" ||
!clientPattern.test(k.clientId) ||
typeof k.keyId !== "string" ||
!keyPattern.test(k.keyId) ||
typeof k.tokenSHA256 !== "string" ||
!secretPattern.test(k.tokenSHA256)
)
invalid();
const from = timestamp(k.notBefore),
until = timestamp(k.notAfter);
const id = k.clientId + ":" + k.keyId;
const count = (clients.get(k.clientId) || 0) + 1;
if (
until <= from ||
keys.has(id) ||
digests.has(k.tokenSHA256) ||
count > 16
)
invalid();
clients.set(k.clientId, count);
if (clients.size > 32) invalid();
digests.add(k.tokenSHA256);
keys.set(id, {
clientId: k.clientId,
digest: Buffer.from(k.tokenSHA256, "hex"),
from,
until,
});
}
return keys;
}
/** Optional server-to-server read-only endpoint. No browser, owner or repository authority is inferred. */
export function createReviewCapabilities(
raw?: string,
now: () => number = Date.now,
): RequestHandler {
const keys = raw === undefined ? null : registry(raw);
// One bounded counter per registered client, shared by its rotation keys.
const rates = new Map<string, { minute: number; count: number }>();
const dummyDigest = Buffer.alloc(32);
return (req, res) => {
res.setHeader("Cache-Control", "no-store");
res.setHeader("Referrer-Policy", "no-referrer");
res.setHeader("X-Content-Type-Options", "nosniff");
const fail = (status: number, code: string, retry?: number) => {
// Do not keep a rejected request alive while Node drains an unread body.
res.setHeader("Connection", "close");
if (retry) res.setHeader("Retry-After", String(retry));
res.status(status).json({
contract,
code,
requestId: randomBytes(16).toString("hex"),
...(retry ? { retryAfterSeconds: retry } : {}),
});
};
if (!keys || req.originalUrl !== "/service/v1/capabilities")
return fail(404, "not-found");
if (req.method !== "GET") return fail(405, "invalid-request");
// Reject browser context and bodies before authentication; never read or log them.
if (
[
"cookie",
"origin",
"referer",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
"sec-fetch-user",
].some((h) => req.headers[h] !== undefined)
)
return fail(403, "forbidden");
if (
req.headers["transfer-encoding"] !== undefined ||
req.headers["content-type"] !== undefined ||
(req.headers["content-length"] !== undefined &&
req.headers["content-length"] !== "0")
)
return fail(400, "invalid-request");
const one = (name: string): string | undefined => {
let count = 0;
for (let i = 0; i < req.rawHeaders.length; i += 2)
if (req.rawHeaders[i].toLowerCase() === name) count++;
const value = req.headers[name];
return count === 1 && typeof value === "string" ? value : undefined;
};
const client = one("x-4open-artifact-client-id"),
keyId = one("x-4open-artifact-service-key-id"),
authorization = one("authorization");
if (
!client ||
!clientPattern.test(client) ||
!keyId ||
!keyPattern.test(keyId) ||
!authorization ||
!/^Bearer [a-f0-9]{64}$/i.test(authorization) ||
!secretPattern.test(authorization.slice(7))
)
return fail(401, "unauthorized");
const key = keys.get(client + ":" + keyId);
const digest = createHash("sha256")
.update(authorization.slice(7), "ascii")
.digest();
const equal = timingSafeEqual(digest, key ? key.digest : dummyDigest);
const time = now();
if (
!key ||
!equal ||
!Number.isFinite(time) ||
time < key.from ||
time >= key.until
)
return fail(401, "unauthorized");
const minute = Math.floor(time / 60000);
let rate = rates.get(client);
if (!rate || rate.minute !== minute) {
rate = { minute, count: 0 };
rates.set(client, rate);
}
if (rate.count >= 60)
return fail(
429,
"rate-limited",
Math.max(1, Math.ceil((60000 - (time % 60000)) / 1000)),
);
rate.count++;
// No link, snapshot, protected-delivery or retention support exists yet.
res.status(200).json({
contract,
clientId: client,
contracts: [contract],
available: [],
});
};
}
@@ -0,0 +1,262 @@
import { randomBytes } from "crypto";
import { RequestHandler } from "express";
import {
createReviewOwnerConsent,
ReviewConsentError,
} from "./review-owner-consent";
import {
createReviewServiceAuth,
singleReviewHeader,
} from "./review-service-auth";
import { decodeReviewJSON } from "./review-json";
const contract = "4open.artifacts/1";
const opaque = /^[a-f0-9]{32}$/;
function exact(
value: unknown,
keys: string[],
): value is Record<string, unknown> {
return (
!!value &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.keys(value).sort().join(",") === keys.sort().join(",")
);
}
function receipt(value: unknown, clientId: string, intentId: string): unknown {
if (
!exact(value, [
"contract",
"clientId",
"intentId",
"bindingId",
"submissionRef",
"policy",
"entitlementId",
]) ||
value.contract !== contract ||
value.clientId !== clientId ||
value.intentId !== intentId ||
![value.bindingId, value.submissionRef, value.entitlementId].every(
(id) => typeof id === "string" && opaque.test(id),
) ||
!exact(value.policy, ["version", "access", "retainUntil"])
)
throw new Error("Invalid receipt");
const policy = value.policy;
if (
!Number.isSafeInteger(policy.version) ||
(policy.version as number) < 1 ||
!["anonymous-link", "restricted-review"].includes(
policy.access as string,
) ||
typeof policy.retainUntil !== "string" ||
!/^2[01][0-9]{2}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/.test(
policy.retainUntil,
) ||
!Number.isFinite(Date.parse(policy.retainUntil)) ||
new Date(policy.retainUntil).toISOString() !==
policy.retainUntil.replace("Z", ".000Z")
)
throw new Error("Invalid receipt");
return value;
}
/** Mount before global JSON parsing and browser authentication. Disabled without
* its own scoped registry. This factory is intentionally not mounted at startup. */
export function createReviewCompletionService(
raw: string | undefined,
backend: Pick<
ReturnType<typeof createReviewOwnerConsent>,
"exchange" | "receipt"
>,
now: () => number = Date.now,
): RequestHandler {
const auth =
raw === undefined ? undefined : createReviewServiceAuth(raw, now);
const rates = new Map<string, { minute: number; count: number }>();
let active = 0;
return async (req, res) => {
res.set({
"Cache-Control": "no-store",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
});
res.removeHeader("Access-Control-Allow-Origin");
const fail = (status: number, code: string) => {
if (res.headersSent || res.destroyed) return;
res.setHeader("Connection", "close");
res
.status(status)
.json({ contract, code, requestId: randomBytes(16).toString("hex") });
};
const exchange = req.originalUrl === "/service/v1/completions/exchange";
const read = /^\/service\/v1\/intents\/([a-f0-9]{32})\/receipt$/.exec(
req.originalUrl,
);
if (!auth || (!exchange && !read)) return fail(404, "not-found");
if (req.method !== (exchange ? "POST" : "GET"))
return fail(405, "invalid-request");
if (
!req.secure ||
[
"cookie",
"origin",
"referer",
"sec-fetch-site",
"sec-fetch-mode",
"sec-fetch-dest",
"sec-fetch-user",
].some((name) => req.headers[name] !== undefined)
)
return fail(403, "forbidden");
if (req.body !== undefined) return fail(503, "unavailable");
if (req.headers["content-encoding"] !== undefined)
return fail(400, "invalid-request");
for (const name of ["content-type", "content-length", "transfer-encoding"])
if (
req.headers[name] !== undefined &&
singleReviewHeader(req, name) === undefined
)
return fail(400, "invalid-request");
if (exchange) {
if (
!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(
singleReviewHeader(req, "content-type") || "",
)
)
return fail(400, "invalid-request");
const length = req.headers["content-length"];
if (
length !== undefined &&
(!/^\d+$/.test(String(length)) || Number(length) > 65536)
)
return fail(400, "invalid-request");
} else if (
req.headers["transfer-encoding"] !== undefined ||
req.headers["content-type"] !== undefined ||
(req.headers["content-length"] !== undefined &&
req.headers["content-length"] !== "0")
)
return fail(400, "invalid-request");
const principal = auth.authenticate(
req,
exchange ? "completion.exchange" : "receipt.read",
);
if (!principal) return fail(401, "unauthorized");
const minute = Math.floor(now() / 60000);
let rate = rates.get(principal.clientId);
if (!rate || rate.minute !== minute) {
rate = { minute, count: 0 };
rates.set(principal.clientId, rate);
}
if (rate.count++ >= 60) return fail(429, "rate-limited");
if (active >= 4) return fail(503, "unavailable");
active++;
let cancelBody: (() => void) | undefined;
let ended = false;
const close = () => {
ended = true;
cancelBody?.();
};
res.once("close", close);
const timer = setTimeout(() => {
ended = true;
fail(503, "unavailable");
cancelBody?.();
}, 15000);
try {
let intentId = read?.[1] || "";
let result: unknown;
if (exchange) {
const bytes = await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
let size = 0;
const cleanup = () => {
req.off("data", data);
req.off("end", end);
req.off("aborted", abort);
req.off("error", abort);
cancelBody = undefined;
};
const abort = () => {
cleanup();
reject(new Error("Request unavailable"));
};
const data = (chunk: Buffer) => {
size += chunk.length;
if (size > 65536) {
fail(400, "invalid-request");
abort();
} else chunks.push(chunk);
};
const end = () => {
cleanup();
resolve(Buffer.concat(chunks));
};
cancelBody = abort;
req.on("data", data);
req.once("end", end);
req.once("aborted", abort);
req.once("error", abort);
});
if (ended) return;
let command: unknown;
try {
command = decodeReviewJSON(bytes);
} catch {
return fail(400, "invalid-request");
}
if (
!exact(command, [
"contract",
"clientId",
"intentId",
"code",
"requestId",
]) ||
typeof command.contract !== "string" ||
![command.clientId, command.intentId, command.requestId].every(
(id) => typeof id === "string" && opaque.test(id),
) ||
typeof command.code !== "string" ||
!/^[a-f0-9]{64}$/.test(command.code)
)
return fail(400, "invalid-request");
if (command.contract !== contract)
return fail(422, "unsupported-version");
if (command.clientId !== principal.clientId)
return fail(403, "forbidden");
if (!principal.valid()) return fail(401, "unauthorized");
intentId = command.intentId as string;
result = await backend.exchange(
principal.clientId,
command as Parameters<typeof backend.exchange>[1],
);
} else {
if (!principal.valid()) return fail(401, "unauthorized");
result = await backend.receipt(principal.clientId, intentId);
}
if (ended) return;
if (!principal.valid()) return fail(401, "unauthorized");
res.status(200).json(receipt(result, principal.clientId, intentId));
} catch (error) {
if (ended) return;
if (error instanceof ReviewConsentError) {
const codes = {
invalid: [400, "invalid-request"],
forbidden: [403, "forbidden"],
expired: [410, "expired"],
conflict: [409, "conflict"],
unavailable: [503, "unavailable"],
} as const;
const [status, code] = codes[error.kind];
fail(status, code);
} else fail(503, "unavailable");
} finally {
clearTimeout(timer);
res.off("close", close);
active--;
}
};
}
+336
View File
@@ -0,0 +1,336 @@
import { ReviewCallbacks } from "./review-callbacks";
import { createHmac, timingSafeEqual } from "crypto";
import * as express from "express";
import { Request, Response } from "express";
import { createReviewOwnerConsent } from "./review-owner-consent";
type BrowserSession = { passport?: { user?: unknown } };
type Context = { accountId: string; sessionId: string };
const opaque = /^[a-f0-9]{32}$/;
const secret = /^[a-f0-9]{64}$/;
function fields(
value: unknown,
names: string[],
): value is Record<string, unknown> {
return (
!!value &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.keys(value).sort().join(",") === names.sort().join(",")
);
}
function reject(res: Response, status: number, code: string) {
if (!res.headersSent && !res.destroyed) res.setHeader("Connection", "close");
if (!res.headersSent && !res.destroyed)
res.status(status).json({ error: { code } });
}
async function current(req: Request): Promise<Context | undefined> {
const id = (req.user as { user?: { _id?: unknown } } | undefined)?.user?._id;
const accountId = id === undefined ? "" : String(id);
if (
!req.isAuthenticated?.() ||
!/^[a-f0-9]{24}$/.test(accountId) ||
!/^[A-Za-z0-9_-]{24,256}$/.test(req.sessionID || "")
)
return undefined;
const session = await new Promise<BrowserSession | undefined>(
(resolve, fail) =>
req.sessionStore.get(req.sessionID, (error, value) =>
error
? fail(new Error("Session unavailable"))
: resolve(value as BrowserSession | undefined),
),
);
if (session?.passport?.user !== accountId) return undefined;
return { accountId, sessionId: req.sessionID };
}
/** Mount after current Passport/session authentication and BEFORE any JSON
* parser. This factory is not registered by application startup. TLS must be
* established directly or identified through the configured trusted proxy. */
export function createReviewConsentRouter(
origin: string,
backend: ReturnType<typeof createReviewOwnerConsent>,
csrfKey: Buffer,
callbacks?: ReviewCallbacks,
) {
try {
if (new URL(origin).origin !== origin || !origin.startsWith("https://"))
throw new Error();
} catch {
throw new Error("Invalid review consent origin");
}
if (!Buffer.isBuffer(csrfKey) || csrfKey.length !== 32)
throw new Error("Invalid review consent CSRF key");
const key = Buffer.from(csrfKey);
const csrf = (actor: Context) =>
createHmac("sha256", key)
.update(
"4open.review-consent-csrf/1." +
actor.accountId +
"." +
actor.sessionId,
)
.digest();
const router = express.Router({ strict: true, caseSensitive: true });
const rates = new Map<string, { until: number; count: number }>();
router.use((req, res, next) => {
res.setHeader("Cache-Control", "no-store");
res.setHeader("Referrer-Policy", "no-referrer");
res.setHeader("X-Content-Type-Options", "nosniff");
res.removeHeader("Access-Control-Allow-Origin");
if (!["/csrf", "/preview", "/confirm", ...(callbacks ? ["/handoff"] : [])].includes(req.url))
return reject(res, 404, "not-found");
if (req.method !== (req.url === "/csrf" ? "GET" : "POST"))
return reject(res, 405, "invalid-request");
if (
req.url === "/csrf" &&
(req.headers["transfer-encoding"] !== undefined ||
(req.headers["content-length"] !== undefined &&
req.headers["content-length"] !== "0"))
)
return reject(res, 400, "invalid-request");
if (
!req.secure ||
req.headers.authorization !== undefined ||
req.headers["content-encoding"] !== undefined
)
return reject(res, 403, "forbidden");
for (const name of [
"origin",
"content-type",
"x-review-csrf",
"sec-fetch-site",
]) {
if (
req.rawHeaders.filter((v, i) => i % 2 === 0 && v.toLowerCase() === name)
.length > 1
)
return reject(res, 403, "forbidden");
}
if (
(req.headers.origin !== undefined && req.headers.origin !== origin) ||
(req.method === "POST" && req.headers.origin !== origin) ||
(req.headers["sec-fetch-site"] !== undefined &&
req.headers["sec-fetch-site"] !== "same-origin")
)
return reject(res, 403, "forbidden");
if (
req.method === "POST" &&
!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(
req.headers["content-type"] || "",
)
)
return reject(res, 415, "invalid-request");
// Fail closed if mounted after a body parser that bypassed our byte limit.
if (req.body !== undefined) return reject(res, 503, "unavailable");
const controller = new AbortController();
res.locals.consentSignal = controller.signal;
const timer = setTimeout(() => {
controller.abort();
reject(res, 408, "expired");
req.destroy();
}, 15000);
const done = () => {
clearTimeout(timer);
controller.abort();
};
res.once("close", done);
res.once("finish", done);
next();
});
router.use(async (req, res, next) => {
try {
const actor = await current(req);
if (res.destroyed || res.headersSent || res.locals.consentSignal.aborted)
return;
if (!actor) return reject(res, 401, "unauthorized");
if (req.method === "POST") {
const supplied = req.headers["x-review-csrf"];
if (
typeof supplied !== "string" ||
!secret.test(supplied) ||
!timingSafeEqual(Buffer.from(supplied, "hex"), csrf(actor))
)
return reject(res, 403, "forbidden");
}
const now = Date.now();
for (const [id, rate] of rates) if (rate.until <= now) rates.delete(id);
const rate = rates.get(actor.accountId) || {
until: now + 60000,
count: 0,
};
if (
rate.count >= 60 ||
(!rates.has(actor.accountId) && rates.size >= 1024)
) {
res.setHeader("Retry-After", "60");
return reject(res, 429, "rate-limited");
}
rate.count++;
rates.set(actor.accountId, rate);
res.locals.consentActor = actor;
next();
} catch {
reject(res, 503, "unavailable");
}
});
router.get("/csrf", async (req, res) => {
try {
const fresh = await current(req);
if (!fresh || fresh.accountId !== res.locals.consentActor.accountId)
return reject(res, 401, "unauthorized");
// Derive without saving the session: a late CSRF response must never
// resurrect a session that a concurrent logout has destroyed.
res.json({ csrf: csrf(fresh).toString("hex"), handoffEnabled: !!callbacks });
} catch {
reject(res, 503, "unavailable");
}
});
router.use(express.json({ limit: 12288, strict: true, inflate: false }));
// Uploads can outlive the session checked before body parsing. Revalidate
// before any provider call or consent write, not only before the response.
router.use(async (req, res, next) => {
try {
const actor = res.locals.consentActor as Context;
const fresh = await current(req);
if (res.destroyed || res.headersSent || res.locals.consentSignal.aborted)
return;
if (
!fresh ||
fresh.accountId !== actor.accountId ||
fresh.sessionId !== actor.sessionId
)
return reject(res, 401, "unauthorized");
next();
} catch {
reject(res, 503, "unavailable");
}
});
router.post(["/preview", "/confirm", "/handoff"], async (req, res) => {
const actor = res.locals.consentActor as Context;
try {
let result: unknown;
if (req.url === "/handoff") {
if (!callbacks || !fields(req.body, ["ticket"]) || typeof req.body.ticket !== "string" || !req.body.ticket || req.body.ticket.length > 8192) return reject(res, 400, "invalid-request");
const result = await backend.handoff(actor, req.body.ticket, callbacks.resolve);
if (res.destroyed || res.headersSent) return;
const fresh = await current(req);
if (!fresh || fresh.accountId !== actor.accountId || fresh.sessionId !== actor.sessionId) return reject(res, 401, "unauthorized");
res.json({ completion: { contract: result.completion.contract, clientId: result.completion.clientId, intentId: result.completion.intentId, code: result.completion.code, expiresAt: result.completion.expiresAt }, callbackUrl: result.callbackUrl });
} else if (req.url === "/preview") {
if (
!fields(req.body, ["repositoryId", "intent"]) ||
typeof req.body.repositoryId !== "string" ||
!/^[A-Za-z0-9_-]{3,128}$/.test(req.body.repositoryId) ||
!fields(req.body.intent, [
"contract",
"clientId",
"intentId",
"token",
"requestId",
])
)
return reject(res, 422, "invalid-request");
const input = req.body.intent;
if (
input.contract !== "4open.artifacts/1" ||
![input.clientId, input.intentId, input.requestId].every(
(v) => typeof v === "string" && opaque.test(v),
) ||
typeof input.token !== "string" ||
!secret.test(input.token)
)
return reject(res, 422, "invalid-request");
const preview = await backend.preview(
actor,
req.body.repositoryId,
{
contract: input.contract,
clientId: input.clientId as string,
intentId: input.intentId as string,
requestId: input.requestId as string,
token: input.token,
},
res.locals.consentSignal,
);
result = {
ticket: preview.ticket,
repositoryName: preview.repositoryName,
policy: {
version: preview.policy.version,
access: preview.policy.access,
retainUntil: preview.policy.retainUntil,
},
expiresAt: preview.expiresAt,
};
} else {
if (
!fields(req.body, [
"ticket",
"requestId",
"acceptAccess",
"acceptRetention",
]) ||
typeof req.body.ticket !== "string" ||
req.body.ticket.length > 8192 ||
typeof req.body.requestId !== "string" ||
!opaque.test(req.body.requestId) ||
req.body.acceptAccess !== true ||
req.body.acceptRetention !== true
)
return reject(res, 422, "invalid-request");
const saved = await backend.confirm(actor, req.body.ticket, {
requestId: req.body.requestId,
acceptAccess: true,
acceptRetention: true,
});
result = {
requestId: saved.requestId,
policy: {
version: saved.policy.version,
access: saved.policy.access,
retainUntil: saved.policy.retainUntil,
},
confirmedAt: saved.confirmedAt,
};
}
const fresh = await current(req);
if (
!fresh ||
fresh.accountId !== actor.accountId ||
fresh.sessionId !== actor.sessionId
)
return reject(res, 401, "unauthorized");
if (!res.headersSent && !res.destroyed) res.json(result);
} catch (error) {
const kind = (error as { kind?: string })?.kind;
const status =
kind === "invalid" || kind === "protocol"
? 422
: kind === "forbidden" || kind === "rejected"
? 403
: kind === "expired"
? 410
: kind === "conflict"
? 409
: 503;
reject(
res,
status,
status === 503
? "unavailable"
: status === 422
? "invalid-request"
: kind!,
);
}
});
router.use(((error, _req, res, _next) =>
reject(
res,
error?.status === 413 ? 413 : 400,
"invalid-request",
)) as express.ErrorRequestHandler);
return router;
}
+357
View File
@@ -0,0 +1,357 @@
import { decodeReviewJSON } from "./review-json";
import * as https from "https";
import { ClientRequest } from "http";
const contract = "4open.artifacts/1",
limit = 65536;
const opaque = /^[a-f0-9]{32}$/,
secret = /^[a-f0-9]{64}$/;
export type IntentClientFailure =
| "invalid"
| "busy"
| "unavailable"
| "rejected"
| "conflict"
| "expired"
| "not-found"
| "protocol";
export class IntentClientError extends Error {
constructor(public readonly kind: IntentClientFailure) {
super("Review intent consumption " + kind);
this.name = "IntentClientError";
}
}
export interface ReviewIntentClientConfig {
origin: string;
clientId: string;
keyId: string;
token: string;
notBefore: string;
notAfter: string;
}
export interface IntentConsumption {
contract: string;
clientId: string;
intentId: string;
token: string;
requestId: string;
}
export type ConsumedReviewIntent = Readonly<{
contract: string;
clientId: string;
intentId: string;
submissionRef: string;
callbackId: string;
entitlementId: string;
expiresAt: string;
policy: Readonly<{
version: number;
access: "anonymous-link" | "restricted-review";
retainUntil: string;
}>;
}>;
function fail(kind: IntentClientFailure): never {
throw new IntentClientError(kind);
}
function object(value: unknown, fields: string[]): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value))
fail("protocol");
const out = value as Record<string, unknown>;
if (
Object.keys(out).length !== fields.length ||
fields.some((k) => !Object.prototype.hasOwnProperty.call(out, k))
)
fail("protocol");
return out;
}
function instant(value: unknown): number {
if (
typeof value !== "string" ||
!/^2[01][0-9]{2}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/.test(value)
)
fail("protocol");
const time = Date.parse(value);
if (
!Number.isFinite(time) ||
new Date(time).toISOString() !== value.replace("Z", ".000Z")
)
fail("protocol");
return time;
}
function id(value: unknown): value is string {
return typeof value === "string" && opaque.test(value);
}
function decode(bytes: Buffer): unknown {
try {
return decodeReviewJSON(bytes);
} catch {
return fail("protocol");
}
}
function intent(
bytes: Buffer,
clientId: string,
intentId: string,
): ConsumedReviewIntent {
const p = object(decode(bytes), [
"contract",
"clientId",
"intentId",
"submissionRef",
"callbackId",
"policy",
"expiresAt",
"entitlementId",
]);
const policy = object(p.policy, ["version", "access", "retainUntil"]),
now = Date.now();
if (
p.contract !== contract ||
p.clientId !== clientId ||
p.intentId !== intentId ||
!id(p.submissionRef) ||
!id(p.callbackId) ||
!id(p.entitlementId)
)
fail("protocol");
if (
!Number.isSafeInteger(policy.version) ||
(policy.version as number) < 1 ||
(policy.access !== "anonymous-link" &&
policy.access !== "restricted-review") ||
instant(policy.retainUntil) <= now
)
fail("protocol");
const expires = instant(p.expiresAt);
if (expires <= now || expires > now + 600000) fail("protocol");
return Object.freeze({
contract,
clientId,
intentId,
submissionRef: p.submissionRef,
callbackId: p.callbackId,
entitlementId: p.entitlementId,
expiresAt: p.expiresAt as string,
policy: Object.freeze({
version: policy.version as number,
access: policy.access as "anonymous-link" | "restricted-review",
retainUntil: policy.retainUntil as string,
}),
});
}
/** Trusted server configuration only. One attempt per call; the caller retains
* its request ID for an explicit retry. No browser callback or owner authority
* is established by consuming an intent. No credentials are exposed on the
* returned object, through serialization, or in transport errors. */
export function createReviewIntentConsumer(config: ReviewIntentClientConfig) {
let origin: string,
clientId: string,
keyId: string,
token: string,
from: number,
until: number;
try {
const c = object(config, [
"origin",
"clientId",
"keyId",
"token",
"notBefore",
"notAfter",
]);
if (
typeof c.origin !== "string" ||
c.origin.length > 2048 ||
!id(c.clientId) ||
typeof c.keyId !== "string" ||
!/^[A-Za-z0-9_-]{1,64}$/.test(c.keyId) ||
typeof c.token !== "string" ||
!secret.test(c.token)
)
fail("invalid");
const url = new URL(c.origin);
if (
url.protocol !== "https:" ||
url.origin !== c.origin ||
url.username ||
url.password
)
fail("invalid");
origin = c.origin;
clientId = c.clientId;
keyId = c.keyId;
token = c.token;
from = instant(c.notBefore);
until = instant(c.notAfter);
if (until <= from || Date.now() < from || Date.now() >= until)
fail("invalid");
} catch {
return fail("invalid");
}
let active = 0;
return Object.freeze({
consume(
input: IntentConsumption,
signal?: AbortSignal,
): Promise<ConsumedReviewIntent> {
let body: Buffer, expectedIntent: string;
try {
const p = object(input, [
"contract",
"clientId",
"intentId",
"token",
"requestId",
]);
if (
p.contract !== contract ||
p.clientId !== clientId ||
!id(p.intentId) ||
!id(p.requestId) ||
typeof p.token !== "string" ||
!secret.test(p.token) ||
Date.now() < from ||
Date.now() >= until
)
fail("invalid");
expectedIntent = p.intentId;
body = Buffer.from(
JSON.stringify({
contract,
clientId,
intentId: expectedIntent,
token: p.token,
requestId: p.requestId,
}),
);
} catch {
return Promise.reject(new IntentClientError("invalid"));
}
if (signal?.aborted)
return Promise.reject(new IntentClientError("unavailable"));
if (active >= 4) return Promise.reject(new IntentClientError("busy"));
active++;
return new Promise((resolve, reject) => {
let request: ClientRequest | undefined,
settled = false;
const abort = () => finish("unavailable");
const timer = setTimeout(abort, 10000);
function finish(
error?: IntentClientFailure,
value?: ConsumedReviewIntent,
) {
if (settled) return;
settled = true;
clearTimeout(timer);
signal?.removeEventListener("abort", abort);
active--;
if (error) {
request?.destroy();
reject(new IntentClientError(error));
} else resolve(value!);
}
signal?.addEventListener("abort", abort, { once: true });
try {
request = https.request(
origin + "/service/v1/artifact-intents/consume",
{
method: "POST",
agent: false,
rejectUnauthorized: true,
minVersion: "TLSv1.2",
maxHeaderSize: 16384,
headers: {
Authorization: "Bearer " + token,
"X-4open-Artifact-Client-Id": clientId,
"X-4open-Artifact-Service-Key-Id": keyId,
"Content-Type": "application/json",
Accept: "application/json",
"Accept-Encoding": "identity",
"Content-Length": body.length,
},
},
(response) => {
response.once("aborted", abort);
response.once("error", abort);
const status = response.statusCode || 0;
if (status !== 200) {
const kind: IntentClientFailure =
status === 404
? "not-found"
: status === 409
? "conflict"
: status === 410
? "expired"
: [400, 401, 403, 422].includes(status)
? "rejected"
: [408, 429, 500, 502, 503, 504].includes(status)
? "unavailable"
: "protocol";
finish(kind);
return;
}
const types = response.rawHeaders.filter(
(_v, i) =>
i % 2 === 0 &&
response.rawHeaders[i].toLowerCase() === "content-type",
).length;
const length = response.headers["content-length"];
if (
types !== 1 ||
!/^application\/json(?:\s*;\s*charset=(?:utf-8|"utf-8"))?\s*$/i.test(
response.headers["content-type"] || "",
) ||
response.headers["content-encoding"] !== undefined ||
response.headers["set-cookie"] !== undefined ||
(length !== undefined &&
(!/^[0-9]+$/.test(length) || Number(length) > limit))
) {
finish("protocol");
return;
}
let size = 0;
const chunks: Buffer[] = [];
response.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > limit) finish("protocol");
else if (!settled) chunks.push(chunk);
});
response.once("end", () => {
if (settled) return;
if (
signal?.aborted ||
Date.now() < from ||
Date.now() >= until
) {
finish("unavailable");
return;
}
try {
finish(
undefined,
intent(
Buffer.concat(chunks, size),
clientId,
expectedIntent,
),
);
} catch {
finish("protocol");
}
});
},
);
request.once("error", abort);
request.setTimeout(5000, abort);
if (signal?.aborted) abort();
if (!settled) request.end(body);
} catch {
finish("unavailable");
}
});
},
});
}
+68
View File
@@ -0,0 +1,68 @@
function invalid(): never {
throw new Error("Invalid review JSON");
}
// JSON.parse checks syntax; this bounded walk additionally rejects duplicate
// decoded keys, including escaped spellings, before typed field validation.
export function decodeReviewJSON(bytes: Buffer): unknown {
if (bytes.length > 65536) invalid();
const text = bytes.toString("utf8");
if (!Buffer.from(text, "utf8").equals(bytes)) invalid();
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
invalid();
}
let at = 0;
const space = () => {
while (/\s/.test(text[at] || "") && at < text.length) at++;
};
const string = (): string => {
const start = at++;
while (at < text.length) {
if (text[at] === "\\") {
at += 2;
continue;
}
if (text[at++] === '"') return JSON.parse(text.slice(start, at));
}
return invalid();
};
const walk = (depth: number): void => {
if (depth > 8) invalid();
space();
if (text[at] === '"') {
string();
return;
}
if (text[at] === "{" || text[at] === "[") {
const isObject = text[at++] === "{",
end = isObject ? "}" : "]",
keys = new Set<string>();
space();
if (text[at] === end) {
at++;
return;
}
for (;;) {
space();
if (isObject) {
const key = string();
if (keys.has(key)) invalid();
keys.add(key);
space();
at++; // colon; syntax already checked
}
walk(depth + 1);
space();
if (text[at++] === end) return;
}
}
while (at < text.length && !/[\s,}\]]/.test(text[at])) at++;
};
walk(0);
space();
if (at !== text.length) invalid();
return parsed;
}
+602
View File
@@ -0,0 +1,602 @@
import { createHash, createHmac, timingSafeEqual, randomBytes } from "crypto";
import { Connection, ClientSession, Types } from "mongoose";
import {
ConsumedReviewIntent,
IntentConsumption,
createReviewIntentConsumer,
} from "./review-intent-client";
type Principal = { accountId: string; sessionId: string };
type Authority = {
accountId: string;
repositoryId: string;
repositoryName: string;
role: "owner" | "coauthor";
};
type Quote = {
version: 1;
accountId: string;
sessionHash: string;
repositoryId: string;
intent: ConsumedReviewIntent;
};
export type ConsentReceipt = Readonly<{
clientId: string;
intentId: string;
accountId: string;
repositoryId: string;
role: "owner" | "coauthor";
policy: ConsumedReviewIntent["policy"];
requestId: string;
confirmedAt: string;
}>;
type SavedConsent = {
_id: string;
accountId: string;
repositoryId: string;
requestId: string;
ticketHash: string;
receipt: ConsentReceipt;
intent: ConsumedReviewIntent;
};
type BindingReceipt = {
contract: "4open.artifacts/1";
clientId: string;
intentId: string;
bindingId: string;
submissionRef: string;
policy: ConsumedReviewIntent["policy"];
entitlementId: string;
};
type SavedCompletion = {
_id: string;
accountId: string;
repositoryId: string;
ticketHash: string;
intent: ConsumedReviewIntent;
nonce: string;
codeHash: string;
expiresAt: string;
exchange?: { requestId: string; receipt: BindingReceipt };
};
export class ReviewConsentError extends Error {
constructor(
public readonly kind:
"invalid" | "forbidden" | "expired" | "conflict" | "unavailable",
) {
super("Review owner consent " + kind);
this.name = "ReviewConsentError";
}
}
const deny = (kind: ReviewConsentError["kind"]): never => {
throw new ReviewConsentError(kind);
};
const hash = (value: string) =>
createHash("sha256").update(value).digest("hex");
const objectId = (value: unknown): value is string =>
typeof value === "string" && /^[a-f0-9]{24}$/.test(value);
function principal(value: Principal): {
accountId: string;
sessionHash: string;
} {
if (
!value ||
!objectId(value.accountId) ||
typeof value.sessionId !== "string" ||
!/^[A-Za-z0-9_-]{24,256}$/.test(value.sessionId)
)
deny("invalid");
return { accountId: value.accountId, sessionHash: hash(value.sessionId) };
}
/** Server-side consent storage. Principals must come from current authenticated
* browser sessions, never request JSON. A future HTTP adapter must apply its
* own same-origin/CSRF checks. No route or provider is enabled by this factory. */
export function createReviewOwnerConsent(
connection: Connection,
consumer: ReturnType<typeof createReviewIntentConsumer>,
signingKey: Buffer,
) {
if (!Buffer.isBuffer(signingKey) || signingKey.length !== 32) deny("invalid");
const key = Buffer.from(signingKey);
let activeTransactions = 0;
let activeReads = 0;
const signature = (body: string) =>
createHmac("sha256", key)
.update("4open.review-consent/1." + body)
.digest();
function decode(ticket: string, who: ReturnType<typeof principal>): Quote {
if (
typeof ticket !== "string" ||
ticket.length > 8192 ||
!/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/.test(ticket)
)
deny("invalid");
const [body, encoded] = ticket.split(".");
const supplied = Buffer.from(encoded, "base64url");
if (
supplied.length !== 32 ||
supplied.toString("base64url") !== encoded ||
!timingSafeEqual(supplied, signature(body))
)
deny("invalid");
let quote: Quote;
try {
quote = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
} catch {
return deny("invalid");
}
if (
quote.version !== 1 ||
quote.accountId !== who.accountId ||
quote.sessionHash !== who.sessionHash ||
!objectId(quote.repositoryId)
)
deny("forbidden");
if (
Date.parse(quote.intent.expiresAt) <= Date.now() ||
Date.parse(quote.intent.policy.retainUntil) <= Date.now()
)
deny("expired");
return quote;
}
async function transaction<T>(
who: ReturnType<typeof principal>,
repository: { repoId: string } | { _id: Types.ObjectId },
work: (authority: Authority, session: ClientSession) => Promise<T>,
): Promise<T> {
if (connection.readyState !== 1 || activeTransactions >= 4)
return deny("unavailable");
activeTransactions++;
const session = await connection.startSession().catch(() => {
activeTransactions--;
return deny("unavailable");
});
try {
session.startTransaction({
readConcern: { level: "snapshot" },
writeConcern: { w: "majority", wtimeoutMS: 3000 },
maxCommitTimeMS: 5000,
});
// Touch the authority documents in the same transaction as the receipt.
// Concurrent disablement, ownership changes and coauthor removal then
// conflict with this transaction instead of producing a stale consent.
const user = (
await connection.db.collection("users").findOneAndUpdate(
{
_id: new Types.ObjectId(who.accountId),
$or: [{ status: "active" }, { status: { $exists: false } }],
},
{ $inc: { reviewConsentRevision: 1 } },
{
session,
returnDocument: "after",
maxTimeMS: 3000,
projection: { externalIDs: 1 },
},
)
).value;
if (!user) return deny("forbidden");
const githubId = user.externalIDs?.github;
const roles: Record<string, unknown>[] = [
{ owner: new Types.ObjectId(who.accountId) },
];
if (typeof githubId === "string" && /^[1-9][0-9]{0,19}$/.test(githubId))
roles.push({ "coauthors.githubId": githubId });
const repo = (
await connection.db
.collection("anonymizedrepositories")
.findOneAndUpdate(
{ ...repository, status: "ready", $or: roles },
{ $inc: { reviewConsentRevision: 1 } },
{
session,
returnDocument: "after",
maxTimeMS: 3000,
projection: { repoId: 1, owner: 1, options: 1 },
},
)
).value;
if (!repo) return deny("forbidden");
if (
repo.options?.expirationMode !== "never" &&
repo.options?.expirationDate &&
(!Number.isFinite(new Date(repo.options.expirationDate).getTime()) ||
new Date(repo.options.expirationDate).getTime() <= Date.now())
)
return deny("expired");
const authority: Authority = {
accountId: who.accountId,
repositoryId: String(repo._id),
repositoryName: repo.repoId,
role: String(repo.owner) === who.accountId ? "owner" : "coauthor",
};
const result = await work(authority, session);
await session.commitTransaction();
return result;
} catch (error) {
if (session.inTransaction())
await session.abortTransaction().catch(() => undefined);
if (error instanceof ReviewConsentError) throw error;
return deny("unavailable");
} finally {
activeTransactions--;
await session.endSession().catch(() => undefined);
}
}
const completionCode = (id: string, nonce: string) =>
createHmac("sha256", key)
.update("4open.review-completion/1." + id + "." + nonce)
.digest("hex");
const api = {
async handoff(actor: Principal, ticket: string, resolveCallback: (clientId: string, callbackId: string) => string | undefined) {
const quote = decode(ticket, principal(actor));
const callbackUrl = resolveCallback(quote.intent.clientId, quote.intent.callbackId);
if (!callbackUrl) return deny("forbidden");
const completion = await api.completion(actor, ticket);
return Object.freeze({ completion, callbackUrl });
},
// Called only with the current authenticated browser principal. The HTTP
// adapter must retain its session reload, same-origin and CSRF checks.
async completion(actor: Principal, ticket: string) {
const who = principal(actor),
quote = decode(ticket, who);
return transaction(
who,
{ _id: new Types.ObjectId(quote.repositoryId) },
async (authority, session) => {
decode(ticket, who);
const id = quote.intent.clientId + ":" + quote.intent.intentId;
const consent = await connection.db
.collection<SavedConsent>("review_owner_consents")
.findOne({ _id: id }, { session, maxTimeMS: 3000 });
if (
!consent ||
consent.ticketHash !== hash(ticket) ||
consent.accountId !== who.accountId ||
consent.repositoryId !== authority.repositoryId ||
!consent.intent
)
return deny("forbidden");
const collection =
connection.db.collection<SavedCompletion>("review_completions");
let saved = await collection.findOne(
{ _id: id },
{ session, maxTimeMS: 3000 },
);
if (
saved &&
(saved.ticketHash !== consent.ticketHash ||
saved.accountId !== who.accountId ||
saved.repositoryId !== authority.repositoryId)
)
return deny("conflict");
if (!saved) {
const nonce = randomBytes(32).toString("hex");
saved = {
_id: id,
accountId: who.accountId,
repositoryId: authority.repositoryId,
ticketHash: consent.ticketHash,
intent: consent.intent,
nonce,
codeHash: hash(completionCode(id, nonce)),
expiresAt: new Date(
Math.floor(
Math.min(
Date.now() + 300000,
Date.parse(consent.intent.expiresAt),
Date.parse(consent.intent.policy.retainUntil),
) / 1000,
) * 1000,
)
.toISOString()
.replace(/\.000Z$/, "Z"),
};
await collection.insertOne(saved, { session, maxTimeMS: 3000 });
}
if (Date.parse(saved.expiresAt) <= Date.now()) return deny("expired");
const code = completionCode(id, saved.nonce);
// A key rotation must not silently issue a different code for a saved
// intent. Keep the issuing key available for the short code lifetime.
if (hash(code) !== saved.codeHash) return deny("conflict");
return Object.freeze({
contract: "4open.artifacts/1",
clientId: saved.intent.clientId,
intentId: saved.intent.intentId,
code,
expiresAt: saved.expiresAt,
});
},
);
},
// authenticatedClientId must come from service credential verification,
// never browser identity or the request body's clientId alone.
async exchange(
authenticatedClientId: string,
input: {
contract: string;
clientId: string;
intentId: string;
code: string;
requestId: string;
},
): Promise<BindingReceipt> {
if (
!input ||
Object.keys(input).sort().join(",") !==
"clientId,code,contract,intentId,requestId" ||
input.contract !== "4open.artifacts/1" ||
![input.clientId, input.intentId, input.requestId].every(
(value) => typeof value === "string" && /^[a-f0-9]{32}$/.test(value),
) ||
typeof input.code !== "string" ||
!/^[a-f0-9]{64}$/.test(input.code)
)
return deny("invalid");
const command = { ...input };
if (authenticatedClientId !== command.clientId) return deny("forbidden");
if (connection.readyState !== 1 || activeReads >= 4)
return deny("unavailable");
const id = command.clientId + ":" + command.intentId;
const collection =
connection.db.collection<SavedCompletion>("review_completions");
const read = async (session?: ClientSession) => {
const saved = await collection.findOne(
{ _id: id },
{ session, maxTimeMS: 3000, ...(session ? {} : { readConcern: { level: "majority" as const } }) },
);
if (!saved) return deny("forbidden");
if (
!timingSafeEqual(
Buffer.from(saved.codeHash, "hex"),
Buffer.from(hash(command.code), "hex"),
)
)
return deny(
saved.exchange?.requestId === command.requestId
? "conflict"
: "forbidden",
);
return saved;
};
activeReads++;
try {
const before = await read();
if (before.exchange) {
if (before.exchange.requestId !== command.requestId)
return deny("conflict");
return Object.freeze(before.exchange.receipt);
}
return await transaction(
{ accountId: before.accountId, sessionHash: "" },
{ _id: new Types.ObjectId(before.repositoryId) },
async (_authority, session) => {
const saved = await read(session);
if (saved.exchange) {
if (saved.exchange.requestId !== command.requestId)
return deny("conflict");
return Object.freeze(saved.exchange.receipt);
}
if (
Date.parse(saved.expiresAt) <= Date.now() ||
Date.parse(saved.intent.policy.retainUntil) <= Date.now()
)
return deny("expired");
// The unique request document detects a reused key on another intent.
const requests = connection.db.collection<{
_id: string;
intentId: string;
}>("review_completion_requests");
const requestKey = command.clientId + ":" + command.requestId;
if (
await requests.findOne(
{ _id: requestKey },
{ session, maxTimeMS: 3000 },
)
)
return deny("conflict");
const receipt: BindingReceipt = {
contract: "4open.artifacts/1",
clientId: saved.intent.clientId,
intentId: saved.intent.intentId,
bindingId: randomBytes(16).toString("hex"),
submissionRef: saved.intent.submissionRef,
policy: { ...saved.intent.policy },
entitlementId: saved.intent.entitlementId,
};
await connection.db
.collection<{
_id: string;
clientId: string;
intentId: string;
accountId: string;
repositoryId: string;
state: "pending";
revision: number;
receipt: BindingReceipt;
}>("review_artifact_bindings")
.insertOne(
{
_id: receipt.bindingId,
clientId: command.clientId,
intentId: command.intentId,
accountId: saved.accountId,
repositoryId: saved.repositoryId,
state: "pending",
revision: 1,
receipt,
},
{ session, maxTimeMS: 3000 },
);
await requests.insertOne(
{ _id: requestKey, intentId: command.intentId },
{ session, maxTimeMS: 3000 },
);
await collection.updateOne(
{ _id: id },
{ $set: { exchange: { requestId: command.requestId, receipt } } },
{ session, maxTimeMS: 3000 },
);
return Object.freeze(receipt);
},
);
} catch (error) {
if (error instanceof ReviewConsentError) throw error;
return deny("unavailable");
} finally {
activeReads--;
}
},
async receipt(
authenticatedClientId: string,
intentId: string,
): Promise<BindingReceipt> {
if (
![authenticatedClientId, intentId].every(
(value) => typeof value === "string" && /^[a-f0-9]{32}$/.test(value),
)
)
return deny("invalid");
if (connection.readyState !== 1 || activeReads >= 4)
return deny("unavailable");
activeReads++;
try {
const saved = await connection.db
.collection<SavedCompletion>("review_completions")
.findOne(
{ _id: authenticatedClientId + ":" + intentId },
{ maxTimeMS: 3000, readConcern: { level: "majority" }, projection: { exchange: 1 } },
);
if (!saved?.exchange) return deny("forbidden");
return Object.freeze(saved.exchange.receipt);
} catch (error) {
if (error instanceof ReviewConsentError) throw error;
return deny("unavailable");
} finally {
activeReads--;
}
},
async preview(
actor: Principal,
repositoryName: string,
input: IntentConsumption,
signal?: AbortSignal,
) {
const who = principal(actor);
const command = { ...input };
if (
typeof repositoryName !== "string" ||
!/^[A-Za-z0-9_-]{3,128}$/.test(repositoryName)
)
return deny("invalid");
const before = await transaction(
who,
{ repoId: repositoryName },
async (authority) => authority,
);
// The provider call holds no database transaction or authority lock.
const intent = await consumer.consume(command, signal);
const current = await transaction(
who,
{ _id: new Types.ObjectId(before.repositoryId) },
async (authority) => authority,
);
if (
signal?.aborted ||
Date.parse(intent.expiresAt) <= Date.now() ||
Date.parse(intent.policy.retainUntil) <= Date.now()
)
return deny("expired");
const quote: Quote = {
version: 1,
accountId: who.accountId,
sessionHash: who.sessionHash,
repositoryId: current.repositoryId,
intent,
};
const body = Buffer.from(JSON.stringify(quote)).toString("base64url");
return Object.freeze({
ticket: body + "." + signature(body).toString("base64url"),
repositoryName: current.repositoryName,
policy: intent.policy,
expiresAt: intent.expiresAt,
});
},
async confirm(
actor: Principal,
ticket: string,
input: {
requestId: string;
acceptAccess: boolean;
acceptRetention: boolean;
},
): Promise<ConsentReceipt> {
const who = principal(actor);
if (
!input ||
Object.keys(input).sort().join(",") !==
"acceptAccess,acceptRetention,requestId" ||
input.acceptAccess !== true ||
input.acceptRetention !== true ||
typeof input.requestId !== "string" ||
!/^[a-f0-9]{32}$/.test(input.requestId)
)
return deny("invalid");
const quote = decode(ticket, who),
requestId = input.requestId;
return transaction(
who,
{ _id: new Types.ObjectId(quote.repositoryId) },
async (authority, session) => {
decode(ticket, who); // Recheck expiry after waiting for authority locks.
const collection = connection.db.collection<SavedConsent>(
"review_owner_consents",
);
const id = quote.intent.clientId + ":" + quote.intent.intentId;
const ticketHash = hash(ticket);
// A string _id binds each intent to at most one accepted repository and
// session. No raw session ID, intent token or service key is persisted.
const existing = await collection.findOne(
{ _id: id },
{ session, maxTimeMS: 3000 },
);
if (existing) {
if (
existing.ticketHash !== ticketHash ||
existing.requestId !== requestId ||
existing.accountId !== who.accountId ||
existing.repositoryId !== authority.repositoryId
)
return deny("conflict");
return Object.freeze(existing.receipt as ConsentReceipt);
}
const receipt: ConsentReceipt = Object.freeze({
clientId: quote.intent.clientId,
intentId: quote.intent.intentId,
accountId: who.accountId,
repositoryId: authority.repositoryId,
role: authority.role,
policy: quote.intent.policy,
requestId,
confirmedAt: new Date().toISOString(),
});
await collection.insertOne(
{
_id: id,
accountId: who.accountId,
repositoryId: authority.repositoryId,
requestId,
ticketHash,
receipt,
intent: quote.intent,
},
{ session, maxTimeMS: 3000 },
);
return receipt;
},
);
},
};
return Object.freeze(api);
}
+160
View File
@@ -0,0 +1,160 @@
import { createHash, timingSafeEqual } from "crypto";
import { Request } from "express";
import { decodeReviewJSON } from "./review-json";
type Scope = "completion.exchange" | "receipt.read";
type Key = {
clientId: string;
digest: Buffer;
from: number;
until: number;
scopes: Scope[];
};
const opaque = /^[a-f0-9]{32}$/;
const secret = /^[a-f0-9]{64}$/;
const invalid = (): never => {
throw new Error("Invalid review exchange credentials");
};
function fields(value: unknown, names: string[]): Record<string, unknown> {
if (
!value ||
typeof value !== "object" ||
Array.isArray(value) ||
Object.keys(value).sort().join(",") !== names.sort().join(",")
)
return invalid();
return value as Record<string, unknown>;
}
function date(value: unknown): number {
if (
typeof value !== "string" ||
!/^20[0-9]{2}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/.test(value)
)
return invalid();
const number = Date.parse(value);
if (
!Number.isFinite(number) ||
new Date(number).toISOString() !== value.replace("Z", ".000Z")
)
return invalid();
return number;
}
export function singleReviewHeader(
req: Request,
name: string,
): string | undefined {
let count = 0;
for (let i = 0; i < req.rawHeaders.length; i += 2)
if (req.rawHeaders[i].toLowerCase() === name) count++;
const value = req.headers[name];
return count === 1 && typeof value === "string" ? value : undefined;
}
/** Separate scoped registry: the capabilities-only registry is not accepted. */
export function createReviewServiceAuth(
raw: string,
now: () => number = Date.now,
) {
let parsed: unknown;
try {
parsed = decodeReviewJSON(Buffer.from(raw, "utf8"));
} catch {
return invalid();
}
const config = fields(parsed, ["version", "keys"]);
if (
config.version !== 1 ||
!Array.isArray(config.keys) ||
config.keys.length < 1 ||
config.keys.length > 64
)
return invalid();
const keys = new Map<string, Key>(),
digests = new Set<string>(),
clients = new Map<string, number>();
for (const entry of config.keys) {
const k = fields(entry, [
"clientId",
"keyId",
"tokenSHA256",
"notBefore",
"notAfter",
"scopes",
]);
if (
typeof k.clientId !== "string" ||
!opaque.test(k.clientId) ||
typeof k.keyId !== "string" ||
!/^[A-Za-z0-9_-]{1,64}$/.test(k.keyId) ||
typeof k.tokenSHA256 !== "string" ||
!secret.test(k.tokenSHA256) ||
!Array.isArray(k.scopes) ||
k.scopes.length < 1 ||
k.scopes.length > 2 ||
k.scopes.some(
(scope) => scope !== "completion.exchange" && scope !== "receipt.read",
) ||
new Set(k.scopes).size !== k.scopes.length
)
return invalid();
const from = date(k.notBefore),
until = date(k.notAfter),
id = k.clientId + ":" + k.keyId;
const count = (clients.get(k.clientId) || 0) + 1;
if (
until <= from ||
keys.has(id) ||
digests.has(k.tokenSHA256) ||
count > 16
)
return invalid();
clients.set(k.clientId, count);
if (clients.size > 32) return invalid();
digests.add(k.tokenSHA256);
keys.set(id, {
clientId: k.clientId,
digest: Buffer.from(k.tokenSHA256, "hex"),
from,
until,
scopes: [...k.scopes] as Scope[],
});
}
const dummy = Buffer.alloc(32);
return Object.freeze({
authenticate(
req: Request,
scope: Scope,
): { clientId: string; valid: () => boolean } | undefined {
const client = singleReviewHeader(req, "x-4open-artifact-client-id"),
keyId = singleReviewHeader(req, "x-4open-artifact-service-key-id"),
authorization = singleReviewHeader(req, "authorization");
if (
!client ||
!opaque.test(client) ||
!keyId ||
!/^[A-Za-z0-9_-]{1,64}$/.test(keyId) ||
!authorization ||
!/^Bearer [a-f0-9]{64}$/i.test(authorization) ||
!secret.test(authorization.slice(7))
)
return undefined;
const key = keys.get(client + ":" + keyId);
const matches = timingSafeEqual(
createHash("sha256").update(authorization.slice(7), "ascii").digest(),
key?.digest || dummy,
);
const valid = () => {
const time = now();
return (
!!key &&
matches &&
key.scopes.includes(scope) &&
Number.isFinite(time) &&
time >= key.from &&
time < key.until
);
};
return valid() ? Object.freeze({ clientId: client, valid }) : undefined;
},
});
}
+2
View File
@@ -1,3 +1,4 @@
import { installFatalErrorLogging } from "../core/process-monitoring";
import { config as dotenv } from "dotenv";
dotenv();
@@ -16,6 +17,7 @@ import { startPerformanceMonitoring } from "../core/performance-monitoring";
import { getAnonymizationPoolStats } from "../core/anonymization-pool";
const logger = createLogger("streamer");
installFatalErrorLogging("streamer");
const app = express();
app.use(monitorRequests("streamer"));
+2 -1
View File
@@ -34,6 +34,7 @@ router.post(
getToken: () => token,
anonymizerOptions: { ...anonymizerOptions, repoId },
contentOptions,
submodules: req.body.submodules,
},
res
);
@@ -89,7 +90,7 @@ router.post("/", async (req: express.Request, res: express.Response) => {
const content = await source.getFileContentCache(
filePath,
repoId,
() => ({ sha: fileSha, size: fileSize })
() => ({ sha: fileSha, size: fileSize, upstream: req.body.upstream })
);
if (res.destroyed || res.writableEnded) { content.destroy(); anonymizer.destroy(); return; }
const mime = lookup(filePath);