mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 00:07:00 +01:00
merge: reconcile paid hosting with latest main
This commit is contained in:
commit
ded6f905fd
180 files changed
+21709
-3614
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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", {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,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]")
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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 => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[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;
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>`);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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, "'");
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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--;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user