dorfteich/apps/api/src/import-export/data-export.service.ts
Claude Opus 4.8 462eca9699
All checks were successful
CD / Build and push images (push) Successful in 10m39s
CI / Lint, typecheck, test (push) Successful in 3m12s
CI / Auth e2e pack (push) Successful in 4m9s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 11s
Add self-service GDPR data export (#68)
A signed-in account can export all of its own data — profile, a list of
its memberships/grants, and the Markdown of its personal pond plus the
shared ponds it owns — as one ZIP. Foreign content never appears: only
owned ponds are bundled and the per-page read filter (reused from #65)
runs for each.

- Reuse the conversion-job queue as the async carrier: a `data_export`
  job whose worker branch resolves DataExportService via a token (no DI
  cycle), builds the ZIP, and stores it with an `expiresAt`. The download
  link 404s past expiry and an hourly scheduled purge drops the bytes
  (data minimization, security.md §Privacy).
- Extract ExportService.appendPondMarkdown so the pond ZIP (#65) and the
  data export share one read-filtered pond archiver.
- Rate-limit requests per account (RateLimitService); POST
  /users/me/data-export enqueues, GET /jobs/:id(/result) poll/download.
- Settings UI "Export my data" (de+en); web share pollJob/downloadJobResult
  between the document and data export hooks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 13:01:51 +02:00

175 lines
6.8 KiB
TypeScript

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<ConversionJobView> {
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<void>((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<number> {
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<string, unknown> {
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<unknown[]> {
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()];
}
}