mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-10-19 06:07:21 +02:00
feat: notify owners when GitHub access needs attention
This commit is contained in:
1 parent
f92cfbe795
commit
95f8aef30a
21 files changed
+548
-38
No files matched your search
@@ -2,6 +2,8 @@ import { resolve } from "path";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
interface Config {
|
||||
RESEND_API_KEY: string;
|
||||
EMAIL_FROM: string;
|
||||
GITHUB_APP_ENABLED: boolean;
|
||||
GITHUB_APP_NEW_CONNECTIONS: boolean;
|
||||
GITHUB_OAUTH_ENABLED: boolean;
|
||||
@@ -67,6 +69,8 @@ interface Config {
|
||||
RATE_LIMIT: number;
|
||||
}
|
||||
const config: Config = {
|
||||
RESEND_API_KEY: "",
|
||||
EMAIL_FROM: "",
|
||||
GITHUB_APP_ENABLED: false,
|
||||
GITHUB_APP_NEW_CONNECTIONS: true,
|
||||
GITHUB_OAUTH_ENABLED: true,
|
||||
|
||||
+12
-3
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -289,7 +290,7 @@ export function octokit(token: string) {
|
||||
|
||||
export { waitForTokenGate };
|
||||
|
||||
export async function checkToken(token: string) {
|
||||
export async function checkToken(token: string, ownerId?: string) {
|
||||
const oct = octokit(token);
|
||||
try {
|
||||
const context = githubTokenContext(token);
|
||||
@@ -304,6 +305,9 @@ export async function checkToken(token: string) {
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
if (ownerId && (err as { status?: number }).status === 401) {
|
||||
void notifyOwnerAccessProblem(ownerId, "token_expired");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -311,7 +315,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);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRepositoryToken(repository: Repository) {
|
||||
@@ -344,7 +353,7 @@ async function resolveRepositoryToken(repository: Repository) {
|
||||
return refreshed;
|
||||
}
|
||||
}
|
||||
const check = await checkToken(ownerAccessToken);
|
||||
const check = await checkToken(ownerAccessToken, repository.owner.id);
|
||||
if (check) {
|
||||
checkedRepositoryTokens.set(repository, ownerAccessToken);
|
||||
return ownerAccessToken;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyOwnerAccessProblem } from "./owner-notifications";
|
||||
import AnonymizedPathModel from "./model/anonymized-path";
|
||||
import storage from "./storage";
|
||||
import { contentGenerationPrefix, contentRetirementMarker } from "./content-generation";
|
||||
@@ -223,7 +224,10 @@ export default class Repository {
|
||||
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 files = await source.getFiles(opt.progress).catch(error => {
|
||||
void notifyOwnerAccessProblem(this.owner.id, error);
|
||||
throw error;
|
||||
});
|
||||
const sourceWithTruncation = source as unknown as { truncatedFolderList?: string[] };
|
||||
const truncatedFolders = Array.isArray(sourceWithTruncation.truncatedFolderList)
|
||||
? [...sourceWithTruncation.truncatedFolderList] : [];
|
||||
@@ -654,6 +658,9 @@ export default class Repository {
|
||||
repo: this.source.data.repoName,
|
||||
repositoryID: this.model.source.repositoryId,
|
||||
force: true,
|
||||
}).catch(error => {
|
||||
void notifyOwnerAccessProblem(this.owner.id, error);
|
||||
throw error;
|
||||
});
|
||||
|
||||
// update the repository name if it has changed. Persist it
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyOwnerAccessProblem } from "./owner-notifications";
|
||||
import { registerGitHubToken } from "./github-token-context";
|
||||
import { createSign, randomUUID } from "crypto";
|
||||
import { readFileSync } from "fs";
|
||||
@@ -87,6 +88,14 @@ export async function saveAppGrant(ownerId: string, data: AppTokenResponse) {
|
||||
}
|
||||
|
||||
export async function appUserToken(ownerId: string): Promise<string> {
|
||||
try { return await resolveAppUserToken(ownerId); }
|
||||
catch (error) {
|
||||
void notifyOwnerAccessProblem(ownerId, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAppUserToken(ownerId: string): Promise<string> {
|
||||
if (!config.GITHUB_APP_ENABLED) throw appError("github_app_disabled", 503);
|
||||
const user = await UserModel.findById(ownerId).select("status externalIDs").lean();
|
||||
if (!user || user.status === "removed" || user.status === "banned") throw appError();
|
||||
|
||||
@@ -15,6 +15,8 @@ const UserSchema = new Schema({
|
||||
type: String,
|
||||
index: { unique: true },
|
||||
},
|
||||
emailPromptNever: { type: Boolean, default: false },
|
||||
accessAlertAfter: { type: Date, select: false },
|
||||
emails: [
|
||||
{
|
||||
email: { type: String },
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface IUser {
|
||||
createdAt?: Date;
|
||||
lastUsedAt?: Date;
|
||||
}[];
|
||||
emailPromptNever?: boolean;
|
||||
emails: {
|
||||
email: string;
|
||||
default: boolean;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import config from "../config";
|
||||
import UserModel from "./model/users/users.model";
|
||||
import { createLogger } from "./logger";
|
||||
|
||||
const logger = createLogger("owner-notifications");
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const accessErrors = new Set([
|
||||
"token_expired", "github_oauth_required", "github_app_reconnect_required",
|
||||
"github_app_access_required", "repo_not_found", "repository_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 }[]): string {
|
||||
return normalizeEmail(emails?.find(e => e.default)?.email) ||
|
||||
emails?.map(e => normalizeEmail(e.email)).find(Boolean) || "";
|
||||
}
|
||||
|
||||
export function isAccessFailure(error: unknown): boolean {
|
||||
const code = typeof error === "string" ? error : error instanceof Error ? error.message : "";
|
||||
if (!accessErrors.has(code)) return false;
|
||||
if (error instanceof Error) {
|
||||
const upstream = (error as Error & { cause?: { status?: number; httpStatus?: 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 (upstream && ![401, 403, 404].includes(upstream.status || 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): Promise<void> {
|
||||
if (!config.RESEND_API_KEY || !config.EMAIL_FROM || !isAccessFailure(error)) return;
|
||||
try {
|
||||
const now = new Date();
|
||||
// The atomic claim limits all repositories and workers to one email per owner
|
||||
// per day. Claim before sending so concurrent requests cannot flood an inbox.
|
||||
const owner = await UserModel.findOneAndUpdate({
|
||||
_id: ownerId, status: { $nin: ["removed", "banned"] },
|
||||
"emails.0": { $exists: true },
|
||||
$or: [{ accessAlertAfter: { $exists: false } }, { accessAlertAfter: { $lte: now } }],
|
||||
}, { $set: { accessAlertAfter: new Date(now.getTime() + DAY) } }, { new: true }).lean();
|
||||
if (!owner) return;
|
||||
const to = notificationEmail(owner.emails);
|
||||
if (!to) return;
|
||||
const base = /^https?:\/\//.test(config.APP_HOSTNAME) ? config.APP_HOSTNAME : `https://${config.APP_HOSTNAME}`;
|
||||
const url = new URL("/connections", base).href;
|
||||
try {
|
||||
const response = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${config.RESEND_API_KEY}`, "Content-Type": "application/json" },
|
||||
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`,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error("email_delivery_failed");
|
||||
} catch {
|
||||
// Permit retry on a later access failure, with a short backoff for outages.
|
||||
await UserModel.updateOne({ _id: ownerId, accessAlertAfter: new Date(now.getTime() + DAY) },
|
||||
{ $set: { accessAlertAfter: new Date(now.getTime() + 10 * 60000) } });
|
||||
logger.warn("owner access email delivery failed");
|
||||
}
|
||||
} catch {
|
||||
// Do not log provider errors, which can contain credentials or addresses.
|
||||
logger.warn("owner access email unavailable");
|
||||
}
|
||||
}
|
||||
@@ -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]")
|
||||
|
||||
@@ -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);
|
||||
logger.error("finished with error", {
|
||||
...serializeError(error),
|
||||
repoId: job.data.repoId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notificationEmail, normalizeEmail } from "../../core/owner-notifications";
|
||||
import * as express from "express";
|
||||
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
|
||||
import config from "../../config";
|
||||
@@ -16,7 +17,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");
|
||||
@@ -167,7 +168,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), 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 +186,24 @@ router.use("/connections", (req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
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: { emails: [{ email, default: true }] } });
|
||||
res.json({ notificationEmail: email });
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
});
|
||||
|
||||
router.post("/connections/migrate", async (req, res) => {
|
||||
try {
|
||||
const user = await getUser(req);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { notificationEmail } from "../../core/owner-notifications";
|
||||
import DashboardName from "../../core/model/dashboard-name";
|
||||
import { dashboardSummary } from "./dashboard-summary";
|
||||
import { revokeGrant } from "./github-app";
|
||||
@@ -40,7 +42,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.emailPromptNever && !req.session.emailPromptDismissed;
|
||||
if (showEmailPrompt) {
|
||||
req.session.githubConnectionCSRF ||= randomBytes(32).toString("hex");
|
||||
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),
|
||||
emailNotificationsEnabled: !!(config.RESEND_API_KEY && config.EMAIL_FROM),
|
||||
username: user.username,
|
||||
photo: user.photo,
|
||||
isAdmin: user.isAdmin,
|
||||
|
||||
Reference in new issue
Block a user