feat(geschichten): frontend foundation — canBlogWrite, sanitize util, nav, i18n

- Derives canBlogWrite in +layout.server.ts the same way as canAnnotate.
- Adds Geschichten link to AppNav (desktop + mobile, between Stammbaum and Admin).
- Adds error_geschichte_not_found mapping to errors.ts and translation keys
  for the Geschichten index, detail, editor, and confirmation copy in
  de/en/es.
- Adds isomorphic-dompurify-backed safeHtml() helper with allow-list
  matching the backend OWASP policy (p/br/strong/em/h2/h3/ul/ol/li),
  plus Vitest spec.
- Updates legacy spec test data so the new required canBlogWrite layout
  prop type-checks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-02 17:43:29 +02:00
parent afd6d0b20d
commit 9e7861fa03
18 changed files with 998 additions and 28 deletions

View File

@@ -41,6 +41,7 @@ export type ErrorCode =
| 'RELATIONSHIP_NOT_FOUND'
| 'CIRCULAR_RELATIONSHIP'
| 'DUPLICATE_RELATIONSHIP'
| 'GESCHICHTE_NOT_FOUND'
| 'MISSING_CREDENTIALS'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
@@ -145,6 +146,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_circular_relationship();
case 'DUPLICATE_RELATIONSHIP':
return m.error_duplicate_relationship();
case 'GESCHICHTE_NOT_FOUND':
return m.error_geschichte_not_found();
case 'MISSING_CREDENTIALS':
return m.login_error_missing_credentials();
case 'UNAUTHORIZED':

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { safeHtml } from './sanitize';
describe('safeHtml', () => {
it('returns empty string for null/undefined/empty input', () => {
expect(safeHtml(null)).toBe('');
expect(safeHtml(undefined)).toBe('');
expect(safeHtml('')).toBe('');
});
it('keeps allowed tags: p, strong, em, br, h2, h3, ul, ol, li', () => {
const html =
'<p><strong>bold</strong> <em>italic</em><br>x</p>' +
'<h2>H2</h2><h3>H3</h3>' +
'<ul><li>a</li></ul><ol><li>b</li></ol>';
const result = safeHtml(html);
expect(result).toContain('<strong>bold</strong>');
expect(result).toContain('<em>italic</em>');
expect(result).toContain('<br>');
expect(result).toContain('<h2>H2</h2>');
expect(result).toContain('<h3>H3</h3>');
expect(result).toContain('<ul>');
expect(result).toContain('<ol>');
expect(result).toContain('<li>a</li>');
});
it('strips <script> tags entirely', () => {
const result = safeHtml('<p>ok</p><script>alert(1)</script>');
expect(result).not.toContain('<script>');
expect(result).not.toContain('alert');
expect(result).toContain('<p>ok</p>');
});
it('strips on* event-handler attributes', () => {
const result = safeHtml('<p onclick="evil()">x</p>');
expect(result).not.toContain('onclick');
});
it('strips disallowed elements like <img>, <a>, <iframe>', () => {
const result = safeHtml(
'<p>x</p><img src="x" onerror="alert(1)"><a href="javascript:alert(1)">link</a><iframe></iframe>'
);
expect(result).not.toContain('<img');
expect(result).not.toContain('<a ');
expect(result).not.toContain('<iframe');
});
});

View File

@@ -0,0 +1,17 @@
import DOMPurify from 'isomorphic-dompurify';
const ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'h2', 'h3', 'ul', 'ol', 'li'];
/**
* Render-side sanitiser for Geschichte body HTML. The backend already
* sanitises with the OWASP allow-list on save, but we re-run on render
* because the API can be called directly and stored content can pre-date
* a tightening of the allow-list.
*/
export function safeHtml(raw: string | null | undefined): string {
if (!raw) return '';
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS,
ALLOWED_ATTR: []
});
}