import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { ConversionJobView } from '@dorfteich/shared'; import { ConversionJob, User } from '@prisma/client'; import archiver from 'archiver'; import { PinoLogger } from 'nestjs-pino'; import { RateLimitService } from '../rate-limit/rate-limit.service'; import { PrismaService } from '../prisma/prisma.service'; import { ConversionJobService } from './conversion-job.service'; import { DATA_EXPORT_KIND, DataExportProcessor } from './data-export.constants'; import { ExportService } from './export.service'; /** A data export may be requested at most this many times per window per * account (security.md §Privacy: access requests are self-service but must not * be a cheap way to hammer the server). */ const MAX_REQUESTS_PER_WINDOW = 3; const WINDOW_SECONDS = 60 * 60; /** * GDPR data export (issue #68): a signed-in user assembles all of their own * data — profile, a list of their memberships/grants, and the Markdown export * of their personal pond and the shared ponds they own — into a single ZIP. * It is produced as a conversion job (so a large account exports out of band) * with a download link that expires; foreign content never appears, because * only the requester's own ponds are included and the per-page read filter * (reused from #65) runs for every one. */ @Injectable() export class DataExportService implements DataExportProcessor { constructor( private readonly prisma: PrismaService, private readonly exports: ExportService, private readonly jobs: ConversionJobService, private readonly rateLimits: RateLimitService, private readonly logger: PinoLogger, ) { this.logger.setContext(DataExportService.name); } /** Enqueue a data-export job for `user`, rate-limited per account. The client * polls `GET /jobs/:id` and downloads `GET /jobs/:id/result` once it * succeeds; the link stops working after the job's `expiresAt`. */ async request(user: User): Promise { const limit = await this.rateLimits.hit( 'data_export', `user:${user.id}`, MAX_REQUESTS_PER_WINDOW, WINDOW_SECONDS, ); if (!limit.allowed) { throw new HttpException( { code: 'rate_limited', details: { retryAfterSeconds: limit.retryAfterSeconds } }, HttpStatus.TOO_MANY_REQUESTS, ); } const job = await this.jobs.enqueue({ ownerId: user.id, kind: DATA_EXPORT_KIND, from: 'account', to: 'zip', // A data export gathers everything from the database at build time, so the // job needs no input payload. input: Buffer.alloc(0), }); this.logger.info({ jobId: job.id, userId: user.id }, 'audit: data export requested'); return this.jobs.viewOf(job); } /** Worker entry point (via {@link DATA_EXPORT_PROCESSOR}): build the ZIP for a * requested export. */ async build(job: ConversionJob): Promise<{ bytes: Buffer; mimeType: string }> { const user = await this.prisma.user.findUniqueOrThrow({ where: { id: job.ownerId } }); const archive = archiver('zip', { zlib: { level: 9 } }); const chunks: Buffer[] = []; archive.on('data', (chunk: Buffer) => chunks.push(chunk)); const finished = new Promise((resolve, reject) => { archive.on('end', () => resolve()); archive.on('error', reject); }); archive.append(JSON.stringify(this.profileOf(user), null, 2), { name: 'profile.json' }); archive.append(JSON.stringify(await this.membershipsOf(user), null, 2), { name: 'memberships.json', }); // Only ponds this user owns — their personal pond and any shared ponds they // created. Foreign ponds they are merely a member of are represented by the // memberships list, not their content. const ponds = await this.prisma.pond.findMany({ where: { ownerId: user.id, deletedAt: null }, select: { id: true, slug: true }, orderBy: { slug: 'asc' }, }); for (const pond of ponds) { await this.exports.appendPondMarkdown(archive, user, pond, `ponds/${pond.slug}/`); } await archive.finalize(); await finished; this.logger.info( { jobId: job.id, userId: user.id, ponds: ponds.length }, 'audit: data export built', ); return { bytes: Buffer.concat(chunks), mimeType: 'application/zip' }; } /** Drop the stored bytes of every export whose link has expired (data * minimization: the archive is transient). The row remains for its audit * trail; only the payload is deleted, so a later download 404s. Registered as * a scheduled maintenance job. */ async purgeExpired(): Promise { const result = await this.prisma.conversionJob.updateMany({ where: { kind: DATA_EXPORT_KIND, expiresAt: { lt: new Date() }, result: { not: null } }, data: { result: null, resultMimeType: null }, }); if (result.count > 0) { this.logger.info({ purged: result.count }, 'audit: expired data exports purged'); } return result.count; } /** The requester's own account fields — the data minimization set the app * collects (security.md §Privacy): identity, locale, and lifecycle stamps. */ private profileOf(user: User): Record { return { id: user.id, username: user.username, email: user.email, displayName: user.displayName, locale: user.locale, isSiteAdmin: user.isSiteAdmin, status: user.status, emailVerifiedAt: user.emailVerifiedAt?.toISOString() ?? null, createdAt: user.createdAt.toISOString(), lastLoginAt: user.lastLoginAt?.toISOString() ?? null, }; } /** Every pond the user has a role grant in, with the grants that concern * them. This is the user's own membership data — pond identity plus their * roles — and deliberately carries none of those ponds' content. */ private async membershipsOf(user: User): Promise { const grants = await this.prisma.roleGrant.findMany({ where: { subjectType: 'USER', subjectId: user.id }, include: { pond: { select: { slug: true, name: true, type: true, ownerId: true } } }, orderBy: { createdAt: 'asc' }, }); const byPond = new Map< string, { pondSlug: string; pondName: string; pondType: string; owner: boolean; grants: unknown[] } >(); for (const grant of grants) { const entry = byPond.get(grant.pondId) ?? { pondSlug: grant.pond.slug, pondName: grant.pond.name, pondType: grant.pond.type, owner: grant.pond.ownerId === user.id, grants: [], }; entry.grants.push({ role: grant.role, scopeType: grant.scopeType, scopeId: grant.scopeId, effect: grant.effect, createdAt: grant.createdAt.toISOString(), }); byPond.set(grant.pondId, entry); } return [...byPond.values()]; } }