diff --git a/backend/prisma/migrations/20260727225051_add_profile_report_expiry/migration.sql b/backend/prisma/migrations/20260727225051_add_profile_report_expiry/migration.sql new file mode 100644 index 00000000..27b87ec2 --- /dev/null +++ b/backend/prisma/migrations/20260727225051_add_profile_report_expiry/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Added the required column `expiresAt` to the `profile_reports` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "profile_reports" ADD COLUMN "expiresAt" TIMESTAMP(3) NOT NULL, +ALTER COLUMN "reporterIp" DROP NOT NULL; + +-- CreateIndex +CREATE INDEX "profile_reports_expiresAt_idx" ON "profile_reports"("expiresAt"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b7f1021e..05159abd 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -269,11 +269,13 @@ model ProfileReport { profileId String reason String details String? - reporterIp String + reporterIp String? createdAt DateTime @default(now()) + expiresAt DateTime profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade) @@index([profileId]) @@index([profileId, createdAt(sort: Desc)]) + @@index([expiresAt]) @@map("profile_reports") } diff --git a/backend/src/app.ts b/backend/src/app.ts index f7d856b8..08be0563 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -4114,6 +4114,9 @@ All errors return JSON with an \`error\` field and optional \`code\`: } const reporterIp = req.ip ?? "unknown"; + // Reports are used transiently for abuse detection; the reporter IP is + // purged after 90 days to comply with the privacy policy (#870). + const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); await (prisma as any).profileReport.create({ data: { @@ -4121,6 +4124,7 @@ All errors return JSON with an \`error\` field and optional \`code\`: reason: parsed.data.reason, details: parsed.data.details ?? null, reporterIp, + expiresAt, }, }); diff --git a/backend/src/emails/contribution-received.test.ts b/backend/src/emails/contribution-received.test.ts new file mode 100644 index 00000000..20e78cea --- /dev/null +++ b/backend/src/emails/contribution-received.test.ts @@ -0,0 +1,42 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { contributionReceivedEmail } from "./contribution-received.js"; + +const supporterAddress = "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWX"; + +test("subject truncates the supporter's wallet address", () => { + const { subject } = contributionReceivedEmail({ + creatorName: "Alice", + supporterAddress, + amount: "10", + assetCode: "XLM", + }); + + assert.equal(subject, "GABCDE...UVWX sent you 10 XLM"); + assert.ok(!subject.includes(supporterAddress)); +}); + +test("text and html bodies still contain the full wallet address", () => { + const { text, html } = contributionReceivedEmail({ + creatorName: "Alice", + supporterAddress, + amount: "10", + assetCode: "XLM", + }); + + assert.ok(text.includes(supporterAddress)); + assert.ok(html.includes(supporterAddress)); +}); + +test("includes the optional supporter message", () => { + const { text, html } = contributionReceivedEmail({ + creatorName: "Alice", + supporterAddress, + amount: "10", + assetCode: "XLM", + message: "Keep up the great work!", + }); + + assert.ok(text.includes("Keep up the great work!")); + assert.ok(html.includes("Keep up the great work!")); +}); diff --git a/backend/src/emails/contribution-received.ts b/backend/src/emails/contribution-received.ts index ccf9d6a1..0e1627ff 100644 --- a/backend/src/emails/contribution-received.ts +++ b/backend/src/emails/contribution-received.ts @@ -7,7 +7,8 @@ export function contributionReceivedEmail(params: { }): { subject: string; text: string; html: string } { const { creatorName, supporterAddress, amount, assetCode, message } = params; - const subject = `${supporterAddress} sent you a contribution of ${amount} ${assetCode}`; + const shortAddress = `${supporterAddress.slice(0, 6)}...${supporterAddress.slice(-4)}`; + const subject = `${shortAddress} sent you ${amount} ${assetCode}`; const messageSection = message ? `\nTheir message: "${message}"\n` : ""; diff --git a/backend/src/index.ts b/backend/src/index.ts index 4d0ce6ac..4368820a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -109,6 +109,7 @@ import { startWebhookProcessor } from "./services/webhook-processor.js"; import { EventIndexer } from "./services/event-indexer.js"; import { createSorobanRpcClient } from "./services/soroban-rpc-client.js"; import { startWeeklyDigestScheduler, stopWeeklyDigestScheduler } from "./services/weekly-digest.js"; +import { startIpRetentionPurgeScheduler, stopIpRetentionPurgeScheduler } from "./services/ip-retention-purge.js"; import { prisma } from "./db.js"; import { connectRedis, disconnectRedis } from "./services/redis.js"; @@ -155,6 +156,9 @@ const server = app.listen(port, () => { // Start the weekly digest email scheduler startWeeklyDigestScheduler(); + // Start the reporter IP retention purge scheduler + startIpRetentionPurgeScheduler(); + // Start the webhook delivery processor webhookProcessor = startWebhookProcessor(); @@ -183,6 +187,7 @@ async function shutdown(signal: NodeJS.Signals): Promise { webhookProcessor?.stop(), eventIndexer?.stop(), Promise.resolve().then(() => stopWeeklyDigestScheduler()), + Promise.resolve().then(() => stopIpRetentionPurgeScheduler()), ]); await new Promise((resolve, reject) => { diff --git a/backend/src/services/ip-retention-purge.test.ts b/backend/src/services/ip-retention-purge.test.ts new file mode 100644 index 00000000..ba91043e --- /dev/null +++ b/backend/src/services/ip-retention-purge.test.ts @@ -0,0 +1,38 @@ +import { test, mock } from "node:test"; +import assert from "node:assert/strict"; +import { purgeExpiredReporterIps } from "./ip-retention-purge.js"; + +function makePrismaMock(updateManyResult: { count: number } = { count: 0 }) { + const updateMany = mock.fn((_args: { where: unknown; data: unknown }) => + Promise.resolve(updateManyResult), + ); + return { + profileReport: { updateMany }, + _updateMany: updateMany, + }; +} + +test("purges reporterIp only for reports past their expiresAt", async () => { + const prisma = makePrismaMock({ count: 2 }); + const now = new Date("2026-07-27T00:00:00Z"); + + const purged = await purgeExpiredReporterIps(prisma as any, now); + + assert.equal(purged, 2); + assert.equal(prisma._updateMany.mock.calls.length, 1); + + const [args] = prisma._updateMany.mock.calls[0].arguments; + assert.deepEqual(args.where, { + expiresAt: { lte: now }, + reporterIp: { not: null }, + }); + assert.deepEqual(args.data, { reporterIp: null }); +}); + +test("is a no-op when nothing has expired", async () => { + const prisma = makePrismaMock({ count: 0 }); + + const purged = await purgeExpiredReporterIps(prisma as any, new Date()); + + assert.equal(purged, 0); +}); diff --git a/backend/src/services/ip-retention-purge.ts b/backend/src/services/ip-retention-purge.ts new file mode 100644 index 00000000..cb3b3e0e --- /dev/null +++ b/backend/src/services/ip-retention-purge.ts @@ -0,0 +1,81 @@ +import { prisma } from "../db.js"; +import { logger } from "../logger.js"; + +const RETENTION_PURGE_JOB_NAME = "ip-retention-purge"; +const PURGE_INTERVAL_MS = 24 * 60 * 60 * 1000; + +/** + * Null out reporterIp on any ProfileReport past its retention window + * (expiresAt). The report itself (reason/details/profileId) is kept for + * moderation history — only the IP address is privacy-sensitive (#870). + */ +export async function purgeExpiredReporterIps(prismaClient = prisma, now = new Date()) { + const result = await prismaClient.profileReport.updateMany({ + where: { + expiresAt: { lte: now }, + reporterIp: { not: null }, + }, + data: { reporterIp: null }, + }); + + if (result.count > 0) { + logger.info({ purged: result.count }, "Purged expired reporter IPs"); + } + + return result.count; +} + +async function getLastPurgeRunAt(): Promise { + const row = await prisma.schedulerJob.findUnique({ + where: { name: RETENTION_PURGE_JOB_NAME }, + }); + return row?.lastRunAt ?? null; +} + +async function markPurgeRunAt(at: Date): Promise { + await prisma.schedulerJob.upsert({ + where: { name: RETENTION_PURGE_JOB_NAME }, + create: { name: RETENTION_PURGE_JOB_NAME, lastRunAt: at }, + update: { lastRunAt: at }, + }); +} + +/** + * Run the purge only if at least 24h have elapsed since the last successful + * run, so a process restart doesn't re-run it immediately. + */ +async function maybeRunPurge(): Promise { + const lastRunAt = await getLastPurgeRunAt(); + const now = Date.now(); + + if (lastRunAt !== null && now - lastRunAt.getTime() < PURGE_INTERVAL_MS) { + return; + } + + const runAt = new Date(now); + await purgeExpiredReporterIps(); + await markPurgeRunAt(runAt); +} + +let purgeInterval: ReturnType | null = null; + +export function startIpRetentionPurgeScheduler() { + logger.info("IP retention purge scheduler starting..."); + + maybeRunPurge().catch((err) => { + logger.error({ err }, "Error in initial maybeRunPurge check"); + }); + + purgeInterval = setInterval(() => { + maybeRunPurge().catch((err) => { + logger.error({ err }, "Error in maybeRunPurge interval"); + }); + }, PURGE_INTERVAL_MS); +} + +export function stopIpRetentionPurgeScheduler() { + if (purgeInterval) { + clearInterval(purgeInterval); + purgeInterval = null; + } +} diff --git a/frontend/src/app/privacy/page.tsx b/frontend/src/app/privacy/page.tsx index 4a7e6c72..f3b7ca07 100644 --- a/frontend/src/app/privacy/page.tsx +++ b/frontend/src/app/privacy/page.tsx @@ -89,8 +89,11 @@ export default function PrivacyPage() {
  • - IP addresses — used transiently for rate limiting and abuse - prevention; not linked to your profile and not stored long-term. + IP addresses — used transiently for rate limiting; not linked to + your profile and not stored long-term. When you submit a profile + report, the reporter's IP address is additionally retained + for up to 90 days to support abuse investigations, then + automatically purged.
  • HTTP request logs — retained for up to 30 days for security and