Add public read access and server-rendered page HTML (#56)
All checks were successful
CD / Build and push images (push) Successful in 3m13s
CI / Lint, typecheck, test (push) Successful in 2m30s
CI / Auth e2e pack (push) Successful in 3m21s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s

Anonymous visitors read what `public` grants allow, via the SPA and a
server-rendered HTML endpoint for crawlers / PDF export (ADR 0005/0009).

- api `public/`: `GET /public/:pondSlug/:pageSlug` returns a self-contained
  HTML document (content cache + minimal chrome + canonical link, no
  session-dependent content), and `…/content` returns JSON for the SPA. Both
  are `@Public()` and resolve the `public` subject through the shared resolver
  (PermissionService) — denied or missing → 404, so non-public pages never
  reveal their existence (security.md). Cached image nodes (`data-file-id`)
  are resolved to `/api/v1/media/:fileId` for the static render.
- media: `GET /media/:fileId` is `@Public()` too, so embedded images on a
  public page stream to anonymous visitors; the attachment guard still gates
  on the `public` grant (non-public → 404).
- web: a lightweight read-only `PublicPageView` at `/public/:pondSlug/:pageSlug`
  (outside the auth guard) renders the server HTML — deliberately without
  importing the collaborative editor, so anonymous readers load no editor
  bundle. New `public` i18n namespace (de+en).
- tests: `public.e2e.db.test.ts` (HTML + JSON served for a public page; a
  non-public page never resolves; removing the grant 404s both) and a browser
  `public` pack (anonymous reads a public page and its image via the SPA;
  a non-public page shows "not found") with its own CI step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Opus 4.8 2026-07-09 23:46:25 +02:00
parent e62fdcdf8b
commit fc41c91003
15 changed files with 502 additions and 4 deletions

View File

@ -188,6 +188,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/access-rules.spec.ts
- name: Reset login rate limit before public pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run public pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/public.spec.ts
- name: Reset login rate limit before offline pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \

View File

@ -19,6 +19,7 @@ import { PagesModule } from './pages/pages.module';
import { PermissionsModule } from './permissions/permissions.module';
import { PondsModule } from './ponds/ponds.module';
import { PrismaModule } from './prisma/prisma.module';
import { PublicModule } from './public/public.module';
import { RateLimitModule } from './rate-limit/rate-limit.module';
import { SearchModule } from './search/search.module';
import { SettingsModule } from './settings/settings.module';
@ -46,6 +47,7 @@ import { VersionsModule } from './versions/versions.module';
SearchModule,
GrantsModule,
MembersModule,
PublicModule,
AuthModule,
AdminModule,
LoggerModule.forRootAsync({

View File

@ -16,7 +16,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { AttachmentView, MAX_UPLOAD_PARSE_BYTES } from '@dorfteich/shared';
import type { Response } from 'express';
import { AuthedRequest } from '../auth/auth.guard';
import { AuthedRequest, Public } from '../auth/auth.guard';
import {
RequiresAttachmentPermission,
RequiresPondRole,
@ -41,15 +41,21 @@ export class FilesController {
return this.files.upload(request.user!, pondId, file);
}
/** Permission-checked file streaming (ADR 0011) — never served same-origin as executable content. */
/**
* Permission-checked file streaming (ADR 0011) never served same-origin as
* executable content. `@Public()` so embedded images on a public page load
* for anonymous visitors (issue #56); the guard still resolves the `public`
* grant via the attachment's page and 404s otherwise.
*/
@Get('media/:fileId')
@Public()
@RequiresAttachmentPermission('read', { idParam: 'fileId' })
async download(
@Param('fileId') fileId: string,
@Req() request: AuthedRequest,
@Res({ passthrough: true }) response: Response,
): Promise<StreamableFile> {
const { attachment, stream } = await this.files.download(request.user!, fileId);
const { attachment, stream } = await this.files.download(request.user ?? null, fileId);
response.set('X-Content-Type-Options', 'nosniff');
// Attachments are immutable — a new upload always gets a new id.
response.set('Cache-Control', 'private, max-age=31536000, immutable');

View File

@ -104,7 +104,7 @@ export class FilesService {
}
}
async download(_user: User, id: string): Promise<FileDownload> {
async download(_user: User | null, id: string): Promise<FileDownload> {
const attachment = await this.prisma.attachment.findFirst({ where: { id } });
if (!attachment) throw new NotFoundException();
return { attachment, stream: this.storage.createReadStream(attachment.pondId, attachment.id) };

View File

@ -0,0 +1,40 @@
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
import type { Response } from 'express';
import { AuthedRequest, Public } from '../auth/auth.guard';
import { PublicPageContent, PublicService } from './public.service';
/**
* Public read endpoints (issue #56). `@Public()` so anonymous visitors reach
* them; the service enforces the `public` grant through the shared resolver and
* 404s otherwise (non-public pages never leak). Two shapes: JSON for the SPA's
* read-only view, and a self-contained HTML document for crawlers / PDF export.
*/
@Controller('public')
export class PublicController {
constructor(private readonly publicPages: PublicService) {}
@Get(':pondSlug/:pageSlug/content')
@Public()
async content(
@Param('pondSlug') pondSlug: string,
@Param('pageSlug') pageSlug: string,
@Req() request: AuthedRequest,
): Promise<PublicPageContent> {
return this.publicPages.content(request.user ?? null, pondSlug, pageSlug);
}
@Get(':pondSlug/:pageSlug')
@Public()
async html(
@Param('pondSlug') pondSlug: string,
@Param('pageSlug') pageSlug: string,
@Req() request: AuthedRequest,
@Res({ passthrough: true }) response: Response,
): Promise<string> {
const canonical = `${request.protocol}://${request.get('host') ?? ''}${request.originalUrl}`;
const html = await this.publicPages.html(request.user ?? null, pondSlug, pageSlug, canonical);
response.set('Content-Type', 'text/html; charset=utf-8');
return html;
}
}

View File

@ -0,0 +1,126 @@
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PondPermissionCache } from '../permissions/pond-permission-cache';
import { createTestApp } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
/**
* Public read access end to end (issue #56): an anonymous request reads a page
* a `public` grant opens, via both the JSON and HTML endpoints; removing the
* grant 404s both, and a non-public page never resolves. Requests carry no
* session cookie.
*/
describe.skipIf(!hasTestDb)('public read access (e2e, issue #56)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
let ownerId: string;
let pondSlug: string;
let pondId: string;
let pageSlug: string;
let privatePondSlug: string;
let privatePageSlug: string;
let publicGrantId: string;
const api = () => request(app.getHttpServer());
async function makePage(pondIdV: string, slug: string, title: string, html: string) {
const page = await prisma.page.create({
data: {
pondId: pondIdV,
slug,
title,
createdBy: ownerId,
sortKey: 'a0',
ydocState: new Uint8Array(),
contentCache: { create: { plainText: title, markdown: title, html, outline: [] } },
},
});
return page;
}
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
const owner = await prisma.user.create({
data: {
username: `pub-owner-${suffix}`,
email: `pub-owner-${suffix}@example.test`,
displayName: 'Pub Owner',
},
});
ownerId = owner.id;
pondSlug = `pub-pond-${suffix}`;
const pond = await prisma.pond.create({
data: { slug: pondSlug, name: 'Public Pond', type: 'SHARED', ownerId },
});
pondId = pond.id;
pageSlug = `welcome-${suffix}`;
await makePage(pondId, pageSlug, 'Welcome', '<p>Hello world from a public page.</p>');
const grant = await prisma.roleGrant.create({
data: {
pondId,
subjectType: 'PUBLIC',
subjectId: null,
role: 'READER',
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: ownerId,
},
});
publicGrantId = grant.id;
privatePondSlug = `priv-pond-${suffix}`;
const priv = await prisma.pond.create({
data: { slug: privatePondSlug, name: 'Private Pond', type: 'SHARED', ownerId },
});
privatePageSlug = `secret-${suffix}`;
await makePage(priv.id, privatePageSlug, 'Secret', '<p>Nobody public should read this.</p>');
});
afterAll(async () => {
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId } } });
await prisma.pageContentCache.deleteMany({ where: { page: { pond: { ownerId } } } });
await prisma.page.deleteMany({ where: { pond: { ownerId } } });
await prisma.pond.deleteMany({ where: { ownerId } });
await prisma.user.deleteMany({ where: { id: ownerId } });
await prisma.$disconnect();
await app.close();
});
it('serves a public page as HTML and JSON to an anonymous visitor', async () => {
const html = await api().get(`/api/v1/public/${pondSlug}/${pageSlug}`).expect(200);
expect(html.headers['content-type']).toContain('text/html');
expect(html.text).toContain('<!doctype html>');
expect(html.text).toContain('Welcome');
expect(html.text).toContain('Hello world from a public page.');
expect(html.text).toContain('rel="canonical"');
// No session-dependent content leaked into the crawler document.
expect(html.text).not.toContain('dt_session');
const json = await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/content`).expect(200);
expect(json.body).toMatchObject({ title: 'Welcome', pondName: 'Public Pond' });
expect((json.body as { html: string }).html).toContain('Hello world');
});
it('never resolves a non-public page for an anonymous visitor', async () => {
await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}`).expect(404);
await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}/content`).expect(404);
await api().get(`/api/v1/public/${pondSlug}/does-not-exist`).expect(404);
});
it('404s both endpoints once the public grant is removed', async () => {
await prisma.roleGrant.delete({ where: { id: publicGrantId } });
// The API route invalidates on its own mutations; this test deletes
// directly, so drop the cached pond context to mirror that (issue #39).
app.get(PondPermissionCache).invalidate(pondId);
await api().get(`/api/v1/public/${pondSlug}/${pageSlug}`).expect(404);
await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/content`).expect(404);
});
});

