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
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
175 lines
6.8 KiB
TypeScript
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()];
|
|
}
|
|
}
|