mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-09-14 06:38:57 +02:00
feat: add read-only GitHub App access alongside OAuth
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import InstallationModel from "../core/model/github-installation";
|
||||
import { credentialCipher } from "../core/credentials";
|
||||
import CredentialModel from "../core/model/credentials/credentials.model";
|
||||
import mongoose, { ConnectOptions } from "mongoose";
|
||||
@@ -31,6 +32,7 @@ export async function connect() {
|
||||
if (!config.MONGODB_URI) options.authSource = "admin";
|
||||
await mongoose.connect(getMongoUrl(), options);
|
||||
await CredentialModel.createIndexes();
|
||||
if (config.GITHUB_APP_ENABLED) await InstallationModel.createIndexes();
|
||||
isConnected = true;
|
||||
|
||||
return database;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { githubAppRouter, githubAppWebhook } from "./routes/github-app";
|
||||
import { config as dotenv } from "dotenv";
|
||||
dotenv();
|
||||
|
||||
@@ -100,6 +101,7 @@ function indexResponse(req: express.Request, res: express.Response) {
|
||||
export default async function start() {
|
||||
const app = express();
|
||||
app.set("query parser", "extended");
|
||||
app.use("/github/app/webhook", githubAppWebhook);
|
||||
app.use(express.json());
|
||||
// Preserve the empty body used by API validation when no JSON was parsed.
|
||||
app.use((req, _res, next) => {
|
||||
@@ -241,6 +243,7 @@ export default async function start() {
|
||||
next();
|
||||
});
|
||||
|
||||
app.use("/github", rate, speedLimiter, githubAppRouter);
|
||||
app.use("/github", rate, speedLimiter, connectionRouter);
|
||||
|
||||
// api routes
|
||||
|
||||
@@ -117,12 +117,14 @@ const verify = async (
|
||||
}
|
||||
};
|
||||
|
||||
passport.use(
|
||||
if (config.GITHUB_OAUTH_ENABLED) passport.use(
|
||||
new Strategy(
|
||||
{
|
||||
clientID: config.CLIENT_ID,
|
||||
clientSecret: config.CLIENT_SECRET,
|
||||
callbackURL: config.AUTH_CALLBACK,
|
||||
// passport-oauth2 supports boolean session state; github2 types incorrectly narrow it.
|
||||
state: true as unknown as string,
|
||||
},
|
||||
verify
|
||||
)
|
||||
@@ -174,6 +176,7 @@ export const router = express.Router();
|
||||
|
||||
router.get(
|
||||
"/login",
|
||||
(req, res, next) => config.GITHUB_OAUTH_ENABLED ? next() : res.status(503).json({ error: "github_oauth_disabled" }),
|
||||
passport.authenticate("github", { scope: ["repo"] }), // Note the scope here
|
||||
function (req: express.Request, res: express.Response) {
|
||||
res.redirect("/");
|
||||
@@ -182,9 +185,19 @@ router.get(
|
||||
|
||||
router.get(
|
||||
"/auth",
|
||||
passport.authenticate("github", { failureRedirect: "/" }),
|
||||
function (req: express.Request, res: express.Response) {
|
||||
res.redirect("/");
|
||||
(req, res, next) => {
|
||||
if (!config.GITHUB_OAUTH_ENABLED) return res.status(503).json({ error: "github_oauth_disabled" });
|
||||
const existingId = (req.user as { user?: { id?: string } } | undefined)?.user?.id;
|
||||
passport.authenticate("github", (error: Error | null, identity: Express.User | false) => {
|
||||
if (error) return next(error);
|
||||
if (!identity) return res.redirect("/signin");
|
||||
const id = (identity as { user?: { id?: string } }).user?.id;
|
||||
if (existingId && id !== existingId) return res.status(409).json({ error: "github_identity_mismatch" });
|
||||
req.login(identity, loginError => {
|
||||
if (loginError) return next(loginError);
|
||||
res.redirect(existingId ? "/connections" : "/dashboard");
|
||||
});
|
||||
})(req, res, next);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import * as express from "express";
|
||||
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
|
||||
import config from "../../config";
|
||||
import UserModel from "../../core/model/users/users.model";
|
||||
import CredentialModel from "../../core/model/credentials/credentials.model";
|
||||
import InstallationModel from "../../core/model/github-installation";
|
||||
import RepositoryModel from "../../core/model/anonymizedRepositories/anonymizedRepositories.model";
|
||||
import PullRequestModel from "../../core/model/anonymizedPullRequests/anonymizedPullRequests.model";
|
||||
import GistModel from "../../core/model/anonymizedGists/anonymizedGists.model";
|
||||
import { getCredentialToken } from "../../core/credentials";
|
||||
import { APP_PROVIDER, appError, appUserToken, AppInstallation, clearAppTokenCache, exchangeAppToken,
|
||||
githubRequest, GitHubRepositoryInfo, reconcileAppGrant, reconcileInstallation, installationURL, saveAppGrant, selectRepositoryAccess, userInstallations } from "../../core/github-app";
|
||||
import { getUser, handleError } from "./route-utils";
|
||||
import { isDisabledAccount } 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; }
|
||||
}
|
||||
export function safeReturnTo(value: unknown): string {
|
||||
return typeof value === "string" && /^\/(?:(?:anonymize|pull-request-anonymize|gist-anonymize)(?:\/[\w-]+)?|connections|dashboard)(?:\?[^\\\r\n]*)?$/.test(value) ? value : "/connections";
|
||||
}
|
||||
export function consumeFlow(flow: Flow | undefined, state: unknown): Flow {
|
||||
if (!flow || typeof state !== "string" || flow.state !== state || flow.expires < Date.now()) throw appError("invalid_auth_state", 400);
|
||||
return flow;
|
||||
}
|
||||
function newFlow(ownerId: string | undefined, returnTo: unknown): Flow {
|
||||
return { state: randomBytes(32).toString("hex"), expires: Date.now() + 10 * 60000, ownerId, returnTo: safeReturnTo(returnTo) };
|
||||
}
|
||||
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" });
|
||||
next();
|
||||
}
|
||||
export const githubAppRouter = express.Router();
|
||||
const router = githubAppRouter;
|
||||
router.use((_req, res, next) => { res.set("Cache-Control", "no-store"); next(); });
|
||||
|
||||
router.get("/app/login", enabled, async (req, res) => {
|
||||
try {
|
||||
const ownerId = req.isAuthenticated() ? (await getUser(req)).id : undefined;
|
||||
const flow = newFlow(ownerId, req.query.returnTo);
|
||||
flow.install = req.query.install === "1";
|
||||
if (typeof req.query.repository === "string" && /^[\w.-]+\/[\w.-]+$/.test(req.query.repository)) flow.repository = req.query.repository;
|
||||
req.session.githubAppFlow = flow;
|
||||
await saveSession(req);
|
||||
const url = new URL("https://github.com/login/oauth/authorize");
|
||||
url.searchParams.set("client_id", config.GITHUB_APP_CLIENT_ID);
|
||||
url.searchParams.set("redirect_uri", config.GITHUB_APP_CALLBACK);
|
||||
url.searchParams.set("state", flow.state);
|
||||
res.redirect(url.toString());
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
});
|
||||
|
||||
router.get("/app/callback", enabled, async (req, res) => {
|
||||
try {
|
||||
const pending = req.session.githubAppFlow;
|
||||
delete req.session.githubAppFlow;
|
||||
await saveSession(req);
|
||||
const flow = consumeFlow(pending, req.query.state);
|
||||
if (typeof req.query.code !== "string" || req.query.error) throw appError("github_app_authorization_cancelled", 400);
|
||||
const tokens = await exchangeAppToken({ code: req.query.code, redirect_uri: config.GITHUB_APP_CALLBACK });
|
||||
const profile = await githubRequest<{ id: number; login: string; avatar_url?: string }>("/user", tokens.access_token);
|
||||
if (!Number.isSafeInteger(profile.id) || !profile.login) throw appError();
|
||||
let user = await UserModel.findOne({ "externalIDs.github": String(profile.id) });
|
||||
if (flow.ownerId) {
|
||||
const current = await getUser(req);
|
||||
if (current.id !== flow.ownerId || current.model.externalIDs?.github !== String(profile.id) || !user || user.id !== current.id) {
|
||||
throw appError("github_identity_mismatch", 409);
|
||||
}
|
||||
} else if (req.isAuthenticated()) {
|
||||
if (!user || user.id !== (await getUser(req)).id) throw appError("github_identity_mismatch", 409);
|
||||
}
|
||||
if (!user) {
|
||||
// A matching login name alone is not proof of account ownership.
|
||||
if (await UserModel.exists({ username: profile.login })) throw appError("github_account_link_required", 409);
|
||||
user = new UserModel({ username: profile.login, externalIDs: { github: String(profile.id) }, photo: profile.avatar_url, emails: [] });
|
||||
await user.save();
|
||||
}
|
||||
if (isDisabledAccount(user.status)) throw appError("not_connected", 403);
|
||||
await saveAppGrant(user.id, tokens);
|
||||
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); }
|
||||
});
|
||||
|
||||
router.get("/app/install", enabled, async (req, res) => {
|
||||
try {
|
||||
const user = await getUser(req);
|
||||
await appUserToken(user.id); // Authorization and installation are separate.
|
||||
const flow = newFlow(user.id, req.query.returnTo);
|
||||
req.session.githubInstallFlow = flow;
|
||||
let target = installationURL();
|
||||
const installations = await userInstallations(user.id);
|
||||
if (typeof req.query.installationId === "string") {
|
||||
const installation = installations.find(i => String(i.id) === req.query.installationId);
|
||||
if (!installation) throw appError("github_app_access_required");
|
||||
target = installation.account.type === "Organization"
|
||||
? `https://github.com/organizations/${encodeURIComponent(installation.account.login)}/settings/installations/${installation.id}`
|
||||
: `https://github.com/settings/installations/${installation.id}`;
|
||||
} else if (typeof req.query.repository === "string" && /^[\w.-]+\/[\w.-]+$/.test(req.query.repository)) {
|
||||
// Existing OAuth grants can preselect a private repo during initial migration.
|
||||
const token = await getCredentialToken(user.id) || await appUserToken(user.id);
|
||||
try {
|
||||
const repo = await githubRequest<GitHubRepositoryInfo>(`/repos/${req.query.repository}`, token);
|
||||
const existing = installations.find(i => i.account.id === repo.owner.id);
|
||||
target = existing ? (existing.account.type === "Organization"
|
||||
? `https://github.com/organizations/${encodeURIComponent(existing.account.login)}/settings/installations/${existing.id}`
|
||||
: `https://github.com/settings/installations/${existing.id}`) : installationURL(repo.owner.id, [repo.id]);
|
||||
} catch { /* A new private repository may be invisible until installation. */ }
|
||||
}
|
||||
const url = new URL(target);
|
||||
url.searchParams.set("state", flow.state);
|
||||
await saveSession(req);
|
||||
res.redirect(url.toString());
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
});
|
||||
|
||||
router.get("/app/setup", enabled, async (req, res) => {
|
||||
try {
|
||||
const pending = req.session.githubInstallFlow;
|
||||
delete req.session.githubInstallFlow;
|
||||
await saveSession(req);
|
||||
if (!req.isAuthenticated()) return res.redirect("/github/app/login");
|
||||
const user = await getUser(req);
|
||||
// GitHub-initiated installs or configuration pages may not return state.
|
||||
// Without returned state, never attach anything based on installation_id.
|
||||
// A recent flow owned by this session is safe to use only for local navigation.
|
||||
if (!req.query.state) return res.redirect(pending?.ownerId === user.id && pending.expires > Date.now()
|
||||
? safeReturnTo(pending.returnTo) : "/connections");
|
||||
const flow = consumeFlow(pending, req.query.state);
|
||||
if (flow.ownerId !== user.id) throw appError("invalid_auth_state", 400);
|
||||
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 UserModel.updateOne({ _id: user.id }, { $set: { repositories: [] } });
|
||||
res.redirect(flow.returnTo);
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
});
|
||||
|
||||
router.get("/connections", async (req, res) => {
|
||||
try {
|
||||
const user = await getUser(req);
|
||||
req.session.githubConnectionCSRF ||= randomBytes(32).toString("hex");
|
||||
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);
|
||||
let installations: { id: number; account: string; suspended: boolean }[] = [];
|
||||
let appErrorCode: string | undefined;
|
||||
if (appConnected && config.GITHUB_APP_ENABLED) {
|
||||
try {
|
||||
const verified = await userInstallations(user.id);
|
||||
installations = verified.map(i => ({ id: i.id, account: i.account.login, suspended: !!i.suspended_at }));
|
||||
|
||||
}
|
||||
catch (error) { appErrorCode = error instanceof Error ? error.message : "github_app_reconnect_required"; }
|
||||
}
|
||||
const repos = await RepositoryModel.find({ owner: user.id, status: { $ne: "removed" } }).select("repoId source.repositoryName githubAccess status").lean();
|
||||
const prs = await PullRequestModel.find({ owner: user.id, status: { $ne: "removed" } }).select("pullRequestId source.repositoryFullName githubAccess status").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,
|
||||
oauthEnabled: config.GITHUB_OAUTH_ENABLED, oauthConnected: !!(await getCredentialToken(user.id)), appConnected, appError: appErrorCode,
|
||||
installations, gistCount, resources: [
|
||||
...repos.map(r => ({ type: "repository", id: r.repoId, name: r.source.repositoryName, connection: r.githubAccess?.kind || "oauth", status: r.status })),
|
||||
...prs.map(r => ({ type: "pull-request", id: r.pullRequestId, name: r.source.repositoryFullName, connection: r.githubAccess?.kind || "oauth", status: r.status })),
|
||||
] });
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
});
|
||||
|
||||
router.use("/connections", (req, res, next) => {
|
||||
if (req.method !== "GET" && (!req.session.githubConnectionCSRF || req.headers["x-csrf-token"] !== req.session.githubConnectionCSRF)) {
|
||||
return res.status(403).json({ error: "invalid_auth_state" });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
router.post("/connections/migrate", async (req, res) => {
|
||||
try {
|
||||
const user = await getUser(req);
|
||||
const { type, id, connection, preview } = req.body;
|
||||
if (typeof id !== "string" || !["repository", "pull-request"].includes(type) || !["oauth", "github-app"].includes(connection)) throw appError("invalid_connection", 400);
|
||||
if (connection === "github-app" && (!config.GITHUB_APP_ENABLED || !config.GITHUB_APP_NEW_CONNECTIONS)) throw appError("github_app_disabled", 503);
|
||||
const isRepo = type === "repository";
|
||||
const model = isRepo ? await RepositoryModel.findOne({ repoId: id, owner: user.id }) : await PullRequestModel.findOne({ pullRequestId: id, owner: user.id });
|
||||
if (!model || ["removed", "archived"].includes(model.status || "")) throw appError("repo_not_found", 404);
|
||||
if (["preparing", "removing", "expiring"].includes(model.status || "")) throw appError("repository_busy", 409);
|
||||
const source = model.source as { repositoryName?: string; repositoryFullName?: string; commit?: string; pullRequestId?: number };
|
||||
const name = source.repositoryName || source.repositoryFullName || "";
|
||||
const selected = await selectRepositoryAccess(user.id, name, connection);
|
||||
const parts = name.split("/").map(encodeURIComponent).join("/");
|
||||
await githubRequest(`/repos/${parts}/${isRepo ? `commits/${encodeURIComponent(source.commit || "")}` : `pulls/${source.pullRequestId}`}`, selected.token);
|
||||
if (preview === true) return res.json({ eligible: true, connection });
|
||||
const filter = { _id: model._id, owner: user.id, status: model.status, source: model.source,
|
||||
githubAccess: model.githubAccess ? model.githubAccess : { $exists: false } };
|
||||
const change = { $set: { githubAccess: selected.binding } };
|
||||
const result = isRepo ? await RepositoryModel.updateOne(filter, change) : await PullRequestModel.updateOne(filter, change);
|
||||
if (!result.modifiedCount) throw appError("connection_changed", 409);
|
||||
res.json({ connection });
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
});
|
||||
|
||||
export async function revokeGrant(ownerId: string, provider: "github" | "github-app-user") {
|
||||
const token = await getCredentialToken(ownerId, provider);
|
||||
const clientId = provider === "github" ? config.CLIENT_ID : config.GITHUB_APP_CLIENT_ID;
|
||||
const clientSecret = provider === "github" ? config.CLIENT_SECRET : config.GITHUB_APP_CLIENT_SECRET;
|
||||
if (token) {
|
||||
const response = await fetch(`https://api.github.com/applications/${clientId}/grant`, { method: "DELETE",
|
||||
headers: { Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`, "Content-Type": "application/json", Accept: "application/vnd.github+json" },
|
||||
body: JSON.stringify({ access_token: token }), signal: AbortSignal.timeout(20000) });
|
||||
if (!response.ok && response.status !== 404 && response.status !== 422) throw appError("github_grant_revocation_failed", 502);
|
||||
}
|
||||
await CredentialModel.deleteMany({ ownerId, provider });
|
||||
if (provider === "github") await UserModel.updateOne({ _id: ownerId }, { $unset: { "accessTokens.github": "", "accessTokenDates.github": "" } });
|
||||
await UserModel.updateOne({ _id: ownerId }, { $set: { repositories: [] } });
|
||||
}
|
||||
router.post("/connections/disconnect-oauth", async (req, res) => {
|
||||
try {
|
||||
const user = await getUser(req);
|
||||
if (!(await CredentialModel.exists({ ownerId: user.id, provider: APP_PROVIDER, revoked: { $ne: true } }))) throw appError("another_login_required", 409);
|
||||
await appUserToken(user.id);
|
||||
const active = { owner: user.id, status: { $ne: "removed" }, "githubAccess.kind": { $ne: "github-app" } };
|
||||
if (await RepositoryModel.exists(active) || await PullRequestModel.exists(active) || await GistModel.exists({ owner: user.id, status: { $ne: "removed" } })) {
|
||||
throw appError("oauth_resources_remaining", 409);
|
||||
}
|
||||
await revokeGrant(user.id, "github");
|
||||
res.json({ disconnected: true });
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
});
|
||||
|
||||
export function validWebhookSignature(body: Buffer, signature: unknown, secret: string): boolean {
|
||||
if (!secret || typeof signature !== "string" || !/^sha256=[a-f0-9]{64}$/.test(signature)) return false;
|
||||
const expected = createHmac("sha256", secret).update(body).digest();
|
||||
return timingSafeEqual(expected, Buffer.from(signature.slice(7), "hex"));
|
||||
}
|
||||
export const githubAppWebhook = express.Router();
|
||||
githubAppWebhook.post("/", express.raw({ type: "application/json", limit: "2mb" }), async (req, res) => {
|
||||
if (!config.GITHUB_APP_ENABLED || !Buffer.isBuffer(req.body) || !validWebhookSignature(req.body, req.headers["x-hub-signature-256"], config.GITHUB_APP_WEBHOOK_SECRET)) {
|
||||
return res.status(401).json({ error: "invalid_webhook_signature" });
|
||||
}
|
||||
try {
|
||||
const body = JSON.parse(req.body.toString("utf8"));
|
||||
const event = req.headers["x-github-event"];
|
||||
if (event === "github_app_authorization" && body.action === "revoked" && body.sender?.id) {
|
||||
const users = await UserModel.find({ "externalIDs.github": String(body.sender.id) }).select("_id").lean();
|
||||
for (const user of users) await reconcileAppGrant(String(user._id));
|
||||
}
|
||||
if (["installation", "installation_repositories"].includes(String(event)) && Number.isSafeInteger(body.installation?.id) && String(body.installation.app_id) === config.GITHUB_APP_ID) {
|
||||
const installation = body.installation as AppInstallation;
|
||||
// Fail closed immediately. Reconcile from GitHub, never trust event order
|
||||
// to reactivate an installation. Duplicate events are safe to replay.
|
||||
const filter = { appId: config.GITHUB_APP_ID, installationId: installation.id };
|
||||
const revision = randomUUID();
|
||||
await InstallationModel.updateOne(filter, { $set: { blocked: true, revision,
|
||||
reconciliationPending: body.action !== "deleted" } }, { upsert: true });
|
||||
clearAppTokenCache();
|
||||
if (body.action !== "deleted") await reconcileInstallation(installation.id, revision);
|
||||
}
|
||||
return res.status(204).end();
|
||||
} catch { return res.status(503).json({ error: "webhook_processing_failed" }); }
|
||||
});
|
||||
@@ -4,6 +4,8 @@ export const router = express.Router();
|
||||
|
||||
router.get("/", async (req: express.Request, res: express.Response) => {
|
||||
res.json({
|
||||
GITHUB_APP_ENABLED: config.GITHUB_APP_ENABLED && config.GITHUB_APP_NEW_CONNECTIONS,
|
||||
GITHUB_OAUTH_ENABLED: config.GITHUB_OAUTH_ENABLED,
|
||||
ENABLE_DOWNLOAD: config.ENABLE_DOWNLOAD,
|
||||
MAX_FILE_SIZE: config.MAX_FILE_SIZE,
|
||||
MAX_REPO_SIZE: config.MAX_REPO_SIZE,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { selectRepositoryAccess } from "../../core/github-app";
|
||||
import * as express from "express";
|
||||
import { ensureAuthenticated } from "./connection";
|
||||
|
||||
@@ -105,9 +106,11 @@ router.get(
|
||||
async (req, res) => {
|
||||
try {
|
||||
const user = await getUser(req);
|
||||
const access = await selectRepositoryAccess(user.id, `${req.params.owner}/${req.params.repository}`, req.query.connection);
|
||||
const pullRequest = new PullRequest(
|
||||
new AnonymizedPullRequestModel({
|
||||
owner: user.id,
|
||||
githubAccess: access.binding,
|
||||
source: {
|
||||
pullRequestId: parseInt(req.params.pullRequestId),
|
||||
repositoryFullName: `${req.params.owner}/${req.params.repository}`,
|
||||
@@ -116,7 +119,7 @@ router.get(
|
||||
);
|
||||
pullRequest.owner = user;
|
||||
await pullRequest.download();
|
||||
res.json(pullRequest.toJSON());
|
||||
res.json({ ...pullRequest.toJSON(), connection: pullRequest.model.githubAccess?.kind || "oauth" });
|
||||
} catch (error) {
|
||||
handleError(error, res, req);
|
||||
}
|
||||
@@ -133,7 +136,7 @@ router.get(
|
||||
|
||||
const user = await getUser(req);
|
||||
isOwnerOrAdmin([pullRequest.owner.id], user);
|
||||
res.json(pullRequest.toJSON());
|
||||
res.json({ ...pullRequest.toJSON(), connection: pullRequest.model.githubAccess?.kind || "oauth" });
|
||||
} catch (error) {
|
||||
handleError(error, res, req);
|
||||
}
|
||||
@@ -240,7 +243,7 @@ router.post(
|
||||
).exec();
|
||||
await pullRequest.updateStatus(RepositoryStatus.PREPARING);
|
||||
await pullRequest.updateIfNeeded({ force: true });
|
||||
res.json(pullRequest.toJSON());
|
||||
res.json({ ...pullRequest.toJSON(), connection: pullRequest.model.githubAccess?.kind || "oauth" });
|
||||
} catch (error) {
|
||||
return handleError(error, res, req);
|
||||
}
|
||||
@@ -264,6 +267,8 @@ router.post("/", async (req, res) => {
|
||||
pullRequest.model.pullRequestId = pullRequestUpdate.pullRequestId;
|
||||
pullRequest.model.anonymizeDate = new Date();
|
||||
pullRequest.model.owner = user.id;
|
||||
const access = await selectRepositoryAccess(user.id, pullRequestUpdate.source.repositoryFullName, pullRequestUpdate.connection);
|
||||
pullRequest.model.githubAccess = access.binding;
|
||||
|
||||
updatePullRequestModel(pullRequest.model, pullRequestUpdate);
|
||||
pullRequest.source.pullRequestId = pullRequestUpdate.source.pullRequestId;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getCredentialToken } from "../../core/credentials";
|
||||
import { randomUUID } from "crypto";
|
||||
import { githubQuotaKey } from "../../core/github-token-context";
|
||||
import { selectRepositoryAccess, boundAppToken, appError } from "../../core/github-app";
|
||||
import * as express from "express";
|
||||
import { ensureAuthenticated } from "./connection";
|
||||
|
||||
@@ -19,10 +21,9 @@ import ConferenceModel from "../../core/model/conference/conferences.model";
|
||||
import AnonymousError from "../../core/AnonymousError";
|
||||
import { addRemovalJob, downloadQueue } from "../../queue";
|
||||
import RepositoryModel from "../../core/model/repositories/repositories.model";
|
||||
import User from "../../core/User";
|
||||
import { RepositoryStatus } from "../../core/types";
|
||||
import { checkToken, octokit, getRedisGateResetAt, getToken } from "../../core/GitHubUtils";
|
||||
import { createLogger, serializeError } from "../../core/logger";
|
||||
import { octokit, getRedisGateResetAt, getToken } from "../../core/GitHubUtils";
|
||||
import { createLogger } from "../../core/logger";
|
||||
|
||||
const logger = createLogger("route:repo");
|
||||
|
||||
@@ -31,27 +32,15 @@ const router = express.Router();
|
||||
// user needs to be connected for all user API
|
||||
router.use(ensureAuthenticated);
|
||||
|
||||
async function getTokenForAdmin(user: User, req: express.Request) {
|
||||
if (user.isAdmin) {
|
||||
try {
|
||||
const existingRepo = await AnonymizedRepositoryModel.findOne(
|
||||
{
|
||||
"source.repositoryName": `${req.params.owner}/${req.params.repo}`,
|
||||
},
|
||||
{
|
||||
owner: 1,
|
||||
}
|
||||
);
|
||||
if (existingRepo?.owner) {
|
||||
const token = await getCredentialToken(String(existingRepo.owner), "github", {
|
||||
collection: "anonymizedrepositories", id: existingRepo._id,
|
||||
});
|
||||
if (token && await checkToken(token)) return token;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("getToken lookup failed", serializeError(error));
|
||||
}
|
||||
async function previewToken(req: express.Request) {
|
||||
const user = await getUser(req);
|
||||
if (typeof req.query.anonymizedRepoId === "string") {
|
||||
const resource = await db.getRepository(req.query.anonymizedRepoId);
|
||||
isOwnerCoauthorOrAdmin(resource, user);
|
||||
if (resource.model.source.repositoryName?.toLowerCase() !== `${req.params.owner}/${req.params.repo}`.toLowerCase()) throw appError("repo_not_found", 404);
|
||||
return getToken(resource);
|
||||
}
|
||||
return (await selectRepositoryAccess(user.id, `${req.params.owner}/${req.params.repo}`, req.query.connection)).token;
|
||||
}
|
||||
|
||||
// claim a repository
|
||||
@@ -86,11 +75,12 @@ router.post("/claim", async (req, res) => {
|
||||
httpStatus: 404,
|
||||
});
|
||||
}
|
||||
const selectedAccess = await selectRepositoryAccess(user.id, `${r.owner}/${r.name}`, req.body.connection);
|
||||
const repo = await getRepositoryFromGitHub({
|
||||
owner: r.owner,
|
||||
repo: r.name,
|
||||
repositoryID: req.query.repositoryID as string,
|
||||
accessToken: await user.getAccessToken(),
|
||||
accessToken: selectedAccess.token,
|
||||
});
|
||||
if (!repo) {
|
||||
throw new AnonymousError("repo_not_found", {
|
||||
@@ -118,7 +108,7 @@ router.post("/claim", async (req, res) => {
|
||||
|
||||
await AnonymizedRepositoryModel.updateOne(
|
||||
{ repoId: repoConfig.repoId },
|
||||
{ $set: { owner: user.model.id } }
|
||||
{ $set: { owner: user.model.id, githubAccess: selectedAccess.binding } }
|
||||
).collation({ locale: "en", strength: 2 });
|
||||
return res.send("Ok");
|
||||
} catch (error) {
|
||||
@@ -245,11 +235,7 @@ router.get(
|
||||
"/:owner/:repo/",
|
||||
async (req, res) => {
|
||||
try {
|
||||
const user = await getUser(req);
|
||||
let token = await user.getAccessToken();
|
||||
if (user.isAdmin) {
|
||||
token = (await getTokenForAdmin(user, req)) || token;
|
||||
}
|
||||
const token = await previewToken(req);
|
||||
const repo = await getRepositoryFromGitHub({
|
||||
owner: req.params.owner,
|
||||
repo: req.params.repo,
|
||||
@@ -268,11 +254,7 @@ router.get(
|
||||
"/:owner/:repo/branches",
|
||||
async (req, res) => {
|
||||
try {
|
||||
const user = await getUser(req);
|
||||
let token = await user.getAccessToken();
|
||||
if (user.isAdmin) {
|
||||
token = (await getTokenForAdmin(user, req)) || token;
|
||||
}
|
||||
const token = await previewToken(req);
|
||||
const repository = await getRepositoryFromGitHub({
|
||||
accessToken: token,
|
||||
owner: req.params.owner,
|
||||
@@ -296,11 +278,7 @@ router.get(
|
||||
"/:owner/:repo/readme",
|
||||
async (req, res) => {
|
||||
try {
|
||||
const user = await getUser(req);
|
||||
let token = await user.getAccessToken();
|
||||
if (user.isAdmin) {
|
||||
token = (await getTokenForAdmin(user, req)) || token;
|
||||
}
|
||||
const token = await previewToken(req);
|
||||
|
||||
const repo = await getRepositoryFromGitHub({
|
||||
owner: req.params.owner,
|
||||
@@ -347,8 +325,13 @@ router.get("/:repoId/", async (req, res) => {
|
||||
: fullRepo.owner.id === user.model.id
|
||||
? "owner"
|
||||
: "coauthor";
|
||||
const repoToken = await getToken(fullRepo);
|
||||
const gateResetAt = await getRedisGateResetAt(repoToken.slice(-8));
|
||||
json.connection = fullRepo.model.githubAccess?.kind || "oauth";
|
||||
// Connection diagnostics must remain available even when access is revoked.
|
||||
let gateResetAt = 0;
|
||||
try {
|
||||
const repoToken = await getToken(fullRepo);
|
||||
gateResetAt = await getRedisGateResetAt(githubQuotaKey(repoToken));
|
||||
} catch (error) { json.connectionError = error instanceof Error ? error.message : "github_app_reconnect_required"; }
|
||||
if (gateResetAt > 0) {
|
||||
json.rateLimitResetAt = gateResetAt;
|
||||
}
|
||||
@@ -493,6 +476,7 @@ router.post(
|
||||
// needed when the underlying snapshot moves. Other edits (e.g. turning
|
||||
// off auto-update — see #360) just persist and return.
|
||||
const sourceChanged = hasRepositorySourceChanged(repo.model, repoUpdate);
|
||||
const previousAccessRevision = repo.model.githubAccess?.revision;
|
||||
|
||||
updateRepoModel(repo.model, repoUpdate);
|
||||
const reactivating = shouldReactivateInactiveRepository(repo.model);
|
||||
@@ -504,32 +488,35 @@ router.post(
|
||||
if (sourceChanged) {
|
||||
const parsedRepository = gh(repoUpdate.fullName);
|
||||
if (!parsedRepository?.owner || !parsedRepository?.name) {
|
||||
await repo.resetSate(RepositoryStatus.ERROR, "repo_not_found");
|
||||
throw new AnonymousError("repo_not_found", {
|
||||
object: req.body,
|
||||
httpStatus: 404,
|
||||
});
|
||||
}
|
||||
if (repoUpdate.fullName !== repo.model.source.repositoryName && user.id !== repo.owner.id) throw appError("not_owner", 403);
|
||||
const sourceAccess = repo.model.githubAccess?.kind === "github-app" && repoUpdate.fullName === repo.model.source.repositoryName
|
||||
? { token: await boundAppToken(repo.owner.id, repo.model.githubAccess), binding: repo.model.githubAccess }
|
||||
: await selectRepositoryAccess(repo.owner.id, `${parsedRepository.owner}/${parsedRepository.name}`, repo.model.githubAccess?.kind || "oauth");
|
||||
const repository = await getRepositoryFromGitHub({
|
||||
accessToken: await user.getAccessToken(),
|
||||
accessToken: sourceAccess.token,
|
||||
owner: parsedRepository.owner,
|
||||
repo: parsedRepository.name,
|
||||
});
|
||||
if (!repository) {
|
||||
await repo.resetSate(RepositoryStatus.ERROR, "repo_not_found");
|
||||
throw new AnonymousError("repo_not_found", {
|
||||
object: req.body,
|
||||
httpStatus: 404,
|
||||
});
|
||||
}
|
||||
await repository.getCommitInfo(repoUpdate.source.commit, {
|
||||
accessToken: await user.getAccessToken(),
|
||||
accessToken: sourceAccess.token,
|
||||
});
|
||||
repo.model.githubAccess = { ...sourceAccess.binding, revision: randomUUID() };
|
||||
repo.model.source.repositoryId = repository.model.id;
|
||||
repo.model.source.repositoryName =
|
||||
repository.fullName || repoUpdate.fullName;
|
||||
repo.model.anonymizeDate = new Date();
|
||||
await repo.remove();
|
||||
await repo.remove({ accessRevision: previousAccessRevision });
|
||||
}
|
||||
|
||||
const removeRepoFromConference = async (conferenceID: string) => {
|
||||
@@ -581,17 +568,19 @@ router.post(
|
||||
}
|
||||
}
|
||||
repo.model.conference = repoUpdate.conference;
|
||||
await AnonymizedRepositoryModel.updateOne(
|
||||
{ _id: repo.model._id },
|
||||
const saved = await AnonymizedRepositoryModel.updateOne(
|
||||
{ _id: repo.model._id, "githubAccess.revision": previousAccessRevision || { $exists: false } },
|
||||
{
|
||||
$set: {
|
||||
options: repo.model.options,
|
||||
source: repo.model.source,
|
||||
githubAccess: repo.model.githubAccess,
|
||||
conference: repo.model.conference,
|
||||
anonymizeDate: repo.model.anonymizeDate,
|
||||
},
|
||||
}
|
||||
).exec();
|
||||
if (!saved.matchedCount) throw appError("connection_changed", 409);
|
||||
if (!sourceChanged && !reactivating) {
|
||||
return res.json({ status: repo.status });
|
||||
}
|
||||
@@ -637,8 +626,9 @@ router.post("/", async (req, res) => {
|
||||
httpStatus: 404,
|
||||
});
|
||||
}
|
||||
const selectedAccess = await selectRepositoryAccess(user.id, `${r.owner}/${r.name}`, repoUpdate.connection);
|
||||
const repository = await getRepositoryFromGitHub({
|
||||
accessToken: await user.getAccessToken(),
|
||||
accessToken: selectedAccess.token,
|
||||
owner: r.owner,
|
||||
repo: r.name,
|
||||
});
|
||||
@@ -651,13 +641,14 @@ router.post("/", async (req, res) => {
|
||||
}
|
||||
|
||||
await repository.getCommitInfo(repoUpdate.source.commit, {
|
||||
accessToken: await user.getAccessToken(),
|
||||
accessToken: selectedAccess.token,
|
||||
});
|
||||
|
||||
const repo = new AnonymizedRepositoryModel();
|
||||
repo.repoId = repoUpdate.repoId;
|
||||
repo.anonymizeDate = new Date();
|
||||
repo.owner = user.id;
|
||||
repo.githubAccess = selectedAccess.binding;
|
||||
|
||||
updateRepoModel(repo, repoUpdate);
|
||||
repo.source.type = "GitHubStream";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { revokeGrant } from "./github-app";
|
||||
import CredentialModel from "../../core/model/credentials/credentials.model";
|
||||
import * as express from "express";
|
||||
import got from "got";
|
||||
import config from "../../config";
|
||||
import { ensureAuthenticated } from "./connection";
|
||||
import { handleError, getUser, isOwnerOrAdmin } from "./route-utils";
|
||||
@@ -190,21 +190,10 @@ router.delete("/", async (req, res) => {
|
||||
).exec(),
|
||||
]);
|
||||
|
||||
// Revoke the OAuth grant so the application no longer appears in the
|
||||
// user's GitHub authorized applications. Best-effort: the account is
|
||||
// scrubbed even if GitHub rejects the revocation.
|
||||
try {
|
||||
await got.delete(
|
||||
`https://api.github.com/applications/${config.CLIENT_ID}/grant`,
|
||||
{
|
||||
username: config.CLIENT_ID,
|
||||
password: config.CLIENT_SECRET,
|
||||
headers: { accept: "application/vnd.github+json" },
|
||||
json: { access_token: await user.getAccessToken() },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn("oauth grant revocation failed", serializeError(error));
|
||||
// Removing one account must not uninstall a shared organization installation.
|
||||
for (const provider of ["github", "github-app-user"] as const) {
|
||||
try { await revokeGrant(user.id, provider); }
|
||||
catch (error) { logger.warn("grant revocation failed", serializeError(error)); }
|
||||
}
|
||||
|
||||
await CredentialModel.deleteMany({ ownerId: user.model._id });
|
||||
|
||||
Reference in New Issue
Block a user