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
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:
parent
e62fdcdf8b
commit
fc41c91003
@ -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%';" | \
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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) };
|
||||
|
||||
40
apps/api/src/public/public.controller.ts
Normal file
40
apps/api/src/public/public.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
126
apps/api/src/public/public.e2e.db.test.ts
Normal file
126
apps/api/src/public/public.e2e.db.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
16
apps/api/src/public/public.module.ts
Normal file
16
apps/api/src/public/public.module.ts
Normal 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 {}
|
||||
131
apps/api/src/public/public.service.ts
Normal file
131
apps/api/src/public/public.service.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
76
apps/web/e2e/public.spec.ts
Normal file
76
apps/web/e2e/public.spec.ts
Normal 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();
|
||||
});
|
||||
@ -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 />} />
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
48
apps/web/src/pages/PublicPageView.tsx
Normal file
48
apps/web/src/pages/PublicPageView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
4
packages/shared/i18n/de/public.json
Normal file
4
packages/shared/i18n/de/public.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"readOnlyBadge": "Öffentliche Seite · nur Lesen",
|
||||
"signInToEdit": "Zum Bearbeiten anmelden"
|
||||
}
|
||||
4
packages/shared/i18n/en/public.json
Normal file
4
packages/shared/i18n/en/public.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"readOnlyBadge": "Public page · read only",
|
||||
"signInToEdit": "Sign in to edit"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user