Skip to content

Commit 18b773e

Browse files
authored
Merge pull request #900 from all-opensource-projects/fix/privacy-ip-retention-and-email-subject
Fix supporter wallet address exposure and ProfileReport IP retention
2 parents 925178f + b59846b commit 18b773e

9 files changed

Lines changed: 192 additions & 4 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/*
2+
Warnings:
3+
4+
- Added the required column `expiresAt` to the `profile_reports` table without a default value. This is not possible if the table is not empty.
5+
6+
*/
7+
-- AlterTable
8+
ALTER TABLE "profile_reports" ADD COLUMN "expiresAt" TIMESTAMP(3) NOT NULL,
9+
ALTER COLUMN "reporterIp" DROP NOT NULL;
10+
11+
-- CreateIndex
12+
CREATE INDEX "profile_reports_expiresAt_idx" ON "profile_reports"("expiresAt");

backend/prisma/schema.prisma

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,11 +269,13 @@ model ProfileReport {
269269
profileId String
270270
reason String
271271
details String?
272-
reporterIp String
272+
reporterIp String?
273273
createdAt DateTime @default(now())
274+
expiresAt DateTime
274275
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
275276
276277
@@index([profileId])
277278
@@index([profileId, createdAt(sort: Desc)])
279+
@@index([expiresAt])
278280
@@map("profile_reports")
279281
}

