dorfteich/apps/api/src/import-export/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

314 lines
12 KiB
TypeScript

import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Injectable, NotFoundException } from '@nestjs/common';
import {
ConversionJobView,
ExportFormat,
PondFonts,
fontSlug,
pondSettingsSchema,
} from '@dorfteich/shared';
import { User } from '@prisma/client';
import archiver from 'archiver';
import type { Response } from 'express';
import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service';
import { FileStorageService } from '../files/file-storage.service';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
import { ConversionJobService } from './conversion-job.service';
import {
imageExtension,
imageFileIds,
markdownForDocument,
markdownForZip,
} from './export-markdown';
import { buildPdfHtml } from './pdf-html';
/**
* Export (ADR 0009, issue #65): a whole pond as a ZIP of Markdown (one `.md`
* per readable page, a `media/` directory, wikilinks as relative links), and a
* single page to `.docx`/`.odt` via a conversion job. Both respect the
* requester's read permissions — a pond export contains only the pages the
* requester may read (permissions.md).
*/
@Injectable()
export class ExportService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
private readonly storage: FileStorageService,
private readonly jobs: ConversionJobService,
private readonly config: AppConfig,
private readonly logger: PinoLogger,
) {
this.logger.setContext(ExportService.name);
}
/**
* Stream a ZIP of the pond's readable pages as Markdown to `res`. Page
* Markdown is appended as small strings; media is appended as read streams, so
* memory stays bounded regardless of pond size (the 500-page AC). The guard
* already checked the requester may see the pond; here we filter to the pages
* they may actually read.
*/
async streamPondMarkdownZip(user: User, pondId: string, res: Response): Promise<void> {
const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } });
if (!pond) throw new NotFoundException();
const archive = archiver('zip', { zlib: { level: 9 } });
res.set('Content-Type', 'application/zip');
res.set('Content-Disposition', `attachment; filename="${pond.slug}.zip"`);
res.set('X-Content-Type-Options', 'nosniff');
archive.on('error', (error) => {
this.logger.error({ pondId, err: error.message }, 'pond export archive failed');
res.destroy(error);
});
archive.pipe(res);
await this.appendPondMarkdown(archive, user, pond);
await archive.finalize();
}
/**
* Append one pond's readable pages (as Markdown) and their media to an open
* archive, each entry under `prefix`. Shared by the pond ZIP above and the
* account data export (#68), which nests several ponds under `ponds/<slug>/`
* — the read filter runs here, so neither caller can leak a page the
* requester may not read. Page Markdown is appended as small strings; media
* as read streams, keeping memory bounded regardless of pond size.
*/
async appendPondMarkdown(
archive: archiver.Archiver,
user: User,
pond: { id: string; slug: string },
prefix = '',
): Promise<void> {
const pondId = pond.id;
const pages = await this.prisma.page.findMany({
where: { pondId, deletedAt: null },
orderBy: { title: 'asc' },
include: {
labels: { select: { labelId: true } },
contentCache: { select: { markdown: true } },
},
});
const readableIds = await this.permissions.filterPages(
user,
pondId,
pages.map((page) => ({ id: page.id, labelIds: page.labels.map((l) => l.labelId) })),
'read',
);
const readablePages = pages.filter((page) => readableIds.has(page.id));
const readableSlugs = new Set(readablePages.map((page) => page.slug));
// Every image referenced by a readable page — resolved to attachments that
// still exist in this pond, so the media directory matches the rewrites.
const referenced = new Set<string>();
for (const page of readablePages) {
for (const id of imageFileIds(page.contentCache?.markdown ?? '')) referenced.add(id);
}
const attachmentRows =
referenced.size > 0
? await this.prisma.attachment.findMany({
where: { id: { in: [...referenced] }, pondId, deletedAt: null },
select: { id: true, mimeType: true },
})
: [];
// Only include media whose bytes are actually on disk — a row whose file is
// missing (data drift) must not error the archive stream and crash the api.
const present = await Promise.all(
attachmentRows.map((a) => this.storage.exists(pond.id, a.id)),
);
const attachments = attachmentRows.filter((_, i) => present[i]);
const mediaNameById = new Map(
attachments.map((a) => [a.id, `${a.id}.${imageExtension(a.mimeType)}`]),
);
for (const page of readablePages) {
const markdown = markdownForZip(
page.contentCache?.markdown ?? '',
readableSlugs,
mediaNameById,
);
// Page slugs are unique within a pond, so `<slug>.md` never collides.
archive.append(markdown, { name: `${prefix}${page.slug}.md` });
}
for (const attachment of attachments) {
const stream = this.storage.createReadStream(pond.id, attachment.id);
// Defence in depth: a file removed between the existence check and the
// read must not throw an unhandled stream error — let the archive skip it.
stream.on('error', (error) =>
this.logger.warn(
{ pondId, fileId: attachment.id, err: error.message },
'export: media read failed',
),
);
archive.append(stream, { name: `${prefix}media/${mediaNameById.get(attachment.id)!}` });
}
this.logger.info(
{ pondId, pages: readablePages.length, media: attachments.length, userId: user.id },
'audit: pond exported as markdown zip',
);
}
/**
* Enqueue a `markdown → pandoc → .docx/.odt` conversion for one page (issue
* #65). Embedded images are inlined as `data:` URIs so the sidecar embeds
* them; wikilinks become plain text (a standalone document has no targets).
* The client polls `GET /jobs/:id` and downloads `GET /jobs/:id/result`.
*/
async enqueuePageExport(
user: User,
pageId: string,
format: ExportFormat,
): Promise<ConversionJobView> {
if (format === 'pdf') return this.enqueuePdfExport(user, pageId);
const page = await this.prisma.page.findFirst({
where: { id: pageId, deletedAt: null },
include: { contentCache: { select: { markdown: true } } },
});
if (!page) throw new NotFoundException();
const markdown = page.contentCache?.markdown ?? '';
const dataUriById = await this.inlineImages(page.pondId, imageFileIds(markdown));
const document = markdownForDocument(markdown, dataUriById);
const job = await this.jobs.enqueue({
ownerId: user.id,
kind: `export_${format}`,
from: 'gfm',
to: format,
input: Buffer.from(document, 'utf8'),
standalone: true,
});
this.logger.info(
{ jobId: job.id, pageId, format, userId: user.id },
'audit: page export enqueued',
);
return this.jobs.viewOf(job);
}
/**
* Enqueue a PDF export (issue #67). The self-contained export HTML — content
* with images inlined, the pond's fonts inlined as base64 `@font-face`, print
* CSS — is built here (permission already checked by the guard) and stored as
* the job input; the worker sends it to Gotenberg (`html → pdf`). Building it
* up front keeps the job a plain byte→byte render the worker can retry.
*/
private async enqueuePdfExport(user: User, pageId: string): Promise<ConversionJobView> {
const page = await this.prisma.page.findFirst({
where: { id: pageId, deletedAt: null },
include: {
pond: { select: { name: true, settings: true } },
contentCache: { select: { html: true } },
},
});
if (!page) throw new NotFoundException();
const fonts = pondSettingsSchema.parse(page.pond.settings ?? {}).fonts;
const bodyHtml = await this.inlineHtmlImages(page.pondId, page.contentCache?.html ?? '');
const html = buildPdfHtml({
title: page.title,
pondName: page.pond.name,
bodyHtml,
fonts,
fontFaceCss: await this.fontFaceCss(fonts),
});
const job = await this.jobs.enqueue({
ownerId: user.id,
kind: 'export_pdf',
from: 'html',
to: 'pdf',
input: Buffer.from(html, 'utf8'),
standalone: true,
});
this.logger.info(
{ jobId: job.id, pageId, format: 'pdf', userId: user.id },
'audit: page export enqueued',
);
return this.jobs.viewOf(job);
}
/** Replace each `<img data-file-id="X">` with an inlined `data:` URI so the
* PDF render (isolated from the api) needs no network to fetch media. */
private async inlineHtmlImages(pondId: string, html: string): Promise<string> {
const ids = [...html.matchAll(/data-file-id="([A-Za-z0-9-]+)"/g)].map((m) => m[1]!);
if (ids.length === 0) return html;
const dataUriById = await this.inlineImages(pondId, ids);
return html.replace(/data-file-id="([A-Za-z0-9-]+)"/g, (whole, id: string) => {
const uri = dataUriById.get(id);
return uri ? `src="${uri}" ${whole}` : whole;
});
}
/** Base64 `@font-face` rules for the pond's three fonts, read from the
* catalog baked into the image (ADR 0016). A font file that is absent (a
* native dev run without `FONTS_DIR` populated) is skipped — the render falls
* back to the system stack rather than failing. */
private async fontFaceCss(fonts: PondFonts): Promise<string> {
const slots = [fonts.heading, fonts.body, fonts.mono];
// Dedup identical family+weight so a doc that repeats a font embeds it once.
const seen = new Set<string>();
const faces: string[] = [];
for (const slot of slots) {
const key = `${slot.family}:${slot.weight}`;
if (seen.has(key)) continue;
seen.add(key);
const slug = fontSlug(slot.family);
const file = join(this.config.env.FONTS_DIR, slug, `${slug}-${slot.weight}.woff2`);
try {
const bytes = await readFile(file);
faces.push(
`@font-face { font-family: '${slot.family}'; font-style: normal; font-weight: ${slot.weight};` +
` src: url('data:font/woff2;base64,${bytes.toString('base64')}') format('woff2'); }`,
);
} catch {
this.logger.warn({ font: key }, 'pdf export: catalog font file missing, using fallback');
}
}
return faces.join('\n');
}
/** Read each referenced attachment's bytes into a `data:` URI (a page has few
* images, so holding them briefly is fine — unlike the streamed pond ZIP). */
private async inlineImages(pondId: string, ids: string[]): Promise<Map<string, string>> {
const dataUriById = new Map<string, string>();
if (ids.length === 0) return dataUriById;
const attachments = await this.prisma.attachment.findMany({
where: { id: { in: ids }, pondId, deletedAt: null },
select: { id: true, mimeType: true },
});
for (const attachment of attachments) {
try {
const bytes = await readStream(this.storage.createReadStream(pondId, attachment.id));
dataUriById.set(
attachment.id,
`data:${attachment.mimeType};base64,${bytes.toString('base64')}`,
);
} catch (error) {
// A missing file (data drift) drops the image rather than failing the
// whole export — the rest of the page still converts.
this.logger.warn(
{ pondId, fileId: attachment.id, err: (error as Error).message },
'export: inline image read failed',
);
}
}
return dataUriById;
}
}
function readStream(stream: NodeJS.ReadableStream): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
}