dorfteich/apps/api/src/import-export/data-export.service.ts
Claude Fable 5 05a979bac3
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m25s
CI / Build container images (pull_request) Successful in 2m58s
CI / Auth e2e pack (pull_request) Successful in 8m35s
CI / Import/export fidelity gate (pull_request) Successful in 1m7s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Has been cancelled
CD / Build and push images (push) Has been cancelled
#222: read-access trail for classified pages
Instrument every full-content read channel for pages with
classification = vs_nfd (ADR 0023, variant A): SPA state fetch and read
rendering, public JSON content, no-JS shell, expanded embeds, public API
GET (incl. the MCP read_page path and write echoes), attachment download
under the #212 effective classification, all export shapes (markdown,
pond ZIP, account data export, queued docx/odt/pdf at enqueue), and
collab-token issuance as the api-side proxy for the WS join.

Events land in the new read_events table (no FKs — evidence survives
page purges and hard user deletions) with actor, session key
(session:/token:/job:/anon), page, pond, channel and the classification
at read time. Recording failures are NOT swallowed: a failed write
aborts the read (hard failure, the deliberate contrast to AuditService —
decision recorded in ADR 0023 and security.md §Logging, together with
the recorded residuals: content fragments and feeds).

One e2e test per channel proves both the event and its absence for
unclassified pages, plus the hard-failure semantics.

Refs #222.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 12:12:55 +02:00

182 lines
7.1 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) {
// The build runs in the conversion worker, outside any request — the
// read-trail session key (#222) is the job itself: `job:<id>` names the
// one download this build feeds, so the dedup window (#223) has a
// stable, honest key.
await this.exports.appendPondMarkdown(archive, user, pond, `ponds/${pond.slug}/`, {
actorId: user.id,
sessionKey: `job:${job.id}`,
});
}
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()];
}
}