backend/src/app.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4114,13 +4114,17 @@ All errors return JSON with an \`error\` field and optional \`code\`:
41144114
}
41154115

41164116
const reporterIp = req.ip ?? "unknown";
4117+
// Reports are used transiently for abuse detection; the reporter IP is
4118+
// purged after 90 days to comply with the privacy policy (#870).
4119+
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
41174120

41184121
await (prisma as any).profileReport.create({
41194122
data: {
41204123
profileId: profile.id,
41214124
reason: parsed.data.reason,
41224125
details: parsed.data.details ?? null,
41234126
reporterIp,
4127+
expiresAt,
41244128
},
41254129
});
41264130

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { test } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { contributionReceivedEmail } from "./contribution-received.js";
4+
5+
const supporterAddress = "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWX";
6+
7+
test("subject truncates the supporter's wallet address", () => {
8+
const { subject } = contributionReceivedEmail({
9+
creatorName: "Alice",
10+
supporterAddress,
11+
amount: "10",
12+
assetCode: "XLM",
13+
});
14+
15+
assert.equal(subject, "GABCDE...UVWX sent you 10 XLM");
16+
assert.ok(!subject.includes(supporterAddress));
17+
});
18+
19+
test("text and html bodies still contain the full wallet address", () => {
20+
const { text, html } = contributionReceivedEmail({
21+
creatorName: "Alice",
22+
supporterAddress,
23+
amount: "10",
24+
assetCode: "XLM",
25+
});
26+
27+
assert.ok(text.includes(supporterAddress));
28+
assert.ok(html.includes(supporterAddress));
29+
});
30+
31+
test("includes the optional supporter message", () => {
32+
const { text, html } = contributionReceivedEmail({
33+
creatorName: "Alice",
34+
supporterAddress,
35+
amount: "10",
36+
assetCode: "XLM",
37+
message: "Keep up the great work!",
38+
});
39+
40+
assert.ok(text.includes("Keep up the great work!"));
41+
assert.ok(html.includes("Keep up the great work!"));
42+
});

backend/src/emails/contribution-received.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ export function contributionReceivedEmail(params: {
77
}): { subject: string; text: string; html: string } {
88
const { creatorName, supporterAddress, amount, assetCode, message } = params;
99

10-
const subject = `${supporterAddress} sent you a contribution of ${amount} ${assetCode}`;
10+
const shortAddress = `${supporterAddress.slice(0, 6)}...${supporterAddress.slice(-4)}`;
11+
const subject = `${shortAddress} sent you ${amount} ${assetCode}`;
1112

1213
const messageSection = message ? `\nTheir message: "${message}"\n` : "";
1314

backend/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ import { startWebhookProcessor } from "./services/webhook-processor.js";
109109
import { EventIndexer } from "./services/event-indexer.js";
110110
import { createSorobanRpcClient } from "./services/soroban-rpc-client.js";
111111
import { startWeeklyDigestScheduler, stopWeeklyDigestScheduler } from "./services/weekly-digest.js";
112+
import { startIpRetentionPurgeScheduler, stopIpRetentionPurgeScheduler } from "./services/ip-retention-purge.js";
112113
import { prisma } from "./db.js";
113114
import { connectRedis, disconnectRedis } from "./services/redis.js";
114115

@@ -155,6 +156,9 @@ const server = app.listen(port, () => {
155156
// Start the weekly digest email scheduler
156157
startWeeklyDigestScheduler();
157158

159+
// Start the reporter IP retention purge scheduler
160+
startIpRetentionPurgeScheduler();
161+
158162
// Start the webhook delivery processor
159163
webhookProcessor = startWebhookProcessor();
160164

@@ -183,6 +187,7 @@ async function shutdown(signal: NodeJS.Signals): Promise<void> {
183187
webhookProcessor?.stop(),
184188
eventIndexer?.stop(),
185189
Promise.resolve().then(() => stopWeeklyDigestScheduler()),
190+
Promise.resolve().then(() => stopIpRetentionPurgeScheduler()),
186191
]);
187192

188193
await new Promise<void>((resolve, reject) => {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { test, mock } from "node:test";
2+
import assert from "node:assert/strict";
3+
import { purgeExpiredReporterIps } from "./ip-retention-purge.js";
4+
5+
function makePrismaMock(updateManyResult: { count: number } = { count: 0 }) {
6+
const updateMany = mock.fn((_args: { where: unknown; data: unknown }) =>
7+
Promise.resolve(updateManyResult),
8+
);
9+
return {
10+
profileReport: { updateMany },
11+
_updateMany: updateMany,
12+
};
13+
}
14+
15+
test("purges reporterIp only for reports past their expiresAt", async () => {
16+
const prisma = makePrismaMock({ count: 2 });
17+
const now = new Date("2026-07-27T00:00:00Z");
18+
19+
const purged = await purgeExpiredReporterIps(prisma as any, now);
20+
21+
assert.equal(purged, 2);
22+
assert.equal(prisma._updateMany.mock.calls.length, 1);
23+
24+
const [args] = prisma._updateMany.mock.calls[0].arguments;
25+
assert.deepEqual(args.where, {
26+
expiresAt: { lte: now },
27+
reporterIp: { not: null },
28+
});
29+
assert.deepEqual(args.data, { reporterIp: null });
30+
});
31+
32+
test("is a no-op when nothing has expired", async () => {
33+
const prisma = makePrismaMock({ count: 0 });
34+
35+
const purged = await purgeExpiredReporterIps(prisma as any, new Date());
36+
37+
assert.equal(purged, 0);
38+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { prisma } from "../db.js";
2+
import { logger } from "../logger.js";
3+
4+
const RETENTION_PURGE_JOB_NAME = "ip-retention-purge";
5+
const PURGE_INTERVAL_MS = 24 * 60 * 60 * 1000;
6+
7+
/**
8+
* Null out reporterIp on any ProfileReport past its retention window
9+
* (expiresAt). The report itself (reason/details/profileId) is kept for
10+
* moderation history — only the IP address is privacy-sensitive (#870).
11+
*/
12+
export async function purgeExpiredReporterIps(prismaClient = prisma, now = new Date()) {
13+
const result = await prismaClient.profileReport.updateMany({
14+
where: {
15+
expiresAt: { lte: now },
16+
reporterIp: { not: null },
17+
},
18+
data: { reporterIp: null },
19+
});
20+
21+
if (result.count > 0) {
22+
logger.info({ purged: result.count }, "Purged expired reporter IPs");
23+
}
24+
25+
return result.count;
26+
}
27+
28+
async function getLastPurgeRunAt(): Promise<Date | null> {
29+
const row = await prisma.schedulerJob.findUnique({
30+
where: { name: RETENTION_PURGE_JOB_NAME },
31+
});
32+
return row?.lastRunAt ?? null;
33+
}
34+
35+
async function markPurgeRunAt(at: Date): Promise<void> {
36+
await prisma.schedulerJob.upsert({
37+
where: { name: RETENTION_PURGE_JOB_NAME },
38+
create: { name: RETENTION_PURGE_JOB_NAME, lastRunAt: at },
39+
update: { lastRunAt: at },
40+
});
41+
}
42+
43+
/**
44+
* Run the purge only if at least 24h have elapsed since the last successful
45+
* run, so a process restart doesn't re-run it immediately.
46+
*/
47+
async function maybeRunPurge(): Promise<void> {
48+
const lastRunAt = await getLastPurgeRunAt();
49+
const now = Date.now();
50+
51+
if (lastRunAt !== null && now - lastRunAt.getTime() < PURGE_INTERVAL_MS) {
52+
return;
53+
}
54+
55+
const runAt = new Date(now);
56+
await purgeExpiredReporterIps();
57+
await markPurgeRunAt(runAt);
58+
}
59+
60+
let purgeInterval: ReturnType<typeof setInterval> | null = null;
61+
62+
export function startIpRetentionPurgeScheduler() {
63+
logger.info("IP retention purge scheduler starting...");
64+
65+
maybeRunPurge().catch((err) => {
66+
logger.error({ err }, "Error in initial maybeRunPurge check");
67+
});
68+
69+
purgeInterval = setInterval(() => {
70+
maybeRunPurge().catch((err) => {
71+
logger.error({ err }, "Error in maybeRunPurge interval");
72+
});
73+
}, PURGE_INTERVAL_MS);
74+
}
75+
76+
export function stopIpRetentionPurgeScheduler() {
77+
if (purgeInterval) {
78+
clearInterval(purgeInterval);
79+
purgeInterval = null;
80+
}
81+
}

frontend/src/app/privacy/page.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,11 @@ export default function PrivacyPage() {
8989
</h3>
9090
<ul className="list-disc pl-6 space-y-1 mt-1">
9191
<li>
92-
IP addresses — used transiently for rate limiting and abuse
93-
prevention; not linked to your profile and not stored long-term.
92+
IP addresses — used transiently for rate limiting; not linked to
93+
your profile and not stored long-term. When you submit a profile
94+
report, the reporter&apos;s IP address is additionally retained
95+
for up to 90 days to support abuse investigations, then
96+
automatically purged.
9497
</li>
9598
<li>
9699
HTTP request logs — retained for up to 30 days for security and

0 commit comments

Comments
 (0)