Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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");
4 changes: 3 additions & 1 deletion backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
4 changes: 4 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4114,13 +4114,17 @@ 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: {
profileId: profile.id,
reason: parsed.data.reason,
details: parsed.data.details ?? null,
reporterIp,
expiresAt,
},
});

Expand Down
42 changes: 42 additions & 0 deletions backend/src/emails/contribution-received.test.ts
Original file line number Diff line number Diff line change
@@ -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!"));
});
3 changes: 2 additions & 1 deletion backend/src/emails/contribution-received.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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` : "";

Expand Down
5 changes: 5 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -183,6 +187,7 @@ async function shutdown(signal: NodeJS.Signals): Promise<void> {
webhookProcessor?.stop(),
eventIndexer?.stop(),
Promise.resolve().then(() => stopWeeklyDigestScheduler()),
Promise.resolve().then(() => stopIpRetentionPurgeScheduler()),
]);

await new Promise<void>((resolve, reject) => {
Expand Down
38 changes: 38 additions & 0 deletions backend/src/services/ip-retention-purge.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
81 changes: 81 additions & 0 deletions backend/src/services/ip-retention-purge.ts
Original file line number Diff line number Diff line change
@@ -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<Date | null> {
const row = await prisma.schedulerJob.findUnique({
where: { name: RETENTION_PURGE_JOB_NAME },
});
return row?.lastRunAt ?? null;
}

async function markPurgeRunAt(at: Date): Promise<void> {
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<void> {
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<typeof setInterval> | 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;
}
}
7 changes: 5 additions & 2 deletions frontend/src/app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,11 @@ export default function PrivacyPage() {
</h3>
<ul className="list-disc pl-6 space-y-1 mt-1">
<li>
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&apos;s IP address is additionally retained
for up to 90 days to support abuse investigations, then
automatically purged.
</li>
<li>
HTTP request logs — retained for up to 30 days for security and
Expand Down
Loading