mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-10-20 14:47:18 +02:00
fix: send one access alert per repository until owner action
This commit is contained in:
1 parent
c55efd14ed
commit
d519a0a9bb
19 files changed
+172
-116
No files matched your search
@@ -271,7 +271,7 @@ export default class AnonymizedFile {
|
||||
};
|
||||
if (typeof source.fetchFileInfoFromPath !== "function") return null;
|
||||
const recovered = await source.fetchFileInfoFromPath(this.anonymizedPath).catch(error => {
|
||||
void notifyOwnerAccessProblem(this.repository.owner.id, error);
|
||||
void notifyOwnerAccessProblem(this.repository.owner.id, error, { kind: "repository", id: String(this.repository.model._id) });
|
||||
throw error;
|
||||
});
|
||||
if (!recovered) return null;
|
||||
@@ -360,10 +360,10 @@ export default class AnonymizedFile {
|
||||
});
|
||||
}
|
||||
const content = await this.repository.source?.getFileContent(this).catch(error => {
|
||||
void notifyOwnerAccessProblem(this.repository.owner.id, 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); });
|
||||
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();
|
||||
@@ -405,7 +405,7 @@ export default class AnonymizedFile {
|
||||
});
|
||||
content.on("error", err => {
|
||||
const { error } = streamerErrorToAnonymous(err, { repoId: this.repository.repoId, filePath: this.anonymizedPath });
|
||||
void notifyOwnerAccessProblem(this.repository.owner.id, error);
|
||||
void notifyOwnerAccessProblem(this.repository.owner.id, error, { kind: "repository", id: String(this.repository.model._id) });
|
||||
});
|
||||
return content;
|
||||
}
|
||||
@@ -485,7 +485,7 @@ export default class AnonymizedFile {
|
||||
filePath: this.anonymizedPath,
|
||||
}
|
||||
);
|
||||
void notifyOwnerAccessProblem(this.repository.owner.id, error);
|
||||
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
-1
@@ -70,7 +70,7 @@ export default class Gist {
|
||||
async download() {
|
||||
try { await this.downloadWithAccess(); }
|
||||
catch (error) {
|
||||
void notifyOwnerAccessProblem(this.owner.id, error);
|
||||
void notifyOwnerAccessProblem(this.owner.id, error, { kind: "gist", id: String(this.model._id) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ export function octokit(token: string) {
|
||||
|
||||
export { waitForTokenGate };
|
||||
|
||||
export async function checkToken(token: string, ownerId?: string) {
|
||||
export async function checkToken(token: string) {
|
||||
const oct = octokit(token);
|
||||
try {
|
||||
const context = githubTokenContext(token);
|
||||
@@ -275,7 +275,6 @@ export async function checkToken(token: string, ownerId?: string) {
|
||||
// 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;
|
||||
if (ownerId) void notifyOwnerAccessProblem(ownerId, "token_expired");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -286,7 +285,7 @@ export async function getToken(repository: Repository) {
|
||||
try {
|
||||
return await measureStage("authorization", () => resolveRepositoryToken(repository));
|
||||
} catch (error) {
|
||||
void notifyOwnerAccessProblem(repository.owner.id, error);
|
||||
void notifyOwnerAccessProblem(repository.owner.id, error, { kind: "repository", id: String(repository.model._id) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -321,11 +320,12 @@ async function resolveRepositoryToken(repository: Repository) {
|
||||
return refreshed;
|
||||
}
|
||||
}
|
||||
const check = await checkToken(ownerAccessToken, repository.owner.id);
|
||||
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", {
|
||||
|
||||
@@ -36,7 +36,7 @@ export default class PullRequest {
|
||||
async download() {
|
||||
try { await this.downloadContent(); }
|
||||
catch (error) {
|
||||
void notifyOwnerAccessProblem(this.owner.id, error);
|
||||
void notifyOwnerAccessProblem(this.owner.id, error, { kind: "pull-request", id: String(this.model._id) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ export default class Repository {
|
||||
const streamed = this.model.source.type !== "Zip";
|
||||
const source = this.source;
|
||||
const files = await source.getFiles(opt.progress).catch(error => {
|
||||
void notifyOwnerAccessProblem(this.owner.id, error);
|
||||
void notifyOwnerAccessProblem(this.owner.id, error, { kind: "repository", id: String(this.model._id) });
|
||||
throw error;
|
||||
});
|
||||
const sourceWithTruncation = source as unknown as { truncatedFolderList?: string[] };
|
||||
@@ -630,7 +630,7 @@ export default class Repository {
|
||||
async updateIfNeeded(opt?: { force: boolean }): Promise<void> {
|
||||
try { await this.refreshIfNeeded(opt); }
|
||||
catch (error) {
|
||||
void notifyOwnerAccessProblem(this.owner.id, error);
|
||||
void notifyOwnerAccessProblem(this.owner.id, error, { kind: "repository", id: String(this.model._id) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { notifyOwnerAccessProblem } from "./owner-notifications";
|
||||
import { registerGitHubToken } from "./github-token-context";
|
||||
import { createSign, randomUUID } from "crypto";
|
||||
import { readFileSync } from "fs";
|
||||
@@ -88,14 +87,6 @@ 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();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Schema } from "mongoose";
|
||||
|
||||
const AnonymizedGistSchema = new Schema({
|
||||
accessAlertClaimedAt: { type: Date, select: false },
|
||||
gistId: {
|
||||
type: String,
|
||||
index: { unique: true },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { repositoryAccessSchema } from "../repository-access.schema";
|
||||
import { Schema } from "mongoose";
|
||||
|
||||
const AnonymizedPullRequestSchema = new Schema({
|
||||
accessAlertClaimedAt: { type: Date, select: false },
|
||||
pullRequestId: {
|
||||
type: String,
|
||||
index: { unique: true },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { repositoryAccessSchema } from "../repository-access.schema";
|
||||
import { Schema } from "mongoose";
|
||||
|
||||
const AnonymizedRepositorySchema = new Schema({
|
||||
accessAlertClaimedAt: { type: Date, select: false },
|
||||
reviewConsentRevision: { type: Number, select: false },
|
||||
repoId: {
|
||||
type: String,
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
import { 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 DAY = 24 * 60 * 60 * 1000;
|
||||
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 filter = { owner: new Types.ObjectId(ownerId),
|
||||
...(resource ? { _id: new Types.ObjectId(resource.id) } : {}) };
|
||||
for (const collection of resource ? [resourceCollections[resource.kind]] : Object.values(resourceCollections)) {
|
||||
await UserModel.db.collection(collection).updateMany(filter, { $unset: { accessAlertClaimedAt: "" } });
|
||||
}
|
||||
} catch { logger.warn("owner access alert reset unavailable"); }
|
||||
}
|
||||
const accessErrors = new Set([
|
||||
"token_expired", "github_oauth_required", "github_app_reconnect_required",
|
||||
"github_app_access_required", "repo_not_found", "repository_not_found",
|
||||
@@ -44,23 +62,23 @@ export function isAccessFailure(error: unknown): boolean {
|
||||
}
|
||||
|
||||
/** Best effort; mail and database failures must not change repository behavior. */
|
||||
export async function notifyOwnerAccessProblem(ownerId: string, error: unknown): Promise<void> {
|
||||
export async function notifyOwnerAccessProblem(ownerId: string, error: unknown, resource: AlertResource): 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"] },
|
||||
$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 (!resource || !Types.ObjectId.isValid(resource.id) || !Types.ObjectId.isValid(ownerId)) 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;
|
||||
// Claim the resource atomically before delivery. This has no expiry: neither
|
||||
// repeated readers nor workers can send again until the owner acts.
|
||||
const claim = await UserModel.db.collection(resourceCollections[resource.kind]).updateOne({
|
||||
_id: new Types.ObjectId(resource.id), owner: new Types.ObjectId(ownerId),
|
||||
accessAlertClaimedAt: { $exists: false },
|
||||
}, { $set: { accessAlertClaimedAt: new Date() } });
|
||||
if (!claim.modifiedCount) 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",
|
||||
@@ -72,13 +90,10 @@ 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`,
|
||||
}),
|
||||
});
|
||||
rejected = !response.ok;
|
||||
if (rejected) throw new Error("email_delivery_failed");
|
||||
if (!response.ok) throw new Error("email_delivery_failed");
|
||||
} catch {
|
||||
// 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) } });
|
||||
// Keep the claim even on failure: delivery can be ambiguous and no
|
||||
// automatic retry may send another email before owner action.
|
||||
logger.warn("owner access email delivery failed");
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -143,7 +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);
|
||||
await notifyOwnerAccessProblem(repo.owner.id, error, { kind: "repository", id: String(repo.model._id) });
|
||||
logger.error("finished with error", {
|
||||
...serializeError(error),
|
||||
repoId: job.data.repoId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resetOwnerAccessAlerts } from "../../core/owner-notifications";
|
||||
import { createClient } from "redis";
|
||||
import * as passport from "passport";
|
||||
import * as session from "express-session";
|
||||
@@ -120,6 +121,7 @@ export const verify = async (
|
||||
return;
|
||||
}
|
||||
await setCredential(String(user!._id), accessToken);
|
||||
await resetOwnerAccessAlerts(String(user!._id));
|
||||
done(null, { username: user!.username, user });
|
||||
} catch (error) {
|
||||
logger.error("verify failed", serializeError(error));
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { notificationEmail, normalizeEmail } from "../../core/owner-notifications";
|
||||
import { notificationEmail, normalizeEmail, resetOwnerAccessAlerts } from "../../core/owner-notifications";
|
||||
import * as express from "express";
|
||||
import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "crypto";
|
||||
import config from "../../config";
|
||||
@@ -94,6 +94,7 @@ router.get("/app/callback", enabled, async (req, res) => {
|
||||
}
|
||||
if (isDisabledAccount(user.status)) throw appError("not_connected", 403);
|
||||
await saveAppGrant(user.id, tokens);
|
||||
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); }
|
||||
@@ -251,6 +252,7 @@ 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);
|
||||
await resetOwnerAccessAlerts(user.id, { kind: isRepo ? "repository" : "pull-request", id: String(model._id) });
|
||||
res.json({ connection });
|
||||
} catch (error) { handleError(error, res, req); }
|
||||
});
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
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";
|
||||
@@ -146,6 +147,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) {
|
||||
@@ -621,6 +623,7 @@ router.post(
|
||||
}
|
||||
).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 });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user