fix: address owner notification review findings

This commit is contained in:
Thomas Durieux committed 2026-10-09 05:42:19 +00:00
1 parent 95f8aef30a
commit 85b9a50d4d
9 files changed
+130 -21

No files matched your search

+9
View File
@@ -1,3 +1,4 @@
import { notifyOwnerAccessProblem } from "./owner-notifications";
import { registerGitHubToken } from "./github-token-context";
import { isConnected } from "../server/database";
import { expireEmbeddedContent } from "./content-expiration";
@@ -67,6 +68,14 @@ export default class Gist {
}
async download() {
try { await this.downloadWithAccess(); }
catch (error) {
void notifyOwnerAccessProblem(this.owner.id, error);
throw error;
}
}
private async downloadWithAccess() {
const access = await this.getAccess();
try {
await this.downloadContent(access.token);
+9
View File
@@ -1,3 +1,4 @@
import { notifyOwnerAccessProblem } from "./owner-notifications";
import { isConnected } from "../server/database";
import { expireEmbeddedContent } from "./content-expiration";
import { boundAppToken } from "./github-app";
@@ -33,6 +34,14 @@ export default class PullRequest {
}
async download() {
try { await this.downloadContent(); }
catch (error) {
void notifyOwnerAccessProblem(this.owner.id, error);
throw error;
}
}
private async downloadContent() {
logger.info("downloading pull request", {
pullRequestId: this._model.source.pullRequestId,
});
+1
View File
@@ -15,6 +15,7 @@ const UserSchema = new Schema({
type: String,
index: { unique: true },
},
notificationEmail: { type: String },
emailPromptNever: { type: Boolean, default: false },
accessAlertAfter: { type: Date, select: false },
emails: [
+1
View File
@@ -20,6 +20,7 @@ export interface IUser {
createdAt?: Date;
lastUsedAt?: Date;
}[];
notificationEmail?: string;
emailPromptNever?: boolean;
emails: {
email: string;
+13 -9
View File
@@ -7,7 +7,7 @@ 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",
"pull_request_not_found", "gist_not_found", "repo_not_accessible", "repo_access_limited", "repo_saml_enforcement",
]);
export function normalizeEmail(value: unknown): string | null {
@@ -20,14 +20,15 @@ export function normalizeEmail(value: unknown): string | null {
return email;
}
export function notificationEmail(emails?: { email: string; default: boolean }[]): string {
return normalizeEmail(emails?.find(e => e.default)?.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 {
const code = typeof error === "string" ? error : error instanceof Error ? error.message : "";
if (!accessErrors.has(code)) return false;
const rawStatus = error instanceof Error ? (error as Error & { status?: number }).status : undefined;
if (!accessErrors.has(code) && ![401, 404].includes(rawStatus || 0)) 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.
@@ -48,14 +49,15 @@ export async function notifyOwnerAccessProblem(ownerId: string, error: unknown):
// 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 },
$and: [{ $or: [{ "emails.0": { $exists: true } }, { notificationEmail: { $exists: true, $ne: "" } }] }],
$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);
const to = notificationEmail(owner.emails, owner.notificationEmail);
if (!to) return;
const base = /^https?:\/\//.test(config.APP_HOSTNAME) ? config.APP_HOSTNAME : `https://${config.APP_HOSTNAME}`;
const url = new URL("/connections", base).href;
let rejected = false;
try {
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
@@ -67,10 +69,12 @@ export async function notifyOwnerAccessProblem(ownerId: string, error: unknown):
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");
rejected = !response.ok;
if (rejected) 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) },
// Only a definite rejection may retry early. A timeout can occur after
// Resend accepted the message, so retain the daily claim in that case.
if (rejected) 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");
}
+9 -3
View File
@@ -29,6 +29,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" });
@@ -151,7 +157,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);
@@ -168,7 +174,7 @@ 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({ notificationEmail: notificationEmail(user.model.emails), emailNotificationsEnabled: !!(config.RESEND_API_KEY && config.EMAIL_FROM),
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,
@@ -199,7 +205,7 @@ router.post("/connections/email", async (req, res) => {
}
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 }] } });
await UserModel.updateOne({ _id: user.id }, { $set: { notificationEmail: email } });
res.json({ notificationEmail: email });
} catch (error) { handleError(error, res, req); }
});
+7 -5
View File
@@ -1,8 +1,7 @@
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";
import { connectionCSRF, revokeGrant } from "./github-app";
import CredentialModel from "../../core/model/credentials/credentials.model";
import * as express from "express";
import config from "../../config";
@@ -43,16 +42,16 @@ 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;
!notificationEmail(user.model.emails, user.model.notificationEmail) && !user.model.emailPromptNever && !req.session.emailPromptDismissed;
if (showEmailPrompt) {
req.session.githubConnectionCSRF ||= randomBytes(32).toString("hex");
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),
notificationEmail: notificationEmail(user.model.emails, user.model.notificationEmail),
emailNotificationsEnabled: !!(config.RESEND_API_KEY && config.EMAIL_FROM),
username: user.username,
photo: user.photo,
@@ -239,6 +238,9 @@ router.delete("/", async (req, res) => {
repositories: [],
},
$unset: {
notificationEmail: "",
emailPromptNever: "",
accessAlertAfter: "",
accessTokens: "",
accessTokenDates: "",
externalIDs: "",