mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-15 00:37:33 +01:00
feat: notify owners when repositories expire
This commit is contained in:
1 parent
511dcd7067
commit
104af7d261
5 files changed
+142
-2
No files matched your search
@@ -139,9 +139,16 @@ the [MongoDB replication guide](docs/mongodb-replication.md).
|
||||
### Owner email alerts
|
||||
|
||||
Set `RESEND_API_KEY` and `EMAIL_FROM` on the web server and download workers to
|
||||
send access alerts through [Resend](https://resend.com/docs/api-reference/emails/send-email).
|
||||
send access and repository expiration alerts through [Resend](https://resend.com/docs/api-reference/emails/send-email).
|
||||
Use a sender address on a domain verified in Resend. `APP_HOSTNAME` sets the
|
||||
host used for the connections link in emails.
|
||||
host used for links in emails.
|
||||
|
||||
After repository expiration completes, the owner receives an email with the
|
||||
anonymous repository name, expiration date, and a dashboard link. This uses the
|
||||
owner's alert address, falling back to their saved email. Each repository's
|
||||
expiration date gets one delivery attempt across workers. Failed or ambiguous
|
||||
deliveries retain the guard to prevent duplicate emails and are logged without
|
||||
email addresses. Existing expired repositories are not scanned for notifications.
|
||||
|
||||
Registration saves a verified GitHub email address, preferring the primary address.
|
||||
OAuth requests `user:email`; GitHub App sign-in uses the read-only email permission
|
||||
|
||||
@@ -4,6 +4,7 @@ import { hasAutomaticUpdates } from "./billing/expiration";
|
||||
import { FILE_TREE_BATCH_SIZE, FILE_LISTING_LIMIT, deleteRowsInBatches, clearStagedRowsInBatches } from "./file-tree";
|
||||
import { SourceBase, sourceStorageSize } from "./source/Source";
|
||||
import { notifyOwnerAccessProblem, withOwnerAccessRecovery } from "./owner-notifications";
|
||||
import { notifyRepositoryExpired } from "./repository-expiration-email";
|
||||
import AnonymizedPathModel from "./model/anonymized-path";
|
||||
import storage from "./storage";
|
||||
import { contentGenerationPrefix, contentRetirementMarker } from "./content-generation";
|
||||
@@ -1045,6 +1046,7 @@ export default class Repository {
|
||||
statusDate: new Date(), isReseted: true, size: { storage: 0, file: 0 } },
|
||||
$unset: { cleanupToken: "", cleanupUntil: "", pathIndexKey: "", pathIndexBuiltAt: "", sizeComputedAt: "", emptyTreeGeneration: "", retiredTreeGenerations: "", retiredPathIndexKeys: "", retiredContentPrefixes: "", legacyContentCleanupPending: "", stagedFileTrees: "" } }).exec();
|
||||
if (result.matchedCount) { this.model.status = RepositoryStatus.EXPIRED; this.model.isReseted = true; this.model.size = { storage: 0, file: 0 }; this.model.sizeComputedAt = undefined; this.model.emptyTreeGeneration = undefined; }
|
||||
if (result.matchedCount) await notifyRepositoryExpired(this.model._id);
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
await AnonymizedRepositoryModel.updateOne({ _id: this.model._id, cleanupToken: token }, { $unset: { cleanupToken: "", cleanupUntil: "" } }).exec();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Schema } from "mongoose";
|
||||
const AnonymizedRepositorySchema = new Schema({
|
||||
billingManaged: Boolean,
|
||||
billingNeverExpiresUntil: Date,
|
||||
expirationEmailClaimedFor: { type: Date, select: false },
|
||||
accessAlertGeneration: { type: String, select: false },
|
||||
accessAlertClaimedAt: { type: Date, select: false },
|
||||
accessAlertAttemptId: { type: String, select: false },
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import config from "../config";
|
||||
import RepositoryModel from "./model/anonymizedRepositories/anonymizedRepositories.model";
|
||||
import UserModel from "./model/users/users.model";
|
||||
import { notificationEmail } from "./owner-notifications";
|
||||
import { createLogger } from "./logger";
|
||||
import { RepositoryStatus } from "./types";
|
||||
|
||||
const logger = createLogger("repository-expiration-email");
|
||||
|
||||
/** Attempt delivery once per repository expiration date, across all workers. */
|
||||
export async function notifyRepositoryExpired(id: unknown): Promise<void> {
|
||||
if (!config.RESEND_API_KEY || !config.EMAIL_FROM) return;
|
||||
try {
|
||||
const repo = await RepositoryModel.findOne({ _id: id, status: RepositoryStatus.EXPIRED }).lean();
|
||||
const expirationDate = repo?.options.expirationDate;
|
||||
if (!repo || repo.options.expirationMode === "never" || !expirationDate || expirationDate > new Date()) return;
|
||||
const owner = await UserModel.findOne({ _id: repo.owner, status: { $nin: ["removed", "banned"] } }).lean();
|
||||
const to = owner && 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("/dashboard", base).href;
|
||||
const claim = await RepositoryModel.updateOne({
|
||||
_id: repo._id, owner: repo.owner, status: RepositoryStatus.EXPIRED, statusDate: repo.statusDate,
|
||||
"options.expirationMode": repo.options.expirationMode,
|
||||
"options.expirationDate": expirationDate,
|
||||
expirationEmailClaimedFor: { $ne: expirationDate },
|
||||
}, { $set: { expirationEmailClaimedFor: expirationDate } }).exec();
|
||||
if (!claim.matchedCount) return;
|
||||
// Keep the claim even after a timeout: the provider may have accepted the email.
|
||||
const response = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${config.RESEND_API_KEY}`, "Content-Type": "application/json",
|
||||
"Idempotency-Key": `repository-expired/${repo._id}/${expirationDate.getTime()}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
body: JSON.stringify({
|
||||
from: config.EMAIL_FROM, to: [to], subject: "Your Anonymous GitHub repository has expired",
|
||||
text: `Your anonymized repository "${repo.repoId}" expired on ${expirationDate.toISOString().slice(0, 10)} (UTC).\n\nIts anonymized content is no longer available.${repo.options.expirationMode === "redirect" ? " Its link now redirects to the original repository." : ""}\n\nOpen your dashboard to manage your repositories:\n${url}\n\nYou are receiving this email because you own this repository on Anonymous GitHub. You can change your alert address on the connections page.\n`,
|
||||
}),
|
||||
});
|
||||
if (response.ok) logger.info("repository expiration email accepted", { repoId: repo.repoId });
|
||||
else logger.warn("repository expiration email delivery failed", { repoId: repo.repoId, httpStatus: response.status });
|
||||
} catch {
|
||||
// Email delivery must not interrupt expiration or expose addresses in logs.
|
||||
logger.warn("repository expiration email unavailable");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
const { expect } = require("chai");
|
||||
require("ts-node/register/transpile-only");
|
||||
const config = require("../src/config").default;
|
||||
const Repo = require("../src/core/model/anonymizedRepositories/anonymizedRepositories.model").default;
|
||||
const Users = require("../src/core/model/users/users.model").default;
|
||||
const { notifyRepositoryExpired } = require("../src/core/repository-expiration-email");
|
||||
|
||||
describe("repository expiration email", function () {
|
||||
const restores = [];
|
||||
const stub = (object, key, value) => { const old = object[key]; restores.push(() => object[key] = old); object[key] = value; };
|
||||
let repo, owner, calls, claims, claimedFor;
|
||||
beforeEach(() => {
|
||||
repo = { _id: "repo-object-id", repoId: "anonymous-name", owner: "owner-id", statusDate: new Date(),
|
||||
options: { expirationMode: "remove", expirationDate: new Date("2025-01-01") } };
|
||||
owner = { emails: [{ email: "default@example.com", default: true }], notificationEmail: "preferred@example.com" };
|
||||
calls = []; claims = []; claimedFor = undefined;
|
||||
stub(config, "RESEND_API_KEY", "test-key"); stub(config, "EMAIL_FROM", "alerts@example.com");
|
||||
stub(config, "APP_HOSTNAME", "https://anonymous.example.com");
|
||||
stub(Repo, "findOne", filter => {
|
||||
expect(filter.status).to.equal("expired");
|
||||
return { lean: async () => repo };
|
||||
});
|
||||
stub(Users, "findOne", filter => {
|
||||
expect(filter.status.$nin).to.deep.equal(["removed", "banned"]);
|
||||
return { lean: async () => owner };
|
||||
});
|
||||
stub(Repo, "updateOne", (filter, update) => ({ exec: async () => {
|
||||
claims.push(filter);
|
||||
const date = update.$set.expirationEmailClaimedFor.getTime();
|
||||
if (claimedFor === date) return { matchedCount: 0 };
|
||||
claimedFor = date;
|
||||
return { matchedCount: 1 };
|
||||
} }));
|
||||
stub(global, "fetch", async (url, options) => { calls.push({ url, options }); return { ok: true }; });
|
||||
});
|
||||
afterEach(() => { while (restores.length) restores.pop()(); });
|
||||
|
||||
it("sends the preferred address an expiration notice with a dashboard link", async () => {
|
||||
await notifyRepositoryExpired(repo._id);
|
||||
const body = JSON.parse(calls[0].options.body);
|
||||
expect(body.to).to.deep.equal(["preferred@example.com"]);
|
||||
expect(body.text).to.include('"anonymous-name" expired on 2025-01-01');
|
||||
expect(body.text).to.include("https://anonymous.example.com/dashboard");
|
||||
expect(body.text).not.to.include("redirects");
|
||||
expect(claims[0]).to.include({ owner: repo.owner, status: "expired", statusDate: repo.statusDate });
|
||||
expect(claims[0]["options.expirationDate"]).to.equal(repo.options.expirationDate);
|
||||
});
|
||||
it("explains redirect expiration", async () => {
|
||||
repo.options.expirationMode = "redirect";
|
||||
await notifyRepositoryExpired(repo._id);
|
||||
expect(JSON.parse(calls[0].options.body).text).to.include("redirects to the original repository");
|
||||
});
|
||||
it("claims concurrent notifications once, and allows a later expiration date", async () => {
|
||||
await Promise.all([notifyRepositoryExpired(repo._id), notifyRepositoryExpired(repo._id)]);
|
||||
expect(calls).to.have.length(1);
|
||||
repo.options.expirationDate = new Date("2025-02-01");
|
||||
await notifyRepositoryExpired(repo._id);
|
||||
expect(calls).to.have.length(2);
|
||||
});
|
||||
for (const scenario of ["disabled", "missing-repo", "never", "future", "missing-owner", "missing-email"]) {
|
||||
it(`skips ${scenario} without claiming delivery`, async () => {
|
||||
if (scenario === "disabled") config.RESEND_API_KEY = "";
|
||||
if (scenario === "missing-repo") repo = null;
|
||||
if (scenario === "never") repo.options.expirationMode = "never";
|
||||
if (scenario === "future") repo.options.expirationDate = new Date(Date.now() + 86400000);
|
||||
if (scenario === "missing-owner") owner = null;
|
||||
if (scenario === "missing-email") owner = {};
|
||||
await notifyRepositoryExpired("repo-object-id");
|
||||
expect(calls).to.have.length(0); expect(claims).to.have.length(0);
|
||||
});
|
||||
}
|
||||
it("does not send when the repository changed before the claim", async () => {
|
||||
stub(Repo, "updateOne", () => ({ exec: async () => ({ matchedCount: 0 }) }));
|
||||
await notifyRepositoryExpired(repo._id);
|
||||
expect(calls).to.have.length(0);
|
||||
});
|
||||
for (const failure of ["timeout", "rejection"]) it(`retains the guard on provider ${failure} without interrupting expiration`, async () => {
|
||||
let attempts = 0;
|
||||
stub(global, "fetch", async () => { attempts++; if (failure === "timeout") throw Error("timeout"); return { ok: false, status: 503 }; });
|
||||
await notifyRepositoryExpired(repo._id);
|
||||
await notifyRepositoryExpired(repo._id);
|
||||
expect(attempts).to.equal(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user