Ships `dev` as **v0.11.0**. `main` contains nothing that `dev` does not, so this is a clean sync. ## Headline changes since v0.10.0 - **Journal & Journal Studio** — photo essays at `/journal`, split-screen block editor with live preview, drafts, per-entry passwords, cover images - **First-run setup wizard at `/install`** — configure a fresh deployment from the browser; credentials are verified against Immich before anything is written, and the wizard is gated by a one-time token - **About page editor and toggle** in the admin panel - **Per-album photo order** independent of Immich, with a drag & drop editor - **Custom favicon upload** - **Experimental portfolio features** — justified grid, `cover` hero, unlisted subpages, per-album grid overrides, cover focal points, external nav links - **Security** — asset tokens length-capped before decoding (#423), journal author markdown escaped rather than filtered, journal file paths contained, admin session key derived with scrypt (#432), Next.js 16.3.0 for CVE-2025-13465 - **Fixed** — client proofing honours its own configuration again (#454); journal photo rendering (#432) Full detail in [CHANGELOG.md](CHANGELOG.md#0110--2026-08-15). ## Verified `npx tsc --noEmit` clean · `npx vitest run` 435 tests pass · `npm run build` succeeds · lint 0 errors · `prettier --check` clean · local `docker build` of the release tree. ## After merge Tag `v0.11.0` on `main` and publish the release — that triggers `docker-publish.yml`, which pushes the image and moves `latest`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
180 lines
5.6 KiB
TypeScript
180 lines
5.6 KiB
TypeScript
import { describe, it, expect, afterEach } from 'vitest';
|
|
import {
|
|
parseJournalMarkdown,
|
|
serializeJournalMarkdown,
|
|
sanitizeHtml,
|
|
renderInlineMarkdown,
|
|
isValidSlug,
|
|
sanitizeSlug,
|
|
} from '../journal';
|
|
import {
|
|
writeJournalEntry,
|
|
readJournalEntry,
|
|
deleteJournalEntry,
|
|
listJournalEntries,
|
|
} from '../admin/journal-service';
|
|
|
|
describe('Journal Service & Parser', () => {
|
|
const testSlug = 'test-journey-nordkap';
|
|
|
|
afterEach(async () => {
|
|
try {
|
|
await deleteJournalEntry(testSlug);
|
|
} catch {}
|
|
});
|
|
|
|
describe('slug validation and sanitization', () => {
|
|
it('validates safe alphanumeric and hyphenated slugs', () => {
|
|
expect(isValidSlug('my-story-2026')).toBe(true);
|
|
expect(isValidSlug('nordkap_journey')).toBe(true);
|
|
expect(isValidSlug('story')).toBe(true);
|
|
});
|
|
|
|
it('rejects path traversal and illegal characters', () => {
|
|
expect(isValidSlug('../etc/passwd')).toBe(false);
|
|
expect(isValidSlug('..\\secret')).toBe(false);
|
|
expect(isValidSlug('story/sub')).toBe(false);
|
|
expect(isValidSlug('story?query=1')).toBe(false);
|
|
});
|
|
|
|
it('sanitizes titles to clean slugs', () => {
|
|
expect(sanitizeSlug('Mein Trip nach Island & Co.!')).toBe('mein-trip-nach-island-co');
|
|
expect(sanitizeSlug(' Nordkap 2026 -- Part 1 ')).toBe('nordkap-2026-part-1');
|
|
});
|
|
});
|
|
|
|
describe('sanitizeHtml & inline markdown', () => {
|
|
it('makes script tags and inline events inert', () => {
|
|
const malicious = 'Hello <script>alert(1)</script><img src="x" onerror="alert(2)">';
|
|
const clean = sanitizeHtml(malicious);
|
|
// The word "onerror" may well survive as text — what matters is that no
|
|
// tag can form around it, so it is never an attribute. Testing for the
|
|
// absence of the substring tested the old denylist, not the property.
|
|
expect(clean).not.toContain('<');
|
|
expect(clean).not.toContain('>');
|
|
expect(clean).not.toContain('"');
|
|
});
|
|
|
|
it('formats bold, italic, and safe links', () => {
|
|
const md = 'This is **bold** and *italic* and a [link](https://example.com).';
|
|
const html = renderInlineMarkdown(md);
|
|
expect(html).toContain('<strong>bold</strong>');
|
|
expect(html).toContain('<em>italic</em>');
|
|
expect(html).toContain('<a href="https://example.com"');
|
|
});
|
|
});
|
|
|
|
describe('parseJournalMarkdown and serializeJournalMarkdown', () => {
|
|
it('parses frontmatter and structured blocks', () => {
|
|
const markdown = `---
|
|
title: "Nordkap Tour"
|
|
subtitle: "Auf den Spuren des Nordlichts"
|
|
date: "2026-08-14"
|
|
author: "Ralf"
|
|
coverAssetId: "cover-uuid-1"
|
|
draft: true
|
|
---
|
|
|
|
# Kapitel 1: Die Reise beginnt
|
|
|
|
Es war ein stürmischer Tag am Fjord.
|
|
|
|
> Das Licht im Norden ist unvergleichlich. -- Ralf
|
|
|
|

|
|
|
|
`;
|
|
|
|
const parsed = parseJournalMarkdown(markdown);
|
|
|
|
expect(parsed.frontmatter.title).toBe('Nordkap Tour');
|
|
expect(parsed.frontmatter.subtitle).toBe('Auf den Spuren des Nordlichts');
|
|
expect(parsed.frontmatter.draft).toBe(true);
|
|
expect(parsed.frontmatter.coverAssetId).toBe('cover-uuid-1');
|
|
|
|
expect(parsed.blocks).toHaveLength(5);
|
|
expect(parsed.blocks[0]).toEqual({
|
|
type: 'heading',
|
|
level: 1,
|
|
text: 'Kapitel 1: Die Reise beginnt',
|
|
});
|
|
expect(parsed.blocks[1]).toEqual({
|
|
type: 'paragraph',
|
|
html: 'Es war ein stürmischer Tag am Fjord.',
|
|
});
|
|
expect(parsed.blocks[2]).toEqual({
|
|
type: 'quote',
|
|
text: 'Das Licht im Norden ist unvergleichlich.',
|
|
author: 'Ralf',
|
|
});
|
|
expect(parsed.blocks[3]).toEqual({
|
|
type: 'photo',
|
|
assetId: 'asset-uuid-2',
|
|
caption: 'Blick über den Fjord',
|
|
layout: 'fullbleed',
|
|
});
|
|
expect(parsed.blocks[4]).toEqual({
|
|
type: 'photo-pair',
|
|
assetIds: ['pair-1', 'pair-2'],
|
|
caption: 'Zwei Perspektiven',
|
|
});
|
|
|
|
expect(parsed.referencedAssetIds).toContain('cover-uuid-1');
|
|
expect(parsed.referencedAssetIds).toContain('asset-uuid-2');
|
|
expect(parsed.referencedAssetIds).toContain('pair-1');
|
|
expect(parsed.referencedAssetIds).toContain('pair-2');
|
|
});
|
|
|
|
it('serializes parsed journal back to markdown', () => {
|
|
const markdown = `---
|
|
title: "Nordkap"
|
|
draft: true
|
|
---
|
|
|
|
# Title
|
|
|
|
A paragraph of text.
|
|
|
|
`;
|
|
|
|
const parsed = parseJournalMarkdown(markdown);
|
|
const serialized = serializeJournalMarkdown(parsed);
|
|
|
|
expect(serialized).toContain('title: "Nordkap"');
|
|
expect(serialized).toContain('draft: true');
|
|
expect(serialized).toContain('# Title');
|
|
expect(serialized).toContain('A paragraph of text.');
|
|
expect(serialized).toContain('');
|
|
});
|
|
});
|
|
|
|
describe('filesystem CRUD operations', () => {
|
|
it('writes, reads, lists and deletes journal files safely', async () => {
|
|
const testContent = `---
|
|
title: "Vitest Journey"
|
|
date: "2026-08-14"
|
|
draft: false
|
|
---
|
|
|
|
Testing the journal filesystem operations.`;
|
|
|
|
await writeJournalEntry(testSlug, testContent);
|
|
|
|
const entry = await readJournalEntry(testSlug);
|
|
expect(entry).not.toBeNull();
|
|
expect(entry?.parsed.frontmatter.title).toBe('Vitest Journey');
|
|
|
|
const all = await listJournalEntries();
|
|
const found = all.find((e) => e.slug === testSlug);
|
|
expect(found).toBeDefined();
|
|
expect(found?.frontmatter.title).toBe('Vitest Journey');
|
|
|
|
const deleted = await deleteJournalEntry(testSlug);
|
|
expect(deleted).toBe(true);
|
|
|
|
const checkAfter = await readJournalEntry(testSlug);
|
|
expect(checkAfter).toBeNull();
|
|
});
|
|
});
|
|
});
|