All checks were successful
CD / Build and push images (push) Successful in 3m57s
CI / Lint, typecheck, test (push) Successful in 3m7s
CI / Auth e2e pack (push) Successful in 3m58s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m14s
CD / Promote to Int (push) Successful in 11s
Two export paths, both permission-aware (permissions.md):
- `GET /ponds/:id/export/markdown` streams a ZIP of the pond's readable
pages as Markdown (one `<slug>.md` per page, a `media/` directory,
wikilinks rewritten to relative `[text](slug.md)` links, image sources to
`media/<id>.<ext>`). The `reader` guard is "may see the pond"; the service
filters to the pages the requester may actually read, so a label-restricted
reader gets only their slice. Media is appended as read streams and pages as
small strings, so memory stays bounded for a large pond (500-page test).
- `POST /pages/:id/export {format: docx|odt}` enqueues a `markdown → pandoc →
file` conversion job (the #62 queue): embedded images are inlined as data
URIs so the sidecar embeds them, wikilinks flatten to text. The client polls
`GET /jobs/:id` and downloads `GET /jobs/:id/result`.
Frontend: office-export buttons in the page menu (`.docx`/`.odt` run the job
and download the result; PDF is a disabled placeholder for Gotenberg, #67) and
a "Download pond as ZIP" link in pond settings. New `export` i18n namespace
(de+en). Markdown copy/download stay as-is (#30).
Robustness: the pond ZIP skips an attachment whose bytes are missing on disk
(data drift) rather than letting an unhandled read-stream error crash the api;
`FileStorageService.exists` gates inclusion, with a defensive stream error
handler. The per-page export drops an unreadable image the same way.
- shared: EXPORT_FORMATS + pageExportInputSchema; export-markdown transform
helpers (image/wikilink rewrites, MIME→extension).
- deps: archiver (streaming ZIP; v7 for CommonJS compat), fflate (dev, reads
ZIPs in tests).
- tests: export-markdown unit + export.service.db (ZIP contents & relative
links, label-restricted omission, docx job with inlined images, 500-page
streaming, missing-media skip); e2e export pack (ZIP download; `.docx`
self-skips without a pandoc sidecar, as in the import pack, #64).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
50 lines
1.7 KiB
TypeScript
50 lines
1.7 KiB
TypeScript
import { createReadStream } from 'node:fs';
|
|
import { access, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import type { Readable } from 'node:stream';
|
|
|
|
import { Injectable } from '@nestjs/common';
|
|
|
|
import { AppConfig } from '../config/app-config.service';
|
|
|
|
/**
|
|
* Filesystem binding for uploaded files (ADR 0011, issue #27): opaque
|
|
* layout `<uploadsDir>/<pondId>/<fileId>`, original filenames and metadata
|
|
* live in the database, not on disk. Kept behind this interface so an S3
|
|
* binding stays possible later without touching callers.
|
|
*/
|
|
@Injectable()
|
|
export class FileStorageService {
|
|
constructor(private readonly config: AppConfig) {}
|
|
|
|
private pathFor(pondId: string, fileId: string): string {
|
|
return join(this.config.env.UPLOADS_DIR, pondId, fileId);
|
|
}
|
|
|
|
async save(pondId: string, fileId: string, data: Buffer): Promise<void> {
|
|
await mkdir(join(this.config.env.UPLOADS_DIR, pondId), { recursive: true });
|
|
await writeFile(this.pathFor(pondId, fileId), data);
|
|
}
|
|
|
|
createReadStream(pondId: string, fileId: string): Readable {
|
|
return createReadStream(this.pathFor(pondId, fileId));
|
|
}
|
|
|
|
/** Whether the file's bytes are actually on disk. Used by the pond export to
|
|
* skip an attachment whose bytes are missing (data drift) rather than crash
|
|
* the archive stream (issue #65). */
|
|
async exists(pondId: string, fileId: string): Promise<boolean> {
|
|
try {
|
|
await access(this.pathFor(pondId, fileId));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Idempotent — removing an already-absent file is not an error. */
|
|
async delete(pondId: string, fileId: string): Promise<void> {
|
|
await rm(this.pathFor(pondId, fileId), { force: true });
|
|
}
|
|
}
|