diff --git a/apps/api/src/import-export/export.service.db.test.ts b/apps/api/src/import-export/export.service.db.test.ts index 35904e6..f1bc708 100644 --- a/apps/api/src/import-export/export.service.db.test.ts +++ b/apps/api/src/import-export/export.service.db.test.ts @@ -1,12 +1,16 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + import { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; -import { unzipSync } from 'fflate'; +import { unzipSync, zipSync } from 'fflate'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { AuthTokensService } from '../auth/auth-tokens.service'; import { FileStorageService } from '../files/file-storage.service'; import { FilesService } from '../files/files.service'; +import { PluginsService } from '../plugins/plugins.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; @@ -373,6 +377,56 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => { expect((result.body as Buffer).toString('utf8')).toContain('%PDF'); }); + it('inlines active section-style plugin CSS into the PDF html (#75)', async () => { + // Install the real reference plugin and make it active everywhere, so the + // export path exercises the same package users get. + const pluginDir = join(__dirname, '../../../../packages/plugins/section-styles-basic'); + const plugins = app.get(PluginsService); + // A `required` plugin refuses uninstall, and the local dev DB may carry + // state from an aborted earlier run — always demote + remove, both ways. + async function removeIfInstalled(): Promise { + await plugins.setMode('section-styles-basic', 'disabled').catch(() => undefined); + await plugins.uninstall('section-styles-basic').catch(() => undefined); + } + await removeIfInstalled(); + await plugins.install( + Buffer.from( + zipSync({ + 'manifest.json': new Uint8Array(readFileSync(join(pluginDir, 'manifest.json'))), + 'styles.css': new Uint8Array(readFileSync(join(pluginDir, 'styles.css'))), + }), + ), + ); + try { + await plugins.setMode('section-styles-basic', 'required'); + + const slug = await seedPage( + personalPondId, + 'Sectioned Pdf', + 'body', + '

boxed

', + ); + const page = await prisma.page.findFirstOrThrow({ + where: { pondId: personalPondId, slug }, + }); + + await api() + .post(`/api/v1/pages/${page.id}/export`) + .set('Cookie', ownerCookie) + .send({ format: 'pdf' }) + .expect(201); + await worker.drain(); + + // The Gotenberg HTML carries both the section markup and the plugin's + // scoped CSS, so the box renders in the (network-isolated) PDF. + expect(renderer.lastHtml).toContain('dt-style-section-styles-basic-callout"'); + expect(renderer.lastHtml).toContain('.dt-style-section-styles-basic-callout {'); + expect(renderer.lastHtml).toContain('/* section-styles-basic@'); + } finally { + await removeIfInstalled(); + } + }); + it('fails a PDF export when the renderer is down', async () => { renderer.failWith = new RenderError('render_failed', false, 'gotenberg exploded'); const slug = await seedPage(personalPondId, 'Pdf Fails', 'x', '

x

'); diff --git a/apps/api/src/import-export/export.service.ts b/apps/api/src/import-export/export.service.ts index 3efdc32..5cec25a 100644 --- a/apps/api/src/import-export/export.service.ts +++ b/apps/api/src/import-export/export.service.ts @@ -17,6 +17,7 @@ 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 { PluginsService } from '../plugins/plugins.service'; import { PrismaService } from '../prisma/prisma.service'; import { ConversionJobService } from './conversion-job.service'; @@ -42,6 +43,7 @@ export class ExportService { private readonly permissions: PermissionService, private readonly storage: FileStorageService, private readonly jobs: ConversionJobService, + private readonly plugins: PluginsService, private readonly config: AppConfig, private readonly logger: PinoLogger, ) { @@ -217,6 +219,9 @@ export class ExportService { bodyHtml, fonts, fontFaceCss: await this.fontFaceCss(fonts), + // Styled sections keep their look in the PDF (#75); a pond without + // active style plugins contributes an empty string. + sectionStyleCss: await this.plugins.sectionStyleCssForPond(page.pondId), }); const job = await this.jobs.enqueue({ diff --git a/apps/api/src/import-export/import-export.module.ts b/apps/api/src/import-export/import-export.module.ts index 0bf2928..77bc087 100644 --- a/apps/api/src/import-export/import-export.module.ts +++ b/apps/api/src/import-export/import-export.module.ts @@ -2,6 +2,7 @@ import { Module, OnModuleInit } from '@nestjs/common'; import { FilesModule } from '../files/files.module'; import { PagesModule } from '../pages/pages.module'; +import { PluginsModule } from '../plugins/plugins.module'; import { SchedulerModule } from '../scheduler/scheduler.module'; import { SchedulerService } from '../scheduler/scheduler.service'; @@ -29,7 +30,7 @@ const EXPORT_PURGE_CADENCE_SECONDS = 60 * 60; * feature exports (#65/#67), and the GDPR account data export (#68). */ @Module({ - imports: [FilesModule, PagesModule, SchedulerModule], + imports: [FilesModule, PagesModule, PluginsModule, SchedulerModule], controllers: [JobsController, ImportController, ExportController, DataExportController], providers: [ ConversionJobService, diff --git a/apps/api/src/import-export/pdf-html.ts b/apps/api/src/import-export/pdf-html.ts index 92b0064..749affa 100644 --- a/apps/api/src/import-export/pdf-html.ts +++ b/apps/api/src/import-export/pdf-html.ts @@ -8,6 +8,11 @@ export interface PdfHtmlParams { fonts: PondFonts; /** Pre-built `@font-face` rules (base64 WOFF2) for the pond's fonts. */ fontFaceCss: string; + /** The pond's active section-style plugin CSS (issue #75), already validated + * at install time (scoped selectors, no external fetches, no ``). + * Sections of a disabled plugin render neutrally — their class matches + * nothing. */ + sectionStyleCss?: string; } function escapeHtml(value: string): string { @@ -64,6 +69,7 @@ figure, img, table, pre { page-break-inside: avoid; } .pdf-header { margin-bottom: 1.5rem; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.75rem; } .pdf-header__pond { color: #64748b; font-size: 0.85rem; margin: 0 0 0.25rem; } .pdf-header__title { margin: 0; } +${params.sectionStyleCss ?? ''} diff --git a/apps/api/src/plugins/plugin-css.test.ts b/apps/api/src/plugins/plugin-css.test.ts new file mode 100644 index 0000000..952518b --- /dev/null +++ b/apps/api/src/plugins/plugin-css.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'vitest'; + +import { assertSafeCss, assertSafeSectionCss, sectionStyleScopeClass } from './plugin-css'; +import { PluginPackageError } from './plugin.constants'; + +const PLUGIN = 'section-styles-basic'; +const STYLES = ['callout', 'info']; + +function expectUnsafe(run: () => void, messagePart: string): void { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(PluginPackageError); + expect((error as PluginPackageError).code).toBe('plugin_css_unsafe'); + expect((error as PluginPackageError).message).toContain(messagePart); + return; + } + throw new Error('expected the CSS to be rejected'); +} + +describe('assertSafeCss', () => { + it('accepts relative and data: urls', () => { + assertSafeCss('.a { background: url("./x.png"), url(data:image/png;base64,AAAA) }'); + }); + + it.each([ + ['@import', '@import "other.css";'], + ['expression()', '.a { width: expression(alert(1)) }'], + ['external URL', '.a { background: url(https://evil.test/beacon) }'], + ['protocol-relative URL', '.a { background: url(//evil.test/beacon) }'], + ['javascript: URL', '.a { background: url(javascript:alert(1)) }'], + ['construct hidden in a string boundary', '.a { background: url( "https://evil.test" ) }'], + ])('rejects %s', (_name, css) => { + expect(() => assertSafeCss(css)).toThrow(PluginPackageError); + }); + + it('rejects a breakout, even hidden in a comment', () => { + expect(() => assertSafeCss('.a { color: red } /*