View File

@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { PublicController } from './public.controller';
import { PublicService } from './public.service';
/**
* Public read access (issue #56): anonymous-reachable page endpoints on top of
* the shared permission resolver (PermissionService comes from the global
* PermissionsModule). Media for public pages is served by FilesModule, which
* marks `GET /media/:fileId` public too.
*/
@Module({
controllers: [PublicController],
providers: [PublicService],
})
export class PublicModule {}

View File

@ -0,0 +1,131 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Pond, User } from '@prisma/client';
import { PermissionService } from '../permissions/permission.service';
import { PrismaService } from '../prisma/prisma.service';
/** The JSON the SPA renders for an anonymous (or any) reader of a public page. */
export interface PublicPageContent {
pondName: string;
pondSlug: string;
title: string;
slug: string;
/** Pre-rendered body HTML from the content cache (issue #24). */
html: string;
updatedAt: string;
}
interface ResolvedPage {
pond: Pond;
page: { id: string; pondId: string; slug: string; title: string };
}
/**
* Public read access (issue #56): resolves a pond/page by slug and enforces
* read permission for the (possibly anonymous) viewer through the shared
* resolver a `public` grant is what lets a logged-out visitor in. Denied or
* missing 404, so non-public pages never reveal their existence
* (security.md). Serves both the SPA's JSON and a server-rendered HTML page for
* crawlers and the PDF exporter (ADR 0005/0009).
*/
@Injectable()
export class PublicService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionService,
) {}
private async resolve(
user: User | null,
pondSlug: string,
pageSlug: string,
): Promise<ResolvedPage> {
const pond = await this.prisma.pond.findFirst({ where: { slug: pondSlug, deletedAt: null } });
if (!pond) throw new NotFoundException();
const page = await this.prisma.page.findFirst({
where: { pondId: pond.id, slug: pageSlug, deletedAt: null },
select: { id: true, pondId: true, slug: true, title: true },
});
// Hide existence: no read access (incl. anonymous without a public grant) → 404.
if (!page || !(await this.permissions.canAccessPage(user, page, 'read'))) {
throw new NotFoundException();
}
return { pond, page };
}
/** The page content for the SPA's read-only public view. */
async content(user: User | null, pondSlug: string, pageSlug: string): Promise<PublicPageContent> {
const { pond, page } = await this.resolve(user, pondSlug, pageSlug);
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } });
return {
pondName: pond.name,
pondSlug: pond.slug,
title: page.title,
slug: page.slug,
html: resolveMediaUrls(cache?.html ?? ''),
updatedAt: (cache?.updatedAt ?? new Date()).toISOString(),
};
}
/** A complete, self-contained HTML document for crawlers / PDF export. */
async html(
user: User | null,
pondSlug: string,
pageSlug: string,
canonical: string,
): Promise<string> {
const content = await this.content(user, pondSlug, pageSlug);
const title = escapeHtml(`${content.title}${content.pondName}`);
// No session-dependent content: this document is identical for every viewer
// who may read the page (crawler-safe, cacheable).
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title}</title>
<link rel="canonical" href="${escapeHtml(canonical)}">
<style>
:root { color-scheme: light dark; }
body { max-width: 48rem; margin: 2rem auto; padding: 0 1rem;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; line-height: 1.6; }
img { max-width: 100%; height: auto; }
.public-page__pond { color: #64748b; font-size: 0.9rem; }
pre { overflow-x: auto; }
</style>
</head>
<body>
<main class="public-page">
<p class="public-page__pond">${escapeHtml(content.pondName)}</p>
<h1>${escapeHtml(content.title)}</h1>
${content.html}
</main>
</body>
</html>
`;
}
}
/**
* The cached HTML carries images as `<img data-file-id="…">` in the live app
* the editor's node view resolves that to `/media/:fileId` client-side. The
* static public view has no such runtime, so resolve it here to a real `src`
* (the media endpoint is public too, issue #56).
*/
function resolveMediaUrls(html: string): string {
// Same URL the editor's image node view uses (apps/web/.../nodes/image.tsx).
return html.replace(
/data-file-id="([A-Za-z0-9-]+)"/g,
'src="/api/v1/media/$1" data-file-id="$1"',
);
}
/** Minimal HTML escaping for the values we interpolate into the shell (not the
* already-sanitized cached body HTML). */
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@ -0,0 +1,76 @@
import { expect, test } from '@playwright/test';
import type { BrowserContext } from '@playwright/test';
import { contextForUser } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
/**
* Public read access (issue #56): an anonymous visitor reads a page a `public`
* grant opens, through the SPA's read-only view (no editor bundle), and its
* embedded image streams too; a non-public page never resolves. Uses the seeded
* `content-fixtures` pond (owned by fixture-user) whose "Fixture Image" page
* carries a real servable image.
*/
async function pondId(owner: BrowserContext, slug: string): Promise<string> {
const res = await owner.request.get(`/api/v1/ponds/${slug}`);
return ((await res.json()) as { id: string }).id;
}
test('an anonymous visitor reads a public page and its image via the SPA', async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const id = await pondId(owner, 'content-fixtures');
const grant = await owner.request.post(`/api/v1/ponds/${id}/grants`, {
data: { subjectType: 'public', role: 'reader', scopeType: 'pond', effect: 'allow' },
});
const grantId = ((await grant.json()) as { id: string }).id;
try {
const anon = await browser.newContext({ baseURL: BASE_URL }); // no session
const page = await anon.newPage();
await page.goto('/public/content-fixtures/fixture-image');
// The read-only public view renders — with no collaborative editor.
await expect(page.locator('.public-page__badge')).toBeVisible();
await expect(page.locator('.public-page__title')).toContainText('Fixture Image');
await expect(page.locator('.ProseMirror')).toHaveCount(0);
// The embedded image streams to the anonymous visitor (media honors public):
// fetch its resolved /media URL from the same session-less context.
const src = await page.locator('.public-page__body img').first().getAttribute('src');
expect(src).toMatch(/^\/api\/v1\/media\//);
const media = await anon.request.get(src!);
expect(media.status()).toBe(200);
expect(media.headers()['content-type']).toContain('image/');
await anon.close();
} finally {
await owner.request.delete(`/api/v1/ponds/${id}/grants/${grantId}`);
await owner.close();
}
});
test('a non-public page never resolves for an anonymous visitor', async ({ browser }) => {
// A fresh private pond + page (no public grant) — isolated from any shared
// fixture pond so the negative case cannot be polluted by another test.
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = (await (
await owner.request.post('/api/v1/ponds', { data: { name: `Private ${Date.now()}` } })
).json()) as { id: string; slug: string };
const page = (await (
await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, { data: { title: 'Secret' } })
).json()) as { slug: string };
const anon = await browser.newContext({ baseURL: BASE_URL });
const view = await anon.newPage();
// The SPA shows "not found"…
await view.goto(`/public/${pond.slug}/${page.slug}`);
await expect(view.locator('.public-page__body')).toHaveCount(0);
// …and the content endpoint hides the page's existence.
const res = await anon.request.get(`/api/v1/public/${pond.slug}/${page.slug}/content`);
expect(res.status()).toBe(404);
await anon.close();
await owner.close();
});

View File

@ -8,6 +8,7 @@ import { NotFoundPage } from './pages/NotFoundPage';
import { PageEditorPage } from './pages/PageEditorPage';
import { PondHomePage } from './pages/PondHomePage';
import { PondSettingsPage } from './pages/PondSettingsPage';
import { PublicPageView } from './pages/PublicPageView';
import { SettingsPage } from './pages/SettingsPage';
import { TrashPage } from './pages/TrashPage';
import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage';
@ -30,6 +31,8 @@ export function App(): React.JSX.Element {
{/* Verify/reset work regardless of session state (mail links). */}
<Route path="verify-email" element={<VerifyEmailPage />} />
<Route path="reset-password" element={<ResetPasswordPage />} />
{/* Public read-only page view — reachable without a session (issue #56). */}
<Route path="public/:pondSlug/:pageSlug" element={<PublicPageView />} />
<Route element={<RequireAuth />}>
<Route path="settings" element={<SettingsPage />} />

View File

@ -6,6 +6,7 @@ import deErrors from '@dorfteich/shared/i18n/de/errors.json';
import deLabels from '@dorfteich/shared/i18n/de/labels.json';
import deLinks from '@dorfteich/shared/i18n/de/links.json';
import deMembers from '@dorfteich/shared/i18n/de/members.json';
import dePublic from '@dorfteich/shared/i18n/de/public.json';
import deSearch from '@dorfteich/shared/i18n/de/search.json';
import deSettings from '@dorfteich/shared/i18n/de/settings.json';
import enAccess from '@dorfteich/shared/i18n/en/access.json';
@ -16,6 +17,7 @@ import enErrors from '@dorfteich/shared/i18n/en/errors.json';
import enLabels from '@dorfteich/shared/i18n/en/labels.json';
import enLinks from '@dorfteich/shared/i18n/en/links.json';
import enMembers from '@dorfteich/shared/i18n/en/members.json';
import enPublic from '@dorfteich/shared/i18n/en/public.json';
import enSearch from '@dorfteich/shared/i18n/en/search.json';
import enSettings from '@dorfteich/shared/i18n/en/settings.json';
import i18n from 'i18next';
@ -43,6 +45,7 @@ void i18n
labels: enLabels,
links: enLinks,
members: enMembers,
public: enPublic,
search: enSearch,
},
de: {
@ -55,6 +58,7 @@ void i18n
labels: deLabels,
links: deLinks,
members: deMembers,
public: dePublic,
search: deSearch,
},
},

View File

@ -0,0 +1,48 @@
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { ApiError, apiGet } from '../lib/api';
import { NotFoundPage } from './NotFoundPage';
interface PublicPageContent {
pondName: string;
pondSlug: string;
title: string;
slug: string;
html: string;
updatedAt: string;
}
/**
* Read-only public page view (issue #56): renders a page that a `public` grant
* opens to anonymous visitors, from the server-derived HTML deliberately
* WITHOUT importing the collaborative editor, so anonymous readers never load
* the editor bundle. A non-public page 404s (the api hides its existence).
*/
export function PublicPageView(): React.JSX.Element {
const { t } = useTranslation('public');
const { pondSlug = '', pageSlug = '' } = useParams<{ pondSlug: string; pageSlug: string }>();
const query = useQuery({
queryKey: ['public-page', pondSlug, pageSlug],
queryFn: () => apiGet<PublicPageContent>(`/public/${pondSlug}/${pageSlug}/content`),
enabled: Boolean(pondSlug && pageSlug),
retry: false,
});
if (query.error instanceof ApiError && query.error.status === 404) return <NotFoundPage />;
if (query.isLoading || !query.data) return <div aria-busy="true" />;
const page = query.data;
return (
<article className="public-page">
<p className="public-page__badge">{t('readOnlyBadge')}</p>
<p className="public-page__pond">{page.pondName}</p>
<h1 className="public-page__title">{page.title}</h1>
{/* The HTML comes from the server's content cache (issue #24), derived
from the sanitized editor schema safe to render. */}
<div className="public-page__body" dangerouslySetInnerHTML={{ __html: page.html }} />
</article>
);
}

View File

@ -1612,3 +1612,31 @@ button {
gap: var(--space-2);
padding: var(--space-1) 0;
}
/* Public read-only page view (issue #56) */
.public-page {
max-width: 48rem;
margin: 0 auto;
}
.public-page__badge {
display: inline-block;
padding: var(--space-1) var(--space-2);
border-radius: 999px;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
font-size: 0.8rem;
color: var(--color-text-muted);
margin-bottom: var(--space-3);
}
.public-page__pond {
color: var(--color-text-muted);
font-size: 0.9rem;
margin: 0;
}
.public-page__body img {
max-width: 100%;
height: auto;
}

View File

@ -0,0 +1,4 @@
{
"readOnlyBadge": "Öffentliche Seite · nur Lesen",
"signInToEdit": "Zum Bearbeiten anmelden"
}

View File

@ -0,0 +1,4 @@
{
"readOnlyBadge": "Public page · read only",
"signInToEdit": "Sign in to edit"
}