+
{info.label}
{info.desc}
+
+ {info.font} · radius {info.radius} · {info.frame}
+
);
@@ -765,7 +915,7 @@ export default function SettingsEditor() {
)}
{l === 'showcase' && (
)}
diff --git a/app/admin/components/journal-studio.css b/app/admin/components/journal-studio.css
new file mode 100644
index 0000000..3909551
--- /dev/null
+++ b/app/admin/components/journal-studio.css
@@ -0,0 +1,485 @@
+/* ── Journal Studio & Editor CSS ──────────────────────────────────── */
+
+/*
+ * The studio is a full-width workspace: it breaks out of the 1120px
+ * `.admin-main` column so the split view has room for editor *and* preview.
+ */
+.admin-main:has(> .journal-studio),
+.admin-main:has(> .journal-editor-container) {
+ max-width: none;
+ padding: 0;
+}
+
+/* ── Form controls ─────────────────────────────────────────────────
+ * Local field styling for the studio. Scoped so the generic class name
+ * cannot leak into the rest of the admin panel.
+ */
+.journal-studio .admin-input,
+.journal-editor-container .admin-input,
+.journal-modal-card .admin-input {
+ display: block;
+ width: 100%;
+ padding: 0.55rem 0.7rem;
+ background: var(--admin-bg-input, #161616);
+ border: 1px solid var(--admin-border, #2e2e2e);
+ border-radius: 8px;
+ color: var(--admin-text, #f0f0f0);
+ font-family: inherit;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ transition:
+ border-color 0.15s ease,
+ background 0.15s ease;
+}
+
+.journal-studio .admin-input:hover,
+.journal-editor-container .admin-input:hover,
+.journal-modal-card .admin-input:hover {
+ border-color: var(--admin-border-hover, #444);
+}
+
+.journal-studio .admin-input:focus,
+.journal-editor-container .admin-input:focus,
+.journal-modal-card .admin-input:focus {
+ outline: none;
+ border-color: var(--admin-border-focus, #6366f1);
+}
+
+.journal-studio .admin-input::placeholder,
+.journal-editor-container .admin-input::placeholder,
+.journal-modal-card .admin-input::placeholder {
+ color: var(--admin-text-secondary, #a0a0a0);
+ opacity: 0.7;
+}
+
+.journal-editor-container textarea.admin-input,
+.journal-modal-card textarea.admin-input {
+ resize: vertical;
+ min-height: 88px;
+}
+
+.journal-editor-container select.admin-input {
+ cursor: pointer;
+ appearance: none;
+ padding-right: 2rem;
+ background-image:
+ linear-gradient(45deg, transparent 50%, currentColor 50%),
+ linear-gradient(135deg, currentColor 50%, transparent 50%);
+ background-position:
+ calc(100% - 16px) calc(50% + 1px),
+ calc(100% - 11px) calc(50% + 1px);
+ background-size: 5px 5px;
+ background-repeat: no-repeat;
+}
+
+.journal-level-select {
+ width: 5.5rem;
+ flex-shrink: 0;
+}
+
+.journal-studio {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: calc(100vh - 70px);
+}
+
+.journal-studio-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1.5rem 2rem;
+ border-bottom: 1px solid var(--admin-border, rgba(255, 255, 255, 0.08));
+}
+
+.journal-studio-header h2 {
+ margin: 0;
+ font-size: 1.5rem;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.journal-list-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ gap: 1.5rem;
+ padding: 2rem;
+}
+
+.journal-admin-card {
+ background: var(--admin-surface, rgba(255, 255, 255, 0.03));
+ border: 1px solid var(--admin-border, rgba(255, 255, 255, 0.08));
+ border-radius: 10px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ transition:
+ border-color 0.2s ease,
+ transform 0.2s ease;
+}
+
+.journal-admin-card:hover {
+ border-color: var(--admin-accent, #6366f1);
+ transform: translateY(-2px);
+}
+
+.journal-admin-card-cover {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ background: rgba(0, 0, 0, 0.2);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.journal-admin-card-cover img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.journal-admin-card-body {
+ padding: 1.25rem;
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+}
+
+.journal-admin-card-title {
+ font-size: 1.15rem;
+ font-weight: 600;
+ margin: 0 0 0.4rem;
+}
+
+.journal-admin-card-slug {
+ font-size: 0.75rem;
+ font-family: monospace;
+ opacity: 0.6;
+ margin-bottom: 0.75rem;
+}
+
+.journal-admin-card-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.75rem;
+ opacity: 0.7;
+ margin-bottom: 1rem;
+ flex-wrap: wrap;
+}
+
+.journal-admin-card-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: auto;
+ padding-top: 1rem;
+ border-top: 1px solid var(--admin-border, rgba(255, 255, 255, 0.06));
+}
+
+/* ── Split-Screen Studio Editor ────────────────────────────────────── */
+
+.journal-editor-container {
+ display: flex;
+ flex-direction: column;
+ height: calc(100vh - 65px);
+ overflow: hidden;
+}
+
+.journal-editor-topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1.5rem;
+ background: var(--admin-surface, #18181b);
+ border-bottom: 1px solid var(--admin-border, rgba(255, 255, 255, 0.08));
+ gap: 1rem;
+ z-index: 10;
+}
+
+.journal-editor-topbar-left {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ flex-grow: 1;
+}
+
+.journal-editor-title-input {
+ font-size: 1.1rem;
+ font-weight: 600;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ color: inherit;
+ padding: 4px 8px;
+ max-width: 400px;
+ width: 100%;
+}
+
+.journal-editor-title-input:focus {
+ border-color: var(--admin-accent, #6366f1);
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.journal-editor-topbar-right {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.journal-editor-split {
+ display: grid;
+ /* Left width is driven by the draggable divider; the preview takes the rest. */
+ grid-template-columns: var(--journal-split-left, 46%) 7px minmax(0, 1fr);
+ flex-grow: 1;
+ overflow: hidden;
+ height: 100%;
+}
+
+/* Kill text selection and pointer flicker while the divider is being dragged. */
+.journal-editor-split.is-dragging {
+ user-select: none;
+ cursor: col-resize;
+}
+
+.journal-editor-split.is-dragging .journal-editor-pane-right {
+ pointer-events: none;
+}
+
+.journal-editor-resizer {
+ position: relative;
+ cursor: col-resize;
+ background: var(--admin-border, rgba(255, 255, 255, 0.08));
+ transition: background 0.15s ease;
+ touch-action: none;
+}
+
+.journal-editor-resizer:hover,
+.journal-editor-resizer:focus-visible,
+.journal-editor-split.is-dragging .journal-editor-resizer {
+ background: var(--admin-accent, #6366f1);
+ outline: none;
+}
+
+/* Grip dots, and a widened invisible hit area so the divider is easy to grab. */
+.journal-editor-resizer-grip {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 15px;
+ height: 44px;
+ border-radius: 4px;
+ background: repeating-linear-gradient(to bottom, currentColor 0 2px, transparent 2px 5px);
+ color: rgba(255, 255, 255, 0.35);
+ background-clip: content-box;
+ padding: 0 6px;
+}
+
+.journal-editor-resizer:hover .journal-editor-resizer-grip {
+ color: #fff;
+}
+
+.journal-editor-pane-left {
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ padding: 1.5rem 2rem 4rem;
+ background: var(--admin-bg, #09090b);
+}
+
+.journal-editor-pane-right {
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ background: var(--surface, #111);
+ position: relative;
+}
+
+.journal-preview-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5rem 1rem;
+ background: rgba(0, 0, 0, 0.4);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+ font-size: 0.75rem;
+ position: sticky;
+ top: 0;
+ z-index: 5;
+ backdrop-filter: blur(8px);
+}
+
+.journal-preview-frame {
+ padding: 2.5rem 2rem;
+ max-width: 1080px;
+ margin: 0 auto;
+ width: 100%;
+ transition: max-width 0.3s ease;
+}
+
+/*
+ * The essay's full-bleed and wide figures are sized against the viewport on the
+ * live site. Inside the preview pane that overshoots — the photo would bleed out
+ * of the pane and drift as the split is dragged — so they are re-anchored to the
+ * frame. 100% is the container's content box, so reaching the pane edges means
+ * adding back both horizontal paddings: 4rem for this frame plus 3rem for
+ * .essay-container. Keep in sync if either padding changes.
+ */
+.journal-preview-frame .essay-container {
+ --essay-bleed: calc(100% + 7rem);
+ --essay-wide: 100%;
+}
+
+.journal-preview-frame.mobile {
+ max-width: 380px;
+ border-left: 1px solid rgba(255, 255, 255, 0.1);
+ border-right: 1px solid rgba(255, 255, 255, 0.1);
+ min-height: 100%;
+ background: var(--background, #000);
+}
+
+.journal-raw-markdown-editor {
+ width: 100%;
+ height: 100%;
+ min-height: 500px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 0.9rem;
+ line-height: 1.6;
+ background: rgba(0, 0, 0, 0.3);
+ color: #f4f4f5;
+ border: 1px solid var(--admin-border, rgba(255, 255, 255, 0.1));
+ border-radius: 8px;
+ padding: 1.25rem;
+ resize: none;
+}
+
+.journal-raw-markdown-editor:focus {
+ outline: none;
+ border-color: var(--admin-accent, #6366f1);
+}
+
+/* Modal styling for Entry creation / Metadata */
+.journal-modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.75);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+ backdrop-filter: blur(4px);
+}
+
+.journal-modal-card {
+ background: var(--admin-surface, #18181b);
+ border: 1px solid var(--admin-border, rgba(255, 255, 255, 0.1));
+ border-radius: 12px;
+ width: 90%;
+ max-width: 620px;
+ max-height: 88vh;
+ overflow-y: auto;
+ padding: 1.75rem;
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
+}
+
+/* ── Card meta & status ───────────────────────────────────────────── */
+
+.journal-admin-card-meta span {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.journal-card-cover-placeholder {
+ opacity: 0.25;
+}
+
+.journal-status-pill {
+ padding: 0.15rem 0.5rem;
+ border-radius: 9999px;
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ opacity: 1;
+}
+
+.journal-status-pill.is-draft {
+ background: rgba(234, 179, 8, 0.15);
+ color: #eab308;
+ border: 1px solid rgba(234, 179, 8, 0.3);
+}
+
+.journal-status-pill.is-published {
+ background: rgba(34, 197, 94, 0.15);
+ color: #22c55e;
+ border: 1px solid rgba(34, 197, 94, 0.3);
+}
+
+.journal-preview-bar-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+/* ── Block field rows ─────────────────────────────────────────────
+ * The generic `.admin-input { width: 100% }` would blow these rows apart,
+ * so the fixed-width controls are pinned here (higher specificity wins).
+ */
+.journal-heading-row,
+.journal-photo-layout-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.journal-editor-container .journal-heading-row .journal-level-select {
+ flex: 0 0 5.5rem;
+ width: 5.5rem;
+}
+
+.journal-editor-container .journal-heading-row input {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.journal-editor-container .journal-photo-layout-row select {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.journal-photo-layout-row .admin-btn {
+ flex-shrink: 0;
+}
+
+/* Stack the split view on narrow screens instead of squeezing both panes. */
+@media (max-width: 1100px) {
+ .journal-editor-split {
+ grid-template-columns: 1fr;
+ grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
+ }
+
+ .journal-editor-resizer {
+ display: none;
+ }
+
+ .journal-editor-pane-left {
+ border-bottom: 1px solid var(--admin-border, rgba(255, 255, 255, 0.08));
+ }
+}
+
+/* An unresolvable photo reference — shown in the block editor, not to visitors. */
+.journal-block-warning {
+ margin: 0 0 0.75rem;
+ padding: 0.55rem 0.7rem;
+ border-radius: 8px;
+ font-size: 0.78rem;
+ line-height: 1.45;
+ color: #fca5a5;
+ background: rgba(239, 68, 68, 0.1);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+}
diff --git a/app/admin/components/useScrollLock.ts b/app/admin/components/useScrollLock.ts
new file mode 100644
index 0000000..d2073f1
--- /dev/null
+++ b/app/admin/components/useScrollLock.ts
@@ -0,0 +1,41 @@
+'use client';
+
+import { useEffect } from 'react';
+
+/**
+ * Freezes the page behind a modal for as long as `active` is true.
+ *
+ * An overlay covers the page but does not absorb wheel events: a scroll that
+ * starts over the backdrop — or one that continues past the end of the modal's
+ * own scroll container — reaches the document and moves the page underneath.
+ * `overscroll-behavior` alone cannot fix that, because it only applies to
+ * scroll containers, and the backdrop is not one.
+ *
+ * Nesting works without a counter: each lock captures whatever inline value it
+ * found and restores exactly that, so a picker opened from the album drawer
+ * restores the drawer's `hidden` rather than clearing it.
+ */
+export function useScrollLock(active: boolean) {
+ useEffect(() => {
+ if (!active) return;
+
+ const { body } = document;
+ const previousOverflow = body.style.overflow;
+ const previousPaddingRight = body.style.paddingRight;
+
+ // Hiding the scrollbar widens the viewport and shifts the page under the
+ // overlay. Pad by exactly the width it gave up so nothing moves. Overlay
+ // scrollbars (most touch devices) measure 0 and need no compensation.
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
+ if (scrollbarWidth > 0) {
+ const current = parseFloat(getComputedStyle(body).paddingRight) || 0;
+ body.style.paddingRight = `${current + scrollbarWidth}px`;
+ }
+ body.style.overflow = 'hidden';
+
+ return () => {
+ body.style.overflow = previousOverflow;
+ body.style.paddingRight = previousPaddingRight;
+ };
+ }, [active]);
+}
diff --git a/app/admin/journal/[slug]/page.tsx b/app/admin/journal/[slug]/page.tsx
new file mode 100644
index 0000000..19875a3
--- /dev/null
+++ b/app/admin/journal/[slug]/page.tsx
@@ -0,0 +1,10 @@
+import { JournalStudio } from '../../components/JournalStudio';
+
+export default async function AdminJournalEntryPage({
+ params,
+}: {
+ params: Promise<{ slug: string }>;
+}) {
+ const { slug } = await params;
+ return
;
+}
diff --git a/app/admin/journal/page.tsx b/app/admin/journal/page.tsx
new file mode 100644
index 0000000..30c4fdf
--- /dev/null
+++ b/app/admin/journal/page.tsx
@@ -0,0 +1,5 @@
+import { JournalStudio } from '../components/JournalStudio';
+
+export default function AdminJournalPage() {
+ return
;
+}
diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx
index 54ca9b7..a8bf1e8 100644
--- a/app/admin/layout.tsx
+++ b/app/admin/layout.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from 'next';
+import AdminShell from './AdminShell';
export const metadata: Metadata = {
title: 'Immich Folio Admin',
@@ -16,5 +17,9 @@ export const metadata: Metadata = {
export const dynamic = 'force-dynamic';
export default function AdminLayout({ children }: { children: React.ReactNode }) {
- return
{children}
;
+ return (
+
+ );
}
diff --git a/app/admin/page.tsx b/app/admin/page.tsx
index 2eb4767..94fad4f 100644
--- a/app/admin/page.tsx
+++ b/app/admin/page.tsx
@@ -1,54 +1,5 @@
-'use client';
+import PageBuilder from './components/PageBuilder';
-import { useState, useEffect } from 'react';
-import AdminLogin from './components/AdminLogin';
-import AdminDashboard from './components/AdminDashboard';
-import './admin.css';
-
-export default function AdminPage() {
- const [authenticated, setAuthenticated] = useState
(null);
- const [enabled, setEnabled] = useState(true);
-
- useEffect(() => {
- checkAuth();
- }, []);
-
- async function checkAuth() {
- try {
- const res = await fetch('/api/admin/auth');
- const data = await res.json();
- setAuthenticated(data.authenticated);
- setEnabled(data.enabled);
- } catch {
- setAuthenticated(false);
- }
- }
-
- if (authenticated === null) {
- return (
-
- );
- }
-
- if (!enabled) {
- return (
-
-
-
Admin Panel
-
- The admin panel is not enabled. Set ADMIN_PASSWORD in your environment
- variables to activate it.
-
-
-
- );
- }
-
- if (!authenticated) {
- return setAuthenticated(true)} />;
- }
-
- return setAuthenticated(false)} />;
+export default function AdminHomePage() {
+ return ;
}
diff --git a/app/admin/pages/page.tsx b/app/admin/pages/page.tsx
new file mode 100644
index 0000000..f6fd6f1
--- /dev/null
+++ b/app/admin/pages/page.tsx
@@ -0,0 +1,5 @@
+import PageBuilder from '../components/PageBuilder';
+
+export default function AdminPagesPage() {
+ return ;
+}
diff --git a/app/admin/settings/[section]/page.tsx b/app/admin/settings/[section]/page.tsx
new file mode 100644
index 0000000..03bf433
--- /dev/null
+++ b/app/admin/settings/[section]/page.tsx
@@ -0,0 +1,10 @@
+import SettingsEditor from '../../components/SettingsEditor';
+
+export default async function AdminSettingsSectionPage({
+ params,
+}: {
+ params: Promise<{ section: string }>;
+}) {
+ const { section } = await params;
+ return ;
+}
diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx
new file mode 100644
index 0000000..5431d73
--- /dev/null
+++ b/app/admin/settings/page.tsx
@@ -0,0 +1,5 @@
+import SettingsEditor from '../components/SettingsEditor';
+
+export default function AdminSettingsPage() {
+ return ;
+}
diff --git a/app/api/admin/__tests__/admin-guards.test.ts b/app/api/admin/__tests__/admin-guards.test.ts
index 6285ade..7b2b59d 100644
--- a/app/api/admin/__tests__/admin-guards.test.ts
+++ b/app/api/admin/__tests__/admin-guards.test.ts
@@ -121,6 +121,64 @@ const ROUTES: {
method: 'POST',
args: () => [new Request('http://localhost/api/admin/backups', { method: 'POST', body: '{}' })],
},
+ {
+ name: 'PUT /api/admin/favicon',
+ path: 'favicon',
+ load: () => import('../favicon/route'),
+ method: 'PUT',
+ args: () => [new Request('http://localhost/api/admin/favicon', { method: 'PUT' })],
+ },
+ {
+ name: 'DELETE /api/admin/favicon',
+ path: 'favicon',
+ load: () => import('../favicon/route'),
+ method: 'DELETE',
+ args: () => [],
+ },
+ {
+ name: 'GET /api/admin/journal',
+ path: 'journal',
+ load: () => import('../journal/route'),
+ method: 'GET',
+ args: () => [],
+ },
+ {
+ name: 'POST /api/admin/journal',
+ path: 'journal',
+ load: () => import('../journal/route'),
+ method: 'POST',
+ args: () => [new Request('http://localhost/api/admin/journal', { method: 'POST', body: '{}' })],
+ },
+ {
+ name: 'GET /api/admin/journal/[slug]',
+ path: 'journal/[slug]',
+ load: () => import('../journal/[slug]/route'),
+ method: 'GET',
+ args: () => [
+ new Request('http://localhost/api/admin/journal/test-slug'),
+ { params: Promise.resolve({ slug: 'test-slug' }) },
+ ],
+ },
+ {
+ name: 'PUT /api/admin/journal/[slug]',
+ path: 'journal/[slug]',
+ load: () => import('../journal/[slug]/route'),
+ method: 'PUT',
+ args: () => [
+ new Request('http://localhost/api/admin/journal/test-slug', { method: 'PUT', body: '{}' }),
+ { params: Promise.resolve({ slug: 'test-slug' }) },
+ ],
+ },
+ {
+ name: 'DELETE /api/admin/journal/[slug]',
+ path: 'journal/[slug]',
+ load: () => import('../journal/[slug]/route'),
+ method: 'DELETE',
+ args: () => [
+ new Request('http://localhost/api/admin/journal/test-slug', { method: 'DELETE' }),
+ { params: Promise.resolve({ slug: 'test-slug' }) },
+ ],
+ },
];
describe('admin route guards', () => {
@@ -165,7 +223,7 @@ describe('admin route guards', () => {
if (entry.name === '__tests__') continue;
walk(full);
} else if (entry.name === 'route.ts') {
- found.push(path.relative(adminDir, path.dirname(full)));
+ found.push(path.relative(adminDir, path.dirname(full)).replace(/\\/g, '/'));
}
}
};
diff --git a/app/api/admin/auth/route.ts b/app/api/admin/auth/route.ts
index bef3f8b..e37d598 100644
--- a/app/api/admin/auth/route.ts
+++ b/app/api/admin/auth/route.ts
@@ -44,7 +44,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Password is too long' }, { status: 400 });
}
- if (!verifyAdminPassword(body.password)) {
+ if (!(await verifyAdminPassword(body.password))) {
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
}
diff --git a/app/api/admin/favicon/route.ts b/app/api/admin/favicon/route.ts
new file mode 100644
index 0000000..ad32c82
--- /dev/null
+++ b/app/api/admin/favicon/route.ts
@@ -0,0 +1,111 @@
+import { NextResponse } from 'next/server';
+import { promises as fs } from 'fs';
+import path from 'path';
+import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
+
+const CONTENT_DIR = path.resolve(process.cwd(), 'content');
+const DEST = 'favicon.svg';
+
+const MAX_SIZE = 512 * 1024; // 512 kB
+
+/**
+ * Write via temp file + rename, the same pattern yaml-service uses: GET
+ * /api/favicon reads this path on every request, so a plain writeFile would
+ * let a concurrent read see a half-written icon.
+ */
+async function writeFavicon(content: Buffer | string): Promise {
+ await fs.mkdir(CONTENT_DIR, { recursive: true });
+ const dest = path.join(CONTENT_DIR, DEST);
+ const tmpPath = `${dest}.${process.pid}.${Date.now()}.tmp`;
+ try {
+ await fs.writeFile(tmpPath, content);
+ await fs.rename(tmpPath, dest);
+ } catch (err) {
+ await fs.unlink(tmpPath).catch(() => {});
+ throw err;
+ }
+}
+
+export async function PUT(request: Request) {
+ if (!isAdminEnabled()) {
+ return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
+ }
+ if (!(await isAdminAuthenticated())) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ let body: FormData;
+ try {
+ body = await request.formData();
+ } catch {
+ return NextResponse.json({ error: 'Invalid form data' }, { status: 400 });
+ }
+
+ // A plain text field named "file" also satisfies `body.get('file')`, and the
+ // .size/.arrayBuffer() calls below would then throw a 500 on what is really
+ // a malformed request.
+ const file = body.get('file');
+ if (!(file instanceof File)) {
+ return NextResponse.json({ error: 'Missing file' }, { status: 400 });
+ }
+
+ if (file.size > MAX_SIZE) {
+ return NextResponse.json({ error: 'File must be under 512 kB' }, { status: 400 });
+ }
+
+ const buf = Buffer.from(await file.arrayBuffer());
+
+ // SVG passes through directly — CSP on the GET route protects the origin.
+ if (file.type === 'image/svg+xml') {
+ const text = buf.toString('utf-8');
+ if (!text.includes('
+
+
+`;
+
+ await writeFavicon(svg);
+ return NextResponse.json({ success: true, message: 'Favicon updated.' });
+}
+
+/** Clean up stale favicon files left by a previous upload of a different format. */
+export async function DELETE() {
+ if (!isAdminEnabled()) {
+ return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
+ }
+ if (!(await isAdminAuthenticated())) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ await fs.unlink(path.join(CONTENT_DIR, DEST));
+ return NextResponse.json({
+ success: true,
+ message: 'Custom favicon removed. Default restored.',
+ });
+ } catch {
+ return NextResponse.json({ error: 'No custom favicon to remove' }, { status: 404 });
+ }
+}
diff --git a/app/api/admin/journal/[slug]/route.ts b/app/api/admin/journal/[slug]/route.ts
new file mode 100644
index 0000000..10eec6a
--- /dev/null
+++ b/app/api/admin/journal/[slug]/route.ts
@@ -0,0 +1,119 @@
+import { NextResponse } from 'next/server';
+import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
+import {
+ isValidSlug,
+ sanitizeSlug,
+ parseJournalMarkdown,
+} from '@/lib/journal';
+import {
+ readJournalEntry,
+ writeJournalEntry,
+ deleteJournalEntry,
+} from '@/lib/admin/journal-service';
+
+interface RouteContext {
+ params: Promise<{ slug: string }>;
+}
+
+export async function GET(request: Request, context: RouteContext) {
+ if (!isAdminEnabled()) {
+ return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
+ }
+ if (!(await isAdminAuthenticated())) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const { slug } = await context.params;
+ if (!isValidSlug(slug)) {
+ return NextResponse.json({ error: 'Invalid slug' }, { status: 400 });
+ }
+
+ try {
+ const entry = await readJournalEntry(slug);
+ if (!entry) {
+ return NextResponse.json({ error: 'Journal entry not found' }, { status: 404 });
+ }
+ return NextResponse.json({ entry });
+ } catch (err) {
+ console.error(`[Admin API] Failed to get journal entry "${slug}":`, err);
+ return NextResponse.json({ error: 'Failed to read journal entry' }, { status: 500 });
+ }
+}
+
+export async function PUT(request: Request, context: RouteContext) {
+ if (!isAdminEnabled()) {
+ return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
+ }
+ if (!(await isAdminAuthenticated())) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const { slug } = await context.params;
+ if (!isValidSlug(slug)) {
+ return NextResponse.json({ error: 'Invalid slug' }, { status: 400 });
+ }
+
+ try {
+ const body = await request.json();
+ const rawMarkdown = body.rawMarkdown ?? body.content;
+
+ if (typeof rawMarkdown !== 'string') {
+ return NextResponse.json({ error: 'Missing rawMarkdown content' }, { status: 400 });
+ }
+
+ // Verify markdown can be parsed safely
+ parseJournalMarkdown(rawMarkdown);
+
+ const targetSlug =
+ typeof body.newSlug === 'string' && body.newSlug
+ ? sanitizeSlug(body.newSlug)
+ : slug;
+
+ if (!isValidSlug(targetSlug)) {
+ return NextResponse.json({ error: 'Invalid target slug' }, { status: 400 });
+ }
+
+ // If renaming, ensure target doesn't already exist
+ if (targetSlug !== slug) {
+ const existing = await readJournalEntry(targetSlug);
+ if (existing) {
+ return NextResponse.json({ error: 'Target slug already exists' }, { status: 409 });
+ }
+ await writeJournalEntry(targetSlug, rawMarkdown);
+ await deleteJournalEntry(slug);
+ } else {
+ await writeJournalEntry(slug, rawMarkdown);
+ }
+
+ const updated = await readJournalEntry(targetSlug);
+ return NextResponse.json({ success: true, entry: updated });
+ } catch (err) {
+ console.error(`[Admin API] Failed to update journal entry "${slug}":`, err);
+ return NextResponse.json({ error: 'Failed to update journal entry' }, { status: 500 });
+ }
+}
+
+export async function DELETE(request: Request, context: RouteContext) {
+ if (!isAdminEnabled()) {
+ return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
+ }
+ if (!(await isAdminAuthenticated())) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ const { slug } = await context.params;
+ if (!isValidSlug(slug)) {
+ return NextResponse.json({ error: 'Invalid slug' }, { status: 400 });
+ }
+
+ try {
+ const deleted = await deleteJournalEntry(slug);
+ if (!deleted) {
+ return NextResponse.json({ error: 'Journal entry not found' }, { status: 404 });
+ }
+ return NextResponse.json({ success: true, deletedSlug: slug });
+ } catch (err) {
+ console.error(`[Admin API] Failed to delete journal entry "${slug}":`, err);
+ return NextResponse.json({ error: 'Failed to delete journal entry' }, { status: 500 });
+ }
+}
diff --git a/app/api/admin/journal/route.ts b/app/api/admin/journal/route.ts
new file mode 100644
index 0000000..df99bd0
--- /dev/null
+++ b/app/api/admin/journal/route.ts
@@ -0,0 +1,75 @@
+import { NextResponse } from 'next/server';
+import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
+import {
+ sanitizeSlug,
+ isValidSlug,
+ type JournalFrontmatter,
+} from '@/lib/journal';
+import {
+ listJournalEntries,
+ writeJournalEntry,
+ readJournalEntry,
+} from '@/lib/admin/journal-service';
+
+export async function GET() {
+ if (!isAdminEnabled()) {
+ return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
+ }
+ if (!(await isAdminAuthenticated())) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const entries = await listJournalEntries();
+ return NextResponse.json({ entries });
+ } catch (err) {
+ console.error('[Admin API] Failed to list journal entries:', err);
+ return NextResponse.json({ error: 'Failed to list journal entries' }, { status: 500 });
+ }
+}
+
+export async function POST(request: Request) {
+ if (!isAdminEnabled()) {
+ return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
+ }
+ if (!(await isAdminAuthenticated())) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+ }
+
+ try {
+ const body = await request.json();
+ const rawTitle = typeof body.title === 'string' ? body.title.trim() : 'Untitled';
+ const slug = sanitizeSlug(typeof body.slug === 'string' && body.slug ? body.slug : rawTitle);
+
+ if (!isValidSlug(slug)) {
+ return NextResponse.json({ error: 'Invalid slug' }, { status: 400 });
+ }
+
+ const existing = await readJournalEntry(slug);
+ if (existing) {
+ return NextResponse.json({ error: 'A journal entry with this slug already exists' }, { status: 409 });
+ }
+
+ const frontmatter: JournalFrontmatter = {
+ title: rawTitle,
+ date: new Date().toISOString().slice(0, 10),
+ draft: true,
+ ...(typeof body.subtitle === 'string' && body.subtitle ? { subtitle: body.subtitle } : {}),
+ ...(typeof body.author === 'string' && body.author ? { author: body.author } : {}),
+ ...(typeof body.coverAssetId === 'string' && body.coverAssetId ? { coverAssetId: body.coverAssetId } : {}),
+ };
+
+ let initialMarkdown = body.content;
+ if (typeof initialMarkdown !== 'string' || !initialMarkdown) {
+ initialMarkdown = `---\ntitle: "${frontmatter.title}"\ndate: "${frontmatter.date}"\ndraft: true\n---\n\nWrite your story here...`;
+ }
+
+ await writeJournalEntry(slug, initialMarkdown);
+ const created = await readJournalEntry(slug);
+
+ return NextResponse.json({ success: true, entry: created }, { status: 201 });
+ } catch (err) {
+ console.error('[Admin API] Failed to create journal entry:', err);
+ return NextResponse.json({ error: 'Failed to create journal entry' }, { status: 500 });
+ }
+}
diff --git a/app/api/auth/route.ts b/app/api/auth/route.ts
index c12983c..3356d95 100644
--- a/app/api/auth/route.ts
+++ b/app/api/auth/route.ts
@@ -46,14 +46,15 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid password format or length' }, { status: 400 });
}
- if (type !== 'subpage' && type !== 'album') {
+ if (type !== 'subpage' && type !== 'album' && type !== 'journal') {
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
}
if (!isProtected(slug, type)) {
+ const typeLabel = type === 'journal' ? 'Journal entry' : type === 'subpage' ? 'Subpage' : 'Album';
return NextResponse.json(
{
- error: `${type === 'subpage' ? 'Subpage' : 'Album'} is not password-protected`,
+ error: `${typeLabel} is not password-protected`,
},
{ status: 400 },
);
diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts
new file mode 100644
index 0000000..5cc72d1
--- /dev/null
+++ b/app/api/favicon/route.ts
@@ -0,0 +1,48 @@
+import { NextResponse } from 'next/server';
+import { promises as fs } from 'fs';
+import path from 'path';
+
+const PUBLIC_DIR = path.resolve(process.cwd(), 'public');
+const CONTENT_DIR = path.resolve(process.cwd(), 'content');
+
+export const dynamic = 'force-dynamic';
+
+export async function GET(request: Request) {
+ // Prefer the uploaded favicon in content/ (writable, mounted volume).
+ // Fall back to the bundled default in public/.
+ let filePath = path.join(CONTENT_DIR, 'favicon.svg');
+ let stat;
+ try {
+ stat = await fs.stat(filePath);
+ } catch {
+ filePath = path.join(PUBLIC_DIR, 'favicon.svg');
+ try {
+ stat = await fs.stat(filePath);
+ } catch {
+ return new NextResponse(null, { status: 404 });
+ }
+ }
+
+ // The URL is fixed but its content is not — an upload replaces the file in
+ // place. A far-future `immutable` would hide the new icon for as long as the
+ // browser kept the old one, so revalidate every time and answer with a 304
+ // when nothing changed. The ETag is derived from the file itself, so it moves
+ // the moment the admin uploads a new one.
+ const etag = `"${stat.mtimeMs.toString(36)}-${stat.size.toString(36)}"`;
+ const headers = {
+ 'Content-Type': 'image/svg+xml',
+ 'Cache-Control': 'public, max-age=0, must-revalidate',
+ ETag: etag,
+ // /api routes are excluded from the CSP nonce in proxy.ts, so the
+ // response must carry its own policy. An uploaded SVG must not be
+ // allowed to execute script on the origin.
+ 'Content-Security-Policy': "default-src 'none'; sandbox",
+ };
+
+ if (request.headers.get('if-none-match') === etag) {
+ return new NextResponse(null, { status: 304, headers });
+ }
+
+ const buf = await fs.readFile(filePath);
+ return new NextResponse(new Uint8Array(buf), { status: 200, headers });
+}
diff --git a/app/api/install/albums/route.ts b/app/api/install/albums/route.ts
new file mode 100644
index 0000000..d31d4c5
--- /dev/null
+++ b/app/api/install/albums/route.ts
@@ -0,0 +1,91 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { isInstalled, normalizeApiBase, validateSetupToken } from '@/lib/install';
+import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
+
+/** Album discovery attempts per minute per IP — only live before install. */
+const INSTALL_ALBUMS_RPM = 30;
+
+interface AlbumSummary {
+ id: string;
+ albumName: string;
+ description: string;
+ assetCount: number;
+}
+
+/**
+ * POST: list shared Immich albums for the wizard's picker, using the
+ * credentials the user just typed. The configured Immich client cannot be used
+ * here — it reads getConfig(), which is still in setup mode.
+ */
+export async function POST(request: NextRequest) {
+ if (isInstalled()) {
+ return NextResponse.json({ error: 'Setup is already complete' }, { status: 403 });
+ }
+
+ const token = request.nextUrl.searchParams.get('token') ?? request.headers.get('x-setup-token');
+ if (!validateSetupToken(token)) {
+ return NextResponse.json({ error: 'Invalid or missing setup token' }, { status: 403 });
+ }
+
+ const ip = getClientIp(request);
+ const rl = checkRateLimit(`install-albums:${ip}`, INSTALL_ALBUMS_RPM);
+ if (!rl.success) {
+ return NextResponse.json(
+ { error: 'Too many requests' },
+ {
+ status: 429,
+ headers: { 'Retry-After': String(retryAfterSeconds(rl.resetAt)) },
+ },
+ );
+ }
+
+ const body = await request.json().catch(() => null);
+ const apiUrl = typeof body?.apiUrl === 'string' ? body.apiUrl.trim() : '';
+ const apiKey = typeof body?.apiKey === 'string' ? body.apiKey.trim() : '';
+
+ if (!apiUrl || !apiKey) {
+ return NextResponse.json({ error: 'Immich URL and API key are required' }, { status: 400 });
+ }
+
+ const apiBase = normalizeApiBase(apiUrl);
+ if (!apiBase) {
+ return NextResponse.json({ error: 'Invalid Immich URL' }, { status: 400 });
+ }
+
+ try {
+ const res = await fetch(`${apiBase}/albums?shared=true`, {
+ headers: {
+ 'x-api-key': apiKey,
+ Accept: 'application/json',
+ },
+ signal: AbortSignal.timeout(15_000),
+ });
+
+ if (!res.ok) {
+ return NextResponse.json(
+ { error: `Immich returned HTTP ${res.status} — check the URL and API key` },
+ { status: 502 },
+ );
+ }
+
+ const contentType = res.headers.get('Content-Type') || '';
+ if (!contentType.includes('application/json')) {
+ return NextResponse.json(
+ { error: 'Immich returned a non-JSON response — is the URL a server base URL?' },
+ { status: 502 },
+ );
+ }
+
+ const albums = (await res.json()) as AlbumSummary[];
+ return NextResponse.json({
+ albums: albums.map((album) => ({
+ id: album.id,
+ albumName: album.albumName,
+ assetCount: album.assetCount,
+ description: album.description || '',
+ })),
+ });
+ } catch {
+ return NextResponse.json({ error: 'Could not reach Immich at this URL' }, { status: 502 });
+ }
+}
diff --git a/app/api/install/route.ts b/app/api/install/route.ts
new file mode 100644
index 0000000..5d6c051
--- /dev/null
+++ b/app/api/install/route.ts
@@ -0,0 +1,116 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { revalidatePath } from 'next/cache';
+import { completeInstall, isInstalled, normalizeApiBase, validateSetupToken } from '@/lib/install';
+import { invalidateConfigCache } from '@/lib/config';
+import { immich } from '@/lib/immich';
+import { checkRateLimit, getClientIp, retryAfterSeconds } from '@/lib/rate-limit';
+
+/** Install submissions per minute per IP — only live before install. */
+const INSTALL_RPM = 10;
+
+const THEME_PRESETS = [
+ 'studio',
+ 'studio-modern',
+ 'minimal',
+ 'editorial',
+ 'classic',
+ 'noir',
+ 'monograph',
+];
+const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+/** POST: run the install wizard's final step — verify, then write config. */
+export async function POST(request: NextRequest) {
+ if (isInstalled()) {
+ return NextResponse.json({ error: 'Setup is already complete' }, { status: 403 });
+ }
+
+ const token = request.nextUrl.searchParams.get('token') ?? request.headers.get('x-setup-token');
+ if (!validateSetupToken(token)) {
+ return NextResponse.json({ error: 'Invalid or missing setup token' }, { status: 403 });
+ }
+
+ const ip = getClientIp(request);
+ const rl = checkRateLimit(`install:${ip}`, INSTALL_RPM);
+ if (!rl.success) {
+ return NextResponse.json(
+ { error: 'Too many requests' },
+ {
+ status: 429,
+ headers: { 'Retry-After': String(retryAfterSeconds(rl.resetAt)) },
+ },
+ );
+ }
+
+ const body = await request.json().catch(() => null);
+ if (!body) {
+ return NextResponse.json({ error: 'Missing request body' }, { status: 400 });
+ }
+
+ const apiUrl = typeof body.apiUrl === 'string' ? body.apiUrl.trim() : '';
+ const apiKey = typeof body.apiKey === 'string' ? body.apiKey.trim() : '';
+ if (!apiUrl || !apiKey) {
+ return NextResponse.json({ error: 'Immich URL and API key are required' }, { status: 400 });
+ }
+
+ const apiBase = normalizeApiBase(apiUrl);
+ if (!apiBase) {
+ return NextResponse.json({ error: 'Invalid Immich URL' }, { status: 400 });
+ }
+
+ const theme = THEME_PRESETS.includes(body.theme) ? body.theme : 'studio';
+
+ // Album selection is optional: an empty gallery is a valid install.
+ const rawAlbums: unknown[] = Array.isArray(body.albums) ? (body.albums as unknown[]) : [];
+ const albums: string[] = rawAlbums
+ .filter((id): id is string => typeof id === 'string' && UUID_REGEX.test(id.trim()))
+ .map((id) => id.trim());
+
+ let adminPassword = '';
+ if (typeof body.adminPassword === 'string' && body.adminPassword.length > 0) {
+ adminPassword = body.adminPassword;
+ if (adminPassword.length > 1024) {
+ return NextResponse.json({ error: 'Admin password is too long' }, { status: 400 });
+ }
+ }
+
+ // Verify the credentials against Immich before writing anything, so a typo
+ // does not produce an "installed" app that cannot load a single photo.
+ try {
+ const res = await fetch(`${apiBase}/server/ping`, {
+ headers: { 'x-api-key': apiKey, Accept: 'application/json' },
+ signal: AbortSignal.timeout(15_000),
+ });
+ if (!res.ok) {
+ return NextResponse.json(
+ { error: `Immich rejected the connection (HTTP ${res.status})` },
+ { status: 502 },
+ );
+ }
+ } catch {
+ return NextResponse.json({ error: 'Could not reach Immich at this URL' }, { status: 502 });
+ }
+
+ try {
+ await completeInstall({
+ apiUrl,
+ apiKey,
+ siteTitle: typeof body.siteTitle === 'string' ? body.siteTitle : undefined,
+ siteSubtitle: typeof body.siteSubtitle === 'string' ? body.siteSubtitle : undefined,
+ theme,
+ albums,
+ adminPassword,
+ });
+
+ // The wizard wrote new YAML; the running process must not keep serving
+ // setup mode. The install.json mtime cache is dropped inside completeInstall.
+ invalidateConfigCache();
+ immich.invalidateAll();
+ revalidatePath('/', 'layout');
+
+ return NextResponse.json({ success: true });
+ } catch (err) {
+ console.error('[Install] Failed to write configuration files:', err);
+ return NextResponse.json({ error: 'Failed to write configuration files' }, { status: 500 });
+ }
+}
diff --git a/app/globals.css b/app/globals.css
index 83a9977..3839440 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1210,8 +1210,7 @@
border: 1px solid var(--border-subtle);
background: var(--bg-card);
color: var(--text-secondary);
- font-size: 1.1rem;
- font-weight: 300;
+ padding: 0;
display: flex;
align-items: center;
justify-content: center;
@@ -1227,6 +1226,26 @@
color var(--transition-fast);
}
+.scroll-to-top__icon {
+ width: 18px;
+ height: 18px;
+ transition: transform var(--transition-fast);
+}
+
+.scroll-to-top:hover .scroll-to-top__icon {
+ transform: translateY(-2px);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .scroll-to-top__icon {
+ transition: none;
+ }
+
+ .scroll-to-top:hover .scroll-to-top__icon {
+ transform: none;
+ }
+}
+
.scroll-to-top--visible {
opacity: 1;
pointer-events: auto;
diff --git a/app/install/InstallWizard.tsx b/app/install/InstallWizard.tsx
new file mode 100644
index 0000000..aded49c
--- /dev/null
+++ b/app/install/InstallWizard.tsx
@@ -0,0 +1,458 @@
+'use client';
+
+import Link from 'next/link';
+import { useMemo, useState } from 'react';
+import '../admin/admin.css';
+import './install.css';
+
+interface Album {
+ id: string;
+ albumName: string;
+ assetCount: number;
+ description: string;
+}
+
+interface Props {
+ initialApiUrl: string;
+ setupToken: string;
+}
+
+const STEPS = ['Connect', 'Albums', 'Site', 'Finish'] as const;
+
+const THEME_OPTIONS = [
+ { value: 'studio', label: 'Studio', description: 'Classic portfolio, red accent' },
+ { value: 'studio-modern', label: 'Studio Modern', description: 'Clean with soft corners' },
+ { value: 'minimal', label: 'Minimal', description: 'Bare-bones, monochrome' },
+ { value: 'editorial', label: 'Editorial', description: 'Magazine-style serif' },
+ { value: 'classic', label: 'Classic', description: 'Warm gold, traditional' },
+ { value: 'noir', label: 'Noir', description: 'Dark, high-contrast' },
+ { value: 'monograph', label: 'Monograph', description: 'Typographic, text-first' },
+];
+
+export function InstallWizard({ initialApiUrl, setupToken }: Props) {
+ const [step, setStep] = useState(0);
+
+ // Step 1 — connection
+ const [apiUrl, setApiUrl] = useState(initialApiUrl);
+ const [apiKey, setApiKey] = useState('');
+ const [testing, setTesting] = useState(false);
+ const [testError, setTestError] = useState('');
+
+ // Step 2 — album selection (optional)
+ const [albums, setAlbums] = useState([]);
+ const [selected, setSelected] = useState>({});
+ const [search, setSearch] = useState('');
+ const [loaded, setLoaded] = useState(false);
+
+ // Step 3 — site settings
+ const [siteTitle, setSiteTitle] = useState('');
+ const [siteSubtitle, setSiteSubtitle] = useState('');
+ const [theme, setTheme] = useState('studio');
+ const [adminPassword, setAdminPassword] = useState('');
+ const [adminConfirm, setAdminConfirm] = useState('');
+ const [passwordError, setPasswordError] = useState('');
+
+ // Step 4 — finish
+ const [installing, setInstalling] = useState(false);
+ const [installError, setInstallError] = useState('');
+ const [done, setDone] = useState(false);
+
+ const selectedCount = Object.values(selected).filter(Boolean).length;
+
+ const filteredAlbums = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ if (!q) return albums;
+ return albums.filter((a) => a.albumName.toLowerCase().includes(q));
+ }, [albums, search]);
+
+ async function testConnection() {
+ setTesting(true);
+ setTestError('');
+ try {
+ const res = await fetch('/api/install/albums', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'x-setup-token': setupToken },
+ body: JSON.stringify({ apiUrl: apiUrl.trim(), apiKey: apiKey.trim() }),
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ setTestError(data.error || 'Could not connect to Immich');
+ return;
+ }
+ setAlbums(data.albums || []);
+ setLoaded(true);
+ setStep(1);
+ } catch {
+ setTestError('Could not reach the install service');
+ } finally {
+ setTesting(false);
+ }
+ }
+
+ function toggleAlbum(id: string) {
+ setSelected((prev) => ({ ...prev, [id]: !prev[id] }));
+ }
+
+ function validateSiteStep(): boolean {
+ if (adminPassword && adminPassword !== adminConfirm) {
+ setPasswordError('Passwords do not match');
+ return false;
+ }
+ setPasswordError('');
+ return true;
+ }
+
+ async function runInstall() {
+ setInstalling(true);
+ setInstallError('');
+ try {
+ const res = await fetch('/api/install', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'x-setup-token': setupToken },
+ body: JSON.stringify({
+ apiUrl: apiUrl.trim(),
+ apiKey: apiKey.trim(),
+ siteTitle,
+ siteSubtitle,
+ theme,
+ albums: Object.keys(selected).filter((id) => selected[id]),
+ ...(adminPassword ? { adminPassword } : {}),
+ }),
+ });
+ const data = await res.json();
+ if (!res.ok) {
+ setInstallError(data.error || 'Install failed');
+ return;
+ }
+ setDone(true);
+ } catch {
+ setInstallError('Could not reach the install service');
+ } finally {
+ setInstalling(false);
+ }
+ }
+
+ if (done) {
+ return (
+
+
+
+
+
Immich Folio
+
Install complete
+
Your portfolio is ready. Add or rearrange albums any time from the admin panel.
+
+ {/* install-body carries the card's padding — every other step
+ renders inside it, and skipping it here left the buttons
+ flush against the bottom edge. */}
+
+
+
+ Visit your gallery
+
+ {adminPassword && (
+
+ Open admin panel
+
+ )}
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
Immich Folio
+
Install wizard
+
Connect your Immich server, choose what to show, and publish your portfolio.
+
+
+
+ {STEPS.map((label, i) => (
+
+ {i < step ? '✓' : i + 1}
+ {label}
+
+ ))}
+
+
+
+ {step === 0 && (
+
+ )}
+
+ {step === 1 && (
+
+
+ Pick albums to publish. This step is optional — you can add
+ albums later from the admin panel.
+
+
+ {albums.length === 0 ? (
+
+
+ {loaded
+ ? 'No shared albums found on this Immich server.'
+ : 'Albums could not be loaded.'}
+
+
+ Immich only lists albums that have been shared. You can still continue and add
+ them later.
+
+
+ ) : (
+ <>
+
+ setSearch(e.target.value)}
+ placeholder="Search albums…"
+ aria-label="Search albums"
+ />
+
+
+
+ >
+ )}
+
+
+ setStep(0)} disabled={installing}>
+ Back
+
+ setStep(2)}
+ disabled={installing}
+ >
+ {selectedCount > 0
+ ? `Continue with ${selectedCount} album${selectedCount === 1 ? '' : 's'}`
+ : 'Skip — continue with no albums'}
+
+
+
+ )}
+
+ {step === 2 && (
+
+ )}
+
+ {step === 3 && (
+
+
+
+
Immich server
+ {apiUrl}
+
+
+
Albums
+
+ {selectedCount > 0
+ ? `${selectedCount} selected`
+ : 'None — you can add them later'}
+
+
+
+
Site title
+ {siteTitle || 'Gallery'}
+
+
+
Theme
+ {THEME_OPTIONS.find((t) => t.value === theme)?.label}
+
+
+
Admin panel
+ {adminPassword ? 'Enabled' : 'Disabled'}
+
+
+
+ {installError &&
{installError}
}
+
+
+ setStep(2)} disabled={installing}>
+ Back
+
+
+ {installing ? 'Installing…' : 'Install'}
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/install/install.css b/app/install/install.css
new file mode 100644
index 0000000..cd3ffc4
--- /dev/null
+++ b/app/install/install.css
@@ -0,0 +1,280 @@
+/* ── Install Wizard Styles ───────────────────────────────────── */
+/* Sits on top of the admin theme variables (.admin-layout scope). */
+
+.install-page {
+ min-height: 100dvh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem 1rem;
+}
+
+.install-card {
+ width: 100%;
+ max-width: 560px;
+ background: var(--admin-bg-raised);
+ border: 1px solid var(--admin-border);
+ border-radius: 10px;
+ overflow: hidden;
+}
+
+.install-card__header {
+ padding: 1.75rem 2rem 1.25rem;
+ border-bottom: 1px solid var(--admin-border);
+}
+
+.install-card__kicker {
+ display: block;
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: var(--admin-accent);
+ margin-bottom: 0.4rem;
+}
+
+.install-card__header h1 {
+ margin: 0 0 0.4rem;
+ font-size: 1.5rem;
+ letter-spacing: -0.01em;
+}
+
+.install-card__header p {
+ margin: 0;
+ font-size: 0.875rem;
+ color: var(--admin-text-secondary);
+ line-height: 1.5;
+}
+
+/* ── Step indicator ──────────────────────────────────────────── */
+
+.install-steps {
+ list-style: none;
+ margin: 0;
+ padding: 1rem 2rem;
+ display: flex;
+ gap: 1.5rem;
+ border-bottom: 1px solid var(--admin-border);
+}
+
+.install-steps li {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ color: var(--admin-text-muted);
+ font-size: 0.75rem;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.install-steps__dot {
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.7rem;
+ border: 1px solid var(--admin-border);
+ background: var(--admin-bg-input);
+}
+
+.install-steps li.is-active {
+ color: var(--admin-text);
+}
+
+.install-steps li.is-active .install-steps__dot {
+ border-color: var(--admin-accent);
+ color: var(--admin-accent);
+}
+
+.install-steps li.is-done .install-steps__dot {
+ background: var(--admin-accent);
+ border-color: var(--admin-accent);
+ color: #ffffff;
+}
+
+/* ── Body ────────────────────────────────────────────────────── */
+
+.install-body {
+ padding: 1.5rem 2rem 2rem;
+}
+
+.install-form {
+ display: flex;
+ flex-direction: column;
+}
+
+.install-form .admin-field {
+ margin-bottom: 1rem;
+}
+
+.admin-hint {
+ font-size: 0.78rem;
+ color: var(--admin-text-muted);
+ margin: 0.35rem 0 0;
+ line-height: 1.5;
+}
+
+.admin-hint code {
+ background: var(--admin-bg-elevated);
+ padding: 0.1em 0.35em;
+ border-radius: 4px;
+ color: var(--admin-text-secondary);
+}
+
+.install-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.6rem;
+ margin-top: 1.5rem;
+}
+
+.install-actions .admin-btn {
+ text-decoration: none;
+}
+
+/* ── Albums ──────────────────────────────────────────────────── */
+
+.install-albums__list {
+ list-style: none;
+ margin: 0.75rem 0 0;
+ padding: 0;
+ max-height: 320px;
+ overflow-y: auto;
+ border: 1px solid var(--admin-border);
+ border-radius: 6px;
+}
+
+.install-albums__list li + li {
+ border-top: 1px solid var(--admin-border);
+}
+
+.install-albums__item {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0.6rem 0.75rem;
+ cursor: pointer;
+ font-size: 0.875rem;
+}
+
+.install-albums__item:hover {
+ background: var(--admin-bg-elevated);
+}
+
+.install-albums__item input[type='checkbox'] {
+ width: 16px;
+ height: 16px;
+ accent-color: var(--admin-btn-primary-bg);
+ flex-shrink: 0;
+}
+
+.install-albums__name {
+ flex: 1;
+ color: var(--admin-text);
+}
+
+.install-albums__meta {
+ font-size: 0.75rem;
+ color: var(--admin-text-muted);
+}
+
+.install-empty {
+ padding: 0.75rem 0.5rem;
+ color: var(--admin-text-muted);
+ font-size: 0.875rem;
+ line-height: 1.5;
+}
+
+.install-empty p {
+ margin: 0 0 0.35rem;
+}
+
+.install-empty .admin-hint {
+ margin: 0;
+}
+
+/* ── Divider ─────────────────────────────────────────────────── */
+
+.install-divider {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin: 0.75rem 0 1rem;
+ color: var(--admin-text-muted);
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.install-divider::before,
+.install-divider::after {
+ content: '';
+ flex: 1;
+ height: 1px;
+ background: var(--admin-border);
+}
+
+/* ── Review ──────────────────────────────────────────────────── */
+
+.install-review__list {
+ margin: 0;
+ border: 1px solid var(--admin-border);
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.install-review__list > div {
+ display: flex;
+ gap: 1rem;
+ padding: 0.65rem 0.9rem;
+}
+
+.install-review__list > div + div {
+ border-top: 1px solid var(--admin-border);
+}
+
+.install-review__list dt {
+ flex: 0 0 120px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--admin-text-secondary);
+ padding-top: 0.1rem;
+}
+
+.install-review__list dd {
+ margin: 0;
+ font-size: 0.875rem;
+ color: var(--admin-text);
+ word-break: break-word;
+}
+
+/* ── Responsive ──────────────────────────────────────────────── */
+
+@media (max-width: 480px) {
+ .install-steps {
+ padding: 0.85rem 1rem;
+ gap: 1rem;
+ }
+
+ .install-steps__label {
+ display: none;
+ }
+
+ .install-body {
+ padding: 1.25rem 1rem 1.5rem;
+ }
+
+ .install-review__list > div {
+ flex-direction: column;
+ gap: 0.2rem;
+ }
+
+ .install-review__list dt {
+ flex: none;
+ }
+}
diff --git a/app/install/page.tsx b/app/install/page.tsx
new file mode 100644
index 0000000..d5ea838
--- /dev/null
+++ b/app/install/page.tsx
@@ -0,0 +1,70 @@
+import type { Metadata } from 'next';
+import { redirect } from 'next/navigation';
+import {
+ isInstalled,
+ getInstallCredentials,
+ getSetupToken,
+ validateSetupToken,
+} from '@/lib/install';
+import { InstallWizard } from './InstallWizard';
+
+export const metadata: Metadata = {
+ title: 'Install Immich Folio',
+ robots: { index: false, follow: false },
+};
+
+/**
+ * Rendered per request so the CSP nonce issued by proxy.ts lands on the script
+ * tags — same reason /admin is force-dynamic. Also what lets the redirect once
+ * setup is complete take effect on the next navigation, not a future build.
+ */
+export const dynamic = 'force-dynamic';
+
+export default async function InstallPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ token?: string }>;
+}) {
+ if (isInstalled()) {
+ redirect('/');
+ }
+
+ const { token } = await searchParams;
+ if (!validateSetupToken(token ?? null)) {
+ // Force evaluation of the token so it is logged.
+ getSetupToken();
+ return (
+
+
+
+ Setup Token Required
+
+
+ Check your server logs for the one-time setup token and append ?token= to
+ this URL.
+
+
+
+ );
+ }
+
+ const creds = getInstallCredentials();
+
+ return ;
+}
diff --git a/app/journal/[slug]/page.tsx b/app/journal/[slug]/page.tsx
new file mode 100644
index 0000000..59f3a5e
--- /dev/null
+++ b/app/journal/[slug]/page.tsx
@@ -0,0 +1,203 @@
+import { notFound } from 'next/navigation';
+import { cookies } from 'next/headers';
+import type { Metadata } from 'next';
+import { readJournalEntry } from '@/lib/admin/journal-service';
+import type { ParsedJournal } from '@/lib/journal';
+import { isAdminAuthenticated } from '@/lib/admin/auth';
+import { getConfig } from '@/lib/config';
+import { immich } from '@/lib/immich';
+import {
+ imageUrl,
+ exifUrl,
+ assetPlaceholder,
+ assetAspectRatio,
+ assetExifSummary,
+} from '@/lib/urls';
+import { encodeAssetId } from '@/lib/tokens';
+import { EssayView } from '@/app/[...path]/EssayView';
+import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
+import type { ImmichAsset } from '@/lib/immich';
+import PasswordGate from '@/components/PasswordGate';
+import { BackLink } from '@/components/BackLink';
+import { verifyScrypt, isScryptHash } from '@/lib/password';
+import crypto from 'crypto';
+
+export const dynamic = 'force-dynamic';
+
+interface JournalDetailPageProps {
+ params: Promise<{ slug: string }>;
+}
+
+export async function generateMetadata({ params }: JournalDetailPageProps): Promise {
+ const { slug } = await params;
+ const entry = await readJournalEntry(slug);
+ if (!entry) return { title: 'Not Found' };
+
+ const { frontmatter } = entry.parsed;
+ const title = frontmatter.title || slug;
+ const description = frontmatter.subtitle || 'Journal entry on Immich Folio';
+
+ const ogImages = frontmatter.coverAssetId
+ ? [{ url: imageUrl(frontmatter.coverAssetId, 'preview') }]
+ : [];
+
+ return {
+ title,
+ description,
+ openGraph: {
+ title,
+ description,
+ images: ogImages,
+ },
+ };
+}
+
+/** Check if journal entry password cookie is valid */
+function isJournalAuthenticated(
+ slug: string,
+ storedPassword?: string,
+ cookieVal?: string,
+): boolean {
+ if (!storedPassword) return true;
+ if (!cookieVal) return false;
+
+ const sep = cookieVal.indexOf('.');
+ if (sep === -1) return false;
+
+ const expiresAt = Number(cookieVal.slice(0, sep));
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return false;
+
+ const hmac = crypto
+ .createHmac('sha256', getConfig().authSecret)
+ .update(`${slug}:${storedPassword}:${expiresAt}`)
+ .digest('hex');
+
+ const expected = `${expiresAt}.${hmac}`;
+ try {
+ const a = Buffer.from(cookieVal, 'utf8');
+ const b = Buffer.from(expected, 'utf8');
+ if (a.length !== b.length) return false;
+ return crypto.timingSafeEqual(a, b);
+ } catch {
+ return false;
+ }
+}
+
+export default async function JournalDetailPage({ params }: JournalDetailPageProps) {
+ const { slug } = await params;
+ const entry = await readJournalEntry(slug);
+
+ if (!entry) {
+ notFound();
+ }
+
+ const { frontmatter, blocks, referencedAssetIds } = entry.parsed;
+ const isAuthedAdmin = await isAdminAuthenticated();
+
+ // If draft, only visible to authenticated admin
+ if (frontmatter.draft && !isAuthedAdmin) {
+ notFound();
+ }
+
+ // Password protection check
+ if (frontmatter.password) {
+ const cookieStore = await cookies();
+ const cookieVal = cookieStore.get(`lb_auth_journal_${slug}`)?.value;
+
+ if (!isJournalAuthenticated(slug, frontmatter.password, cookieVal)) {
+ return ;
+ }
+ }
+
+ const config = getConfig();
+
+ // Fetch all referenced assets from Immich
+ const assetPromises = referencedAssetIds.map(async (assetId) => {
+ try {
+ const asset = await immich.getAssetInfo(assetId);
+ return asset;
+ } catch {
+ return null;
+ }
+ });
+
+ const rawAssets = (await Promise.all(assetPromises)).filter((a): a is ImmichAsset => a !== null);
+
+ // EssayView drops photo blocks it cannot resolve, which is right for visitors
+ // but leaves no trace of *why* a photo vanished. Legacy positional references
+ // ("1", "2") are the usual cause: they only resolved against a subpage album,
+ // and a standalone journal entry has none.
+ if (rawAssets.length < referencedAssetIds.length) {
+ const resolved = new Set(rawAssets.map((a) => a.id));
+ const missing = referencedAssetIds.filter((id) => !resolved.has(id));
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[journal] ${slug}: ${missing.length} photo reference(s) could not be resolved and will not render: ${missing.join(', ')}`,
+ );
+ }
+
+ /*
+ * EssayView resolves a block's photo through PhotoItem.id, and those ids are
+ * encrypted tokens — while the blocks coming out of the Markdown carry raw
+ * asset UUIDs. Without this translation every photo block fails its lookup and
+ * is dropped, so a correctly authored entry renders text only. Doing it here
+ * also keeps raw UUIDs out of the client payload, per the project rule that
+ * they must never reach the browser. Unresolvable references collapse to an
+ * empty string, which EssayView skips.
+ */
+ const tokenByAssetId = new Map(rawAssets.map((a) => [a.id, encodeAssetId(a.id)]));
+ const toToken = (assetId: string) => tokenByAssetId.get(assetId) ?? '';
+
+ const essayForClient: ParsedJournal = {
+ frontmatter: {
+ ...frontmatter,
+ coverAssetId: frontmatter.coverAssetId ? toToken(frontmatter.coverAssetId) : undefined,
+ },
+ blocks: blocks.map((block) => {
+ if (block.type === 'photo') {
+ return { ...block, assetId: toToken(block.assetId) };
+ }
+ if (block.type === 'photo-pair') {
+ return {
+ ...block,
+ assetIds: [toToken(block.assetIds[0]), toToken(block.assetIds[1])] as [string, string],
+ };
+ }
+ return block;
+ }),
+ referencedAssetIds: rawAssets.map((a) => encodeAssetId(a.id)),
+ };
+
+ const images: PhotoItem[] = rawAssets
+ .filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO')
+ .map((a) => {
+ const ph = assetPlaceholder(a);
+ const exif = config.exifOnHover && a.type === 'IMAGE' ? assetExifSummary(a) : undefined;
+ const isVideo = a.type === 'VIDEO';
+ return {
+ id: encodeAssetId(a.id),
+ type: isVideo ? 'video' : 'image',
+ thumbUrl: imageUrl(a.id, 'preview'),
+ previewUrl: imageUrl(a.id, 'preview'),
+ exifUrl: exifUrl(a.id),
+ ...(ph ? { blurDataURL: ph.blurDataURL, dominantColor: ph.dominantColor } : {}),
+ ...(exif ?? {}),
+ aspectRatio: assetAspectRatio(a),
+ };
+ });
+
+ return (
+
+ );
+}
diff --git a/app/journal/journal.css b/app/journal/journal.css
new file mode 100644
index 0000000..82de475
--- /dev/null
+++ b/app/journal/journal.css
@@ -0,0 +1,176 @@
+/* ── Journal Index & Reader Styling ───────────────────────────── */
+
+.journal-index-container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 3rem 1.5rem 6rem;
+}
+
+.journal-index-header {
+ margin-bottom: 3.5rem;
+ text-align: left;
+}
+
+.journal-index-header__kicker {
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.15em;
+ opacity: 0.6;
+ margin-bottom: 0.5rem;
+}
+
+.journal-index-header__title {
+ font-size: clamp(2.2rem, 4.5vw, 3.5rem);
+ font-weight: 700;
+ line-height: 1.1;
+ letter-spacing: -0.02em;
+ margin: 0 0 0.75rem;
+}
+
+.journal-index-header__subtitle {
+ font-size: 1.1rem;
+ opacity: 0.75;
+ margin: 0;
+ max-width: 600px;
+ line-height: 1.5;
+}
+
+.journal-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
+ gap: 2.5rem;
+}
+
+.journal-card {
+ display: flex;
+ flex-direction: column;
+ text-decoration: none;
+ color: inherit;
+ background: var(--surface, rgba(255, 255, 255, 0.03));
+ border: 1px solid var(--border, rgba(255, 255, 255, 0.08));
+ border-radius: 12px;
+ overflow: hidden;
+ transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
+}
+
+.journal-card:hover {
+ transform: translateY(-4px);
+ border-color: var(--border-hover, rgba(255, 255, 255, 0.2));
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.25);
+}
+
+.journal-card__cover {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 16 / 10;
+ background: var(--surface-subtle, rgba(255, 255, 255, 0.02));
+ overflow: hidden;
+}
+
+.journal-card__cover img {
+ object-fit: cover;
+ transition: transform 0.4s ease;
+}
+
+.journal-card:hover .journal-card__cover img {
+ transform: scale(1.03);
+}
+
+.journal-card__cover-empty {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0.3;
+ font-size: 2rem;
+}
+
+.journal-card__content {
+ padding: 1.75rem;
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+}
+
+.journal-card__meta {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ opacity: 0.6;
+ margin-bottom: 0.75rem;
+}
+
+.journal-card__title {
+ font-size: 1.35rem;
+ font-weight: 700;
+ line-height: 1.25;
+ margin: 0 0 0.5rem;
+ letter-spacing: -0.01em;
+}
+
+.journal-card__subtitle {
+ font-size: 0.95rem;
+ line-height: 1.45;
+ opacity: 0.8;
+ margin: 0 0 1rem;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.journal-card__excerpt {
+ font-size: 0.85rem;
+ line-height: 1.5;
+ opacity: 0.65;
+ margin: 0 0 1.25rem;
+ flex-grow: 1;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.journal-card__footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-top: 1rem;
+ border-top: 1px solid var(--border, rgba(255, 255, 255, 0.06));
+ font-size: 0.8rem;
+ font-weight: 500;
+}
+
+.journal-card__read-more {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--accent, #6366f1);
+ transition: gap 0.2s ease;
+}
+
+.journal-card:hover .journal-card__read-more {
+ gap: 8px;
+}
+
+.journal-card__draft-badge {
+ display: inline-block;
+ padding: 2px 8px;
+ background: rgba(234, 179, 8, 0.15);
+ color: #eab308;
+ border-radius: 4px;
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.journal-empty-state {
+ text-align: center;
+ padding: 5rem 1rem;
+ opacity: 0.7;
+}
diff --git a/app/journal/page.tsx b/app/journal/page.tsx
new file mode 100644
index 0000000..b4c04b3
--- /dev/null
+++ b/app/journal/page.tsx
@@ -0,0 +1,147 @@
+import Link from 'next/link';
+import Image from 'next/image';
+import type { Metadata } from 'next';
+import { type JournalEntrySummary } from '@/lib/journal';
+import { listJournalEntries } from '@/lib/admin/journal-service';
+import { isAdminAuthenticated } from '@/lib/admin/auth';
+import { immich } from '@/lib/immich';
+import { imageUrl, assetPlaceholder } from '@/lib/urls';
+import { BackLink } from '@/components/BackLink';
+import './journal.css';
+
+export const dynamic = 'force-dynamic';
+
+export const metadata: Metadata = {
+ title: 'Journal',
+ description: 'Photo essays, travel stories, and behind-the-scenes journals.',
+};
+
+interface EnrichedJournalEntry extends JournalEntrySummary {
+ coverUrl?: string;
+ blurDataURL?: string;
+ dominantColor?: string;
+}
+
+export default async function JournalIndexPage() {
+ const isAuthedAdmin = await isAdminAuthenticated();
+ const allEntries = await listJournalEntries();
+
+ // Non-admins only see published entries
+ const visibleEntries = isAuthedAdmin
+ ? allEntries
+ : allEntries.filter((e) => !e.frontmatter.draft);
+
+ // Fetch cover image placeholders and URLs
+ const enrichedEntries: EnrichedJournalEntry[] = await Promise.all(
+ visibleEntries.map(async (entry) => {
+ if (!entry.frontmatter.coverAssetId) {
+ return entry;
+ }
+
+ try {
+ const asset = await immich.getAssetInfo(entry.frontmatter.coverAssetId);
+ const ph = asset ? assetPlaceholder(asset) : null;
+ return {
+ ...entry,
+ coverUrl: imageUrl(entry.frontmatter.coverAssetId, 'preview'),
+ blurDataURL: ph?.blurDataURL,
+ dominantColor: ph?.dominantColor,
+ };
+ } catch {
+ return {
+ ...entry,
+ coverUrl: imageUrl(entry.frontmatter.coverAssetId, 'preview'),
+ };
+ }
+ }),
+ );
+
+ return (
+
+
+
+
+ Stories & Essays
+
+
Journal
+
+ Photo essays, visual stories, and field notes.
+
+
+
+ {enrichedEntries.length === 0 ? (
+
+
No journal entries published yet.
+
+ ) : (
+
+ {enrichedEntries.map((entry) => {
+ const dateStr = entry.frontmatter.date
+ ? new Date(entry.frontmatter.date).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })
+ : null;
+
+ return (
+
+
+ {entry.coverUrl ? (
+
+ ) : (
+
📖
+ )}
+
+
+
+
+ {dateStr && {dateStr} }
+ {dateStr && entry.readingTimeMinutes && • }
+ {entry.readingTimeMinutes && {entry.readingTimeMinutes} min read }
+ {entry.frontmatter.draft && (
+ Draft
+ )}
+
+
+
{entry.frontmatter.title || entry.slug}
+
+ {entry.frontmatter.subtitle && (
+
{entry.frontmatter.subtitle}
+ )}
+
+ {/*
+ extractExcerpt() falls back to the subtitle when there is
+ one, so rendering both printed the same sentence twice.
+ */}
+ {entry.excerpt && entry.excerpt !== entry.frontmatter.subtitle && (
+
{entry.excerpt}
+ )}
+
+
+ Read Story →
+ {entry.frontmatter.author && (
+ by {entry.frontmatter.author}
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 079192c..82b8a6d 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -15,6 +15,7 @@ import { Footer } from '@/components/Footer';
import { SetupScreen } from '@/components/SetupScreen';
import { getConfigOrNull, getGoogleFontsUrl, AppConfig } from '@/lib/config';
import { isAdminPath } from '@/lib/admin/paths';
+import { isInstallPath } from '@/lib/install';
// DevToolbarLoader is a Client Component (ssr: false is only allowed there)
import { DevToolbarLoader } from '@/components/DevToolbarLoader';
import AssetProtection from '@/components/AssetProtection';
@@ -24,7 +25,11 @@ export async function generateMetadata(): Promise {
const config = getConfigOrNull();
if (!config) {
// Nothing to describe, and nothing that should be indexed while broken.
- return { title: 'Setup Required', robots: { index: false, follow: false } };
+ return {
+ title: 'Setup Required',
+ robots: { index: false, follow: false },
+ icons: { icon: '/api/favicon' },
+ };
}
const siteTitle = config.seo.title;
@@ -42,6 +47,7 @@ export async function generateMetadata(): Promise {
description: siteDescription,
robots,
icons: {
+ icon: '/api/favicon',
apple: '/apple-touch-icon.png',
},
openGraph: {
@@ -71,7 +77,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
if (!config) {
return (
- {isAdminPath(pathname) ? children : }
+ {isAdminPath(pathname) || isInstallPath(pathname) ? children : }
);
}
@@ -90,9 +96,14 @@ export default async function RootLayout({ children }: { children: React.ReactNo
'--radius-lg': `${theme.radius * 2}px`,
};
- // /admin stays reachable during setup — it is the tool that completes the
- // setup, so the setup screen must not lock it out (#326).
- if ((config as AppConfig & { needsSetup?: boolean }).needsSetup && !isAdminPath(pathname)) {
+ // /admin and /install stay reachable during setup — /admin is the tool that
+ // completes an existing setup, /install is the wizard that performs the first
+ // one, so the setup screen must not lock either of them out (#326).
+ if (
+ (config as AppConfig & { needsSetup?: boolean }).needsSetup &&
+ !isAdminPath(pathname) &&
+ !isInstallPath(pathname)
+ ) {
return (
@@ -153,7 +164,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
{children}
-
+ {config.scrollToTop && }
void;
onPrev: () => void;
watermark?: LightboxWatermark;
+ /**
+ * Show the EXIF ("Info") toggle. Off for editorial contexts such as journal
+ * entries, where a technical data panel interrupts the story.
+ */
+ showExifToggle?: boolean;
}
-export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev, watermark }: LightboxProps) {
+export function Lightbox({
+ assets,
+ currentIndex,
+ onClose,
+ onNext,
+ onPrev,
+ watermark,
+ showExifToggle = true,
+}: LightboxProps) {
const [showExif, setShowExif] = useState(false);
const { exifData, exifLoading, fetchExif, clearExif } = useExif();
const [imageLoaded, setImageLoaded] = useState(false);
@@ -95,16 +108,39 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev, waterm
onSwipeRight: onPrev,
});
- // Listen for 'i' key to toggle EXIF
+ /*
+ * Keyboard control and the scroll lock belong to the lightbox, not to whoever
+ * opens it: PhotoGrid used to install the arrow keys itself, so every other
+ * caller (EssayView, i.e. journal entries and photo essays) silently had no
+ * keyboard navigation at all.
+ */
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'i' || e.key === 'I') {
- handleExifToggle();
+ switch (e.key) {
+ case 'Escape':
+ onClose();
+ break;
+ case 'ArrowRight':
+ onNext();
+ break;
+ case 'ArrowLeft':
+ onPrev();
+ break;
+ case 'i':
+ case 'I':
+ if (showExifToggle) handleExifToggle();
+ break;
}
};
+
document.addEventListener('keydown', handleKeyDown);
- return () => document.removeEventListener('keydown', handleKeyDown);
- }, [handleExifToggle]);
+ document.body.style.overflow = 'hidden';
+
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ document.body.style.overflow = '';
+ };
+ }, [handleExifToggle, onClose, onNext, onPrev, showExifToggle]);
// Click on overlay background → close
const handleOverlayClick = useCallback(
@@ -211,8 +247,8 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev, waterm
watermark.position === 'bottom-left'
? styles.watermark_bottom_left
: watermark.position === 'center'
- ? styles.watermark_center
- : styles.watermark_bottom_right
+ ? styles.watermark_center
+ : styles.watermark_bottom_right
}`}
style={{ opacity: (watermark.opacity ?? 50) / 100 }}
>
@@ -268,16 +304,18 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev, waterm
)}
{/* EXIF toggle */}
-
- {showExif ? 'Hide Info' : 'Info'}
-
+ {showExifToggle && (
+
+ {showExif ? 'Hide Info' : 'Info'}
+
+ )}
{/* EXIF panel */}
{showExif && (
diff --git a/components/PasswordGate.tsx b/components/PasswordGate.tsx
index 9335888..c35c027 100644
--- a/components/PasswordGate.tsx
+++ b/components/PasswordGate.tsx
@@ -11,7 +11,7 @@ import styles from './PasswordGate.module.css';
interface PasswordGateProps {
slug: string;
title: string;
- type?: 'subpage' | 'album';
+ type?: 'subpage' | 'album' | 'journal';
}
export default function PasswordGate({ slug, title, type = 'subpage' }: PasswordGateProps) {
diff --git a/components/ScrollToTop.tsx b/components/ScrollToTop.tsx
index c3665bc..8bffe53 100644
--- a/components/ScrollToTop.tsx
+++ b/components/ScrollToTop.tsx
@@ -1,27 +1,40 @@
/**
- * ScrollToTop — fixed ↑ button that appears after scrolling down.
- * Smooth-scrolls back to the top when clicked.
+ * ScrollToTop — fixed back-to-top button that fades in after scrolling down.
+ * Toggled site-wide via `scrollToTop` in settings.yaml.
*/
'use client';
-import { useState, useEffect } from 'react';
+import { useState, useEffect, useRef } from 'react';
const SHOW_THRESHOLD = 300;
export function ScrollToTop() {
const [visible, setVisible] = useState(false);
+ const frame = useRef(null);
useEffect(() => {
- const onScroll = () => {
+ const measure = () => {
+ frame.current = null;
setVisible(window.scrollY > SHOW_THRESHOLD);
};
+
+ // rAF-throttled: one state update per frame, not one per scroll event.
+ const onScroll = () => {
+ if (frame.current === null) frame.current = requestAnimationFrame(measure);
+ };
+
+ measure();
window.addEventListener('scroll', onScroll, { passive: true });
- return () => window.removeEventListener('scroll', onScroll);
+ return () => {
+ window.removeEventListener('scroll', onScroll);
+ if (frame.current !== null) cancelAnimationFrame(frame.current);
+ };
}, []);
const scrollToTop = () => {
- window.scrollTo({ top: 0, behavior: 'smooth' });
+ const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ window.scrollTo({ top: 0, behavior: reduced ? 'auto' : 'smooth' });
};
return (
@@ -34,7 +47,20 @@ export function ScrollToTop() {
tabIndex={visible ? 0 : -1}
aria-hidden={!visible}
>
- ↑
+
+
+
+
);
}
diff --git a/components/SubpageNav.tsx b/components/SubpageNav.tsx
index 21b980b..ceca5e3 100644
--- a/components/SubpageNav.tsx
+++ b/components/SubpageNav.tsx
@@ -5,13 +5,17 @@
import Link from 'next/link';
import { immich } from '@/lib/immich';
+import { listJournalEntries } from '@/lib/admin/journal-service';
export async function SubpageNav() {
- const [subpages, standaloneAlbums] = await Promise.all([
+ const [subpages, standaloneAlbums, journalEntries] = await Promise.all([
immich.getSubpages(),
immich.getStandaloneAlbums(),
+ listJournalEntries().catch(() => []),
]);
+ const hasPublicJournal = journalEntries.some((e) => !e.frontmatter.draft);
+
return (
<>
{subpages.map((sp) => (
@@ -19,6 +23,11 @@ export async function SubpageNav() {
{sp.name}
))}
+ {hasPublicJournal && (
+
+ Journal
+
+ )}
{standaloneAlbums.map((album) => (
{album.albumName}
diff --git a/content/settings.yaml.example b/content/settings.yaml.example
index 4902c71..c764c3a 100644
--- a/content/settings.yaml.example
+++ b/content/settings.yaml.example
@@ -24,6 +24,9 @@ map: true
# Enable smooth page transitions between routes
transitions: true
+# Show the floating back-to-top arrow on long pages
+scrollToTop: true
+
# Client proofing & favorite photo selections
# proofing:
# enabled: true
diff --git a/docs/admin-panel.md b/docs/admin-panel.md
index d1e0b20..50838ba 100644
--- a/docs/admin-panel.md
+++ b/docs/admin-panel.md
@@ -66,7 +66,7 @@ The **Settings** tab lets you configure global site behavior:
| Section | Controls |
| --------- | ---------------------------------------------------------------- |
-| General | Site title, subtitle, language, EXIF on hover, map, transitions |
+| General | Site title, subtitle, language, EXIF on hover, map, transitions, scroll-to-top |
| Theme | Preset, accent color, photo frame, hero style, grain, header dot |
| Grid | Layout mode, columns, gap, aspect ratio |
| Footer | Name, Instagram, email, website |
diff --git a/docs/journal-studio-design.md b/docs/journal-studio-design.md
new file mode 100644
index 0000000..11ac8fa
--- /dev/null
+++ b/docs/journal-studio-design.md
@@ -0,0 +1,116 @@
+# Design-Spezifikation: Journal Studio (Foto-Essays & Stories)
+
+_Status: Entwurf genehmigt (Brainstorming abgeschlossen) · Datum: 2026-08-14_
+
+---
+
+## 1. Understanding Summary
+
+- **Ziel & Vision:** Bereitstellung eines intuitiven, erstklassigen **Journal Studios** für Fotografen, um Bild-Text-Reportagen, Reisetagebücher und Ausstellungsberichte (im Stil von Leica Journal / National Geographic) zu verfassen und zu veröffentlichen.
+- **Zielgruppe:**
+ - **Besucher:** Immersives, typografisch elegantes Leseerlebnis mit progressiv ladenden Bildern (ThumbHash), responsiven Layouts und Lightbox-Interaktion.
+ - **Fotograf:** Mühelose Redaktion im Admin-Panel mit Live-Split-Screen-Vorschau und universeller Immich-Bildauswahl.
+- **Konzept:** Reines Markdown mit Frontmatter im Content-Volume (`content/journal/*.md`), verwaltet über eine dedizierte Admin-UI und ausgeliefert über saubere Next.js Server Components.
+- **Kern-Vorteile:**
+ - Trennung von Galerie-Konfiguration (`gallery.yaml`) und redaktionellen Inhalten.
+ - Volle Git- & Backup-Kompatibilität durch Standard-Markdown.
+ - Schnelle Erstellung ohne manuelle UUID-Eingabe.
+
+---
+
+## 2. Annahmen (Assumptions)
+
+1. **Storage-Pfad:** Journal-Einträge werden standardmäßig unter `content/journal/[slug].md` gespeichert.
+2. **Bild-Referenzierung:** Bilder verweisen über `` auf Immich-Assets und werden über den internen Image-Proxy mit Cache und ThumbHash ausgeliefert.
+3. **Theming:** Die Leseansicht (`/journal/[slug]`) bindet sich nahtlos an das global konfigurierte Design-Theme an (CSS-Variablen, Fonts, Margins).
+4. **Resilienz:** Nicht auffindbare Bilder führen nicht zu einem Seitenabsturz, sondern werden durch neutrale Platzhalter ersetzt.
+
+---
+
+## 3. Decision Log
+
+| # | Entscheidung | Alternativen | Begründung |
+|---|---|---|---|
+| 1 | **Nomenklatur "Journal"** | Stories, Photo Essays, Magazin | "Stories" wirkt wie flüchtige Social-Media-Inhalte. "Journal" spiegelt zeitlose, fotografische Wertigkeit (wie Leica Journal) wider. |
+| 2 | **Architektur-Ansatz 1 (Eigenständiges Studio)** | Subpage-Drawer, Quick-Fix | Maximale Schreib- und Layout-Ergonomie durch echten Split-Screen und Entkopplung von `gallery.yaml`. |
+| 3 | **Persistenz als `.md`-Dateien** | Strings in `gallery.yaml`, SQLite/DB | Behält das dateibasierte, zustandsarme Self-Hosting-Paradigma bei; einfache Versionierung via Git. |
+| 4 | **Bidirektionaler Editor (Blöcke ↔ Markdown)** | Nur WYSIWYG, Nur Markdown | Fotografen können visuell Blöcke anordnen oder direkt mit Markdown-Code arbeiten, ohne Datenverlust. |
+| 5 | **Sicherheit via Slug-Sanitisierung** | Ungeprüfte Dateinamen | Verhindert Path Traversal (`../`) im Docker-Container/Dateisystem. |
+
+---
+
+## 4. Technische Architektur & Komponenten
+
+### A. Dateisystem & Datenmodell
+Jeder Eintrag ist eine `.md`-Datei mit folgendem Schema:
+
+```markdown
+---
+title: "Expedition Nordkap"
+subtitle: "Mit dem Bulli durch die Fjorde"
+date: "2026-08-14"
+author: "Ralf"
+coverAssetId: "b8c2d111-0000-4000-8000-000000000000"
+password: "" # Optionaler Passwortschutz (scrypt)
+draft: false # Entwurf oder öffentlich
+---
+
+Hier beginnt der Text der Reportage...
+
+
+```
+
+Unterstützte Block-Typen:
+- **Heading:** `# H1`, `## H2`, `### H3`
+- **Paragraph:** Fliesstext mit Inline-Markdown (`**fett**`, `*kursiv*`, `[Link](url)`)
+- **Pullquote:** `> Zitattext` mit optionalem `— Autor`
+- **Photo (Fullbleed / Wide / Contained):** ``
+- **Photo-Pair (2-Spaltig):** Zwei aufeinanderfolgende `asset:`-Bilder
+
+---
+
+### B. Admin API Endpunkte (`/api/admin/journal`)
+
+- **`GET /api/admin/journal`**: Liefert Liste aller Einträge (geparstes Frontmatter, Slug, Cover-Thumbnail, Word Count / Read Time).
+- **`GET /api/admin/journal/[slug]`**: Liefert vollen Inhalt & Metadaten eines spezifischen Eintrags.
+- **`PUT /api/admin/journal/[slug]`**: Speichert den Eintrag atomar (Write to `.tmp` ➔ Rename), legt `.bak`-Sicherungen an und validiert den Markdown-Baum.
+- **`DELETE /api/admin/journal/[slug]`**: Löscht die `.md`-Datei nach Bestätigung.
+
+---
+
+### C. UI: Das Journal Studio (`/admin` ➔ Tab "Journal")
+
+1. **Journal Dashboard:**
+ - Übersicht aller Einträge als Karten mit Thumbnail, Status (*Draft*, *Live*, *Protected*), Erstellungsdatum.
+ - Button `+ New Journal Entry`.
+2. **Studio Split-Screen Editor:**
+ - **Top Navigation:** Titel-Eingabe, Slug, Status-Schalter, Metadaten-Modal (Autor, Datum, Passwort, Cover-Foto) und Save-Button (`Cmd+S`).
+ - **Linker Bereich (Authoring):**
+ - Umschaltbar zwischen **[Visual Blocks]** (Karten mit Drag & Drop Griffleisten) und **[Raw Markdown]**.
+ - Universeller Asset-Picker für Fotos aus allen Immich-Alben.
+ - **Rechter Bereich (Live Preview):**
+ - 1:1 Rendering via `EssayView` mit den CSS-Variablen des aktiven Themes.
+ - Viewport-Umschalter (Desktop / Mobile).
+
+---
+
+### D. Frontend Routing & Rendering
+
+- **`/journal`**: Übersicht aller veröffentlichten Journal-Einträge mit Cover-Bildern und Exzerpten.
+- **`/journal/[slug]`**: Detailansicht der Reportage mit Passwort-Gate (`PasswordGate`) bei geschützten Einträgen.
+- **Subpage-Integration:** Kompatibilität mit `gallery.yaml` (`essayFile: "nordkap.md"`) bleibt vollständig erhalten.
+
+---
+
+## 5. Test- & Absicherungsstrategie
+
+1. **Unit-Tests (`lib/__tests__/journal.test.ts`):**
+ - Frontmatter-Parsing & -Serialisierung.
+ - XSS-Sanitisierung bei bösartigem Markdown-HTML.
+ - Block-Transformation (Fullbleed, Pairs, Quotes).
+2. **API-Tests (`app/api/admin/journal/__tests__/route.test.ts`):**
+ - Auth-Schutz (401/403 bei nicht-angemeldeten Requests).
+ - Path-Traversal-Abwehr (Rejection von `../`, `%2e%2e%2f`).
+ - Atomare Speicherung und Fehlerbehandlung bei Disk-Fehlern.
+3. **E2E / Integration:**
+ - Aufruf von `/journal/[slug]` im Browser und Verifikation des Lightbox-Klicks auf eingebettete Fotos.
diff --git a/lib/__tests__/admin-auth.test.ts b/lib/__tests__/admin-auth.test.ts
index b6de250..3353615 100644
--- a/lib/__tests__/admin-auth.test.ts
+++ b/lib/__tests__/admin-auth.test.ts
@@ -9,6 +9,15 @@ const AUTH_SECRET = 'test-auth-secret-32-chars-long-min';
vi.mock('../env', () => ({
env: {
+ IMMICH_API_URL: '',
+ IMMICH_API_KEY: '',
+ SITE_TITLE: 'Test',
+ SITE_SUBTITLE: '',
+ CACHE_TTL: 300,
+ IMAGE_CACHE_VERSION: '',
+ IMMICH_TIMEOUT_MS: 15000,
+ RATE_LIMIT_RPM: 1500,
+ TRUSTED_PROXY_HOPS: 0,
get AUTH_SECRET() {
return process.env.__TEST_AUTH_SECRET;
},
@@ -18,6 +27,21 @@ vi.mock('../env', () => ({
},
}));
+vi.mock('../install', () => ({
+ getInstallCredentials() {
+ return {
+ apiUrl: '',
+ apiKey: '',
+ authSecret: process.env.__TEST_AUTH_SECRET || '',
+ adminPassword: process.env.__TEST_ADMIN_PASSWORD || '',
+ };
+ },
+ isInstalled: () => false,
+ isInstallPath: () => false,
+ normalizeApiBase: (): string => '',
+ completeInstall: () => {},
+}));
+
/** Import fresh so module-level state cannot leak between cases. */
async function loadAuth() {
vi.resetModules();
@@ -73,9 +97,14 @@ describe('admin session tokens', () => {
it('throws rather than signing with a guessable secret when AUTH_SECRET is unset in production', async () => {
delete process.env.__TEST_AUTH_SECRET;
- vi.stubEnv('NODE_ENV', 'production');
- const { createAdminToken } = await loadAuth();
- expect(() => createAdminToken()).toThrow(/AUTH_SECRET/);
+ const prevNodeEnv = process.env.NODE_ENV;
+ (process.env as Record).NODE_ENV = 'production';
+ try {
+ const { createAdminToken } = await loadAuth();
+ expect(() => createAdminToken()).toThrow(/AUTH_SECRET/);
+ } finally {
+ (process.env as Record).NODE_ENV = prevNodeEnv;
+ }
});
it('returns false (not throw) for a malformed cookie with a short signature', async () => {
@@ -121,18 +150,18 @@ describe('admin session tokens', () => {
describe('verifyAdminPassword', () => {
it('accepts the configured password and rejects others', async () => {
const { verifyAdminPassword } = await loadAuth();
- expect(verifyAdminPassword(ADMIN_PASSWORD)).toBe(true);
- expect(verifyAdminPassword('wrong')).toBe(false);
- expect(verifyAdminPassword(`${ADMIN_PASSWORD}x`)).toBe(false);
- expect(verifyAdminPassword('')).toBe(false);
+ expect(await verifyAdminPassword(ADMIN_PASSWORD)).toBe(true);
+ expect(await verifyAdminPassword('wrong')).toBe(false);
+ expect(await verifyAdminPassword(`${ADMIN_PASSWORD}x`)).toBe(false);
+ expect(await verifyAdminPassword('')).toBe(false);
});
it('rejects everything when no ADMIN_PASSWORD is configured', async () => {
delete process.env.__TEST_ADMIN_PASSWORD;
const { verifyAdminPassword, isAdminEnabled } = await loadAuth();
expect(isAdminEnabled()).toBe(false);
- expect(verifyAdminPassword('')).toBe(false);
- expect(verifyAdminPassword('anything')).toBe(false);
+ expect(await verifyAdminPassword('')).toBe(false);
+ expect(await verifyAdminPassword('anything')).toBe(false);
});
// The comparison used to run on the raw strings, so a length mismatch
@@ -144,18 +173,53 @@ describe('verifyAdminPassword', () => {
const sameLength = 'x'.repeat(ADMIN_PASSWORD.length);
expect(sameLength).toHaveLength(ADMIN_PASSWORD.length);
expect(sameLength).not.toBe(ADMIN_PASSWORD);
- expect(verifyAdminPassword(sameLength)).toBe(false);
+ expect(await verifyAdminPassword(sameLength)).toBe(false);
});
it('rejects an oversized attempt without hashing it', async () => {
const { verifyAdminPassword, MAX_PASSWORD_LENGTH } = await loadAuth();
- expect(verifyAdminPassword('x'.repeat(MAX_PASSWORD_LENGTH + 1))).toBe(false);
+ expect(await verifyAdminPassword('x'.repeat(MAX_PASSWORD_LENGTH + 1))).toBe(false);
});
// A cap that a real passphrase could hit would be a lockout, not a fix.
it('still accepts the configured password when it is long', async () => {
const { verifyAdminPassword, MAX_PASSWORD_LENGTH } = await loadAuth();
expect(ADMIN_PASSWORD.length).toBeLessThanOrEqual(MAX_PASSWORD_LENGTH);
- expect(verifyAdminPassword(ADMIN_PASSWORD)).toBe(true);
+ expect(await verifyAdminPassword(ADMIN_PASSWORD)).toBe(true);
+ });
+
+ // The wizard writes an scrypt hash; ADMIN_PASSWORD as an env var stays
+ // plaintext, because that is what every existing deployment has set. Getting
+ // either wrong locks the owner out of the panel that fixes it.
+ describe('accepts both stored formats', () => {
+ it('verifies against a wizard-written scrypt hash', async () => {
+ const { generateScryptHash } = await import('@/lib/password');
+ process.env.__TEST_ADMIN_PASSWORD = await generateScryptHash(ADMIN_PASSWORD);
+
+ const { verifyAdminPassword, isAdminEnabled } = await loadAuth();
+ expect(isAdminEnabled()).toBe(true);
+ expect(await verifyAdminPassword(ADMIN_PASSWORD)).toBe(true);
+ expect(await verifyAdminPassword('wrong')).toBe(false);
+ expect(await verifyAdminPassword('')).toBe(false);
+ });
+
+ // Someone who set ADMIN_PASSWORD in docker-compose must keep working after
+ // an upgrade — the hash support is additive, not a migration.
+ it('still verifies against a plaintext env password', async () => {
+ process.env.__TEST_ADMIN_PASSWORD = ADMIN_PASSWORD;
+ const { verifyAdminPassword } = await loadAuth();
+ expect(await verifyAdminPassword(ADMIN_PASSWORD)).toBe(true);
+ expect(await verifyAdminPassword('wrong')).toBe(false);
+ });
+
+ // The stored hash is never the password, so it must not be accepted as one.
+ it('does not accept the hash itself as the password', async () => {
+ const { generateScryptHash } = await import('@/lib/password');
+ const hash = await generateScryptHash(ADMIN_PASSWORD);
+ process.env.__TEST_ADMIN_PASSWORD = hash;
+
+ const { verifyAdminPassword } = await loadAuth();
+ expect(await verifyAdminPassword(hash)).toBe(false);
+ });
});
});
diff --git a/lib/__tests__/config-safe.test.ts b/lib/__tests__/config-safe.test.ts
index 1c42d5c..c57238e 100644
--- a/lib/__tests__/config-safe.test.ts
+++ b/lib/__tests__/config-safe.test.ts
@@ -39,10 +39,11 @@ function withGallery(gallery: unknown) {
/**
* The admin page builder can write each of these, and the root layout renders
* /admin inside itself — so a throw here would take down the site together with
- * the only tool that can undo the save.
+ * the only tool that can undo the save. An empty gallery is deliberately NOT in
+ * this list: the install wizard can finish without selecting an album, so an
+ * empty gallery is now a valid, rendered state (see derive-gallery.test.ts).
*/
const UNDERIVABLE = {
- 'a gallery with neither albums nor subpages': { albums: [] },
'a subpage with no name': { albums: [ALBUM_ID], subpages: [{ albums: [ALBUM_ID] }] },
'a subpage with neither albums nor sections': {
albums: [ALBUM_ID],
@@ -61,6 +62,17 @@ describe('getConfig() rejects an underivable gallery.yaml', () => {
expect(() => getConfig()).toThrow();
});
}
+
+ it('accepts an empty gallery — a valid post-install state', () => {
+ // The install wizard may finish without selecting an album, so an empty
+ // gallery must render (title-only homepage) rather than take down the site.
+ withGallery({ albums: [] });
+ const config = getConfig();
+ expect(config.albums).toEqual([]);
+ expect(config.standaloneAlbums).toEqual([]);
+ expect(config.subpages).toEqual([]);
+ expect(config.needsSetup).toBe(false);
+ });
});
describe('getConfigOrNull()', () => {
@@ -81,7 +93,9 @@ describe('getConfigOrNull()', () => {
}
it('logs the underlying error so the cause is diagnosable from the server log', () => {
- withGallery({ albums: [] });
+ // A nameless subpage is still underivable — an empty gallery is not (the
+ // install wizard can finish without an album, so that shape is now valid).
+ withGallery({ albums: [ALBUM_ID], subpages: [{ albums: [ALBUM_ID] }] });
getConfigOrNull();
expect(error).toHaveBeenCalled();
@@ -89,7 +103,7 @@ describe('getConfigOrNull()', () => {
});
it('points at the backup directory, since that is the recovery path', () => {
- withGallery({ albums: [] });
+ withGallery({ albums: [ALBUM_ID], subpages: [{ albums: [ALBUM_ID] }] });
getConfigOrNull();
expect(String(error.mock.calls[0][0])).toContain('content/.backups/');
});
diff --git a/lib/__tests__/derive-gallery.test.ts b/lib/__tests__/derive-gallery.test.ts
index 1ee6dba..0ccd206 100644
--- a/lib/__tests__/derive-gallery.test.ts
+++ b/lib/__tests__/derive-gallery.test.ts
@@ -31,10 +31,6 @@ const B = '22222222-2222-2222-2222-222222222222';
* shapes the page builder can produce.
*/
describe('deriveGallery rejects what the site cannot load', () => {
- it('rejects a gallery with neither albums nor subpages', () => {
- expect(() => deriveGallery({ albums: [] } as GalleryYaml)).toThrow(/at least one album/i);
- });
-
it('rejects a subpage with no name', () => {
expect(() =>
deriveGallery({ albums: [A], subpages: [{ albums: [A] }] } as unknown as GalleryYaml),
@@ -67,6 +63,15 @@ describe('deriveGallery rejects what the site cannot load', () => {
});
describe('deriveGallery accepts valid structures', () => {
+ it('derives an empty gallery (no albums or subpages)', () => {
+ // The install wizard can finish without selecting an album, so an empty
+ // gallery must be a valid, rendered state rather than an error.
+ const result = deriveGallery({ albums: [] } as GalleryYaml);
+ expect(result.albums).toEqual([]);
+ expect(result.standaloneAlbums).toEqual([]);
+ expect(result.subpages).toEqual([]);
+ });
+
it('derives standalone albums', () => {
const result = deriveGallery({ albums: [A, B] } as GalleryYaml);
expect(result.albums).toEqual([A, B]);
diff --git a/lib/__tests__/essay.test.ts b/lib/__tests__/essay.test.ts
index 0bf4078..72a681e 100644
--- a/lib/__tests__/essay.test.ts
+++ b/lib/__tests__/essay.test.ts
@@ -4,7 +4,15 @@ import { parseEssayMarkdown, parseFrontmatter, sanitizeHtml, renderInlineMarkdow
describe('Essay Parser', () => {
it('sanitizes script tags to prevent XSS', () => {
const input = 'Hello World';
- expect(sanitizeHtml(input)).toBe('Hello World');
+ // sanitizeHtml escapes rather than strips: the tag stays visible as the
+ // author's literal text but can no longer be parsed as markup. Asserting
+ // the old removed-substring output would assert the denylist that this
+ // replaced.
+ const out = sanitizeHtml(input);
+ expect(out).not.toContain('<');
+ expect(out).not.toContain('>');
+ expect(out).toContain('Hello');
+ expect(out).toContain('World');
});
it('renders inline markdown formatting correctly', () => {
diff --git a/lib/__tests__/install.test.ts b/lib/__tests__/install.test.ts
new file mode 100644
index 0000000..5e8dace
--- /dev/null
+++ b/lib/__tests__/install.test.ts
@@ -0,0 +1,297 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+
+// Readable env getters, like admin-auth.test.ts, so tests can toggle which
+// values come from the environment vs. the wizard file.
+vi.mock('@/lib/env', () => ({
+ env: {
+ get IMMICH_API_URL() {
+ return process.env.__T_INSTALL_URL || '';
+ },
+ get IMMICH_API_KEY() {
+ return process.env.__T_INSTALL_KEY || '';
+ },
+ get AUTH_SECRET() {
+ return process.env.__T_INSTALL_SECRET || '';
+ },
+ get ADMIN_PASSWORD() {
+ return process.env.__T_INSTALL_ADMIN || '';
+ },
+ get INSTALL_CONTENT_DIR() {
+ return process.env.INSTALL_CONTENT_DIR || '';
+ },
+ },
+}));
+
+/** Import fresh so the module-level install-file cache cannot leak between cases. */
+async function loadInstall() {
+ vi.resetModules();
+ return import('@/lib/install');
+}
+
+let dir: string;
+
+beforeEach(() => {
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'install-test-'));
+ vi.stubEnv('INSTALL_CONTENT_DIR', dir);
+});
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ delete process.env.__T_INSTALL_URL;
+ delete process.env.__T_INSTALL_KEY;
+ delete process.env.__T_INSTALL_SECRET;
+ delete process.env.__T_INSTALL_ADMIN;
+ fs.rmSync(dir, { recursive: true, force: true });
+});
+
+const ALBUM_ID = '11111111-1111-1111-1111-111111111111';
+
+describe('getInstallCredentials', () => {
+ it('returns the file values when the environment is empty', async () => {
+ const { getInstallCredentials } = await loadInstall();
+ fs.writeFileSync(
+ path.join(dir, 'install.json'),
+ JSON.stringify({
+ apiUrl: 'https://file-immich:2283',
+ apiKey: 'file-key',
+ authSecret: 'file-secret',
+ adminPassword: 'file-admin',
+ }),
+ );
+
+ expect(getInstallCredentials()).toEqual({
+ apiUrl: 'https://file-immich:2283',
+ apiKey: 'file-key',
+ authSecret: 'file-secret',
+ adminPassword: 'file-admin',
+ });
+ });
+
+ it('lets environment variables win over the file', async () => {
+ const { getInstallCredentials } = await loadInstall();
+ process.env.__T_INSTALL_URL = 'https://env-immich:2283';
+ process.env.__T_INSTALL_KEY = 'env-key';
+ process.env.__T_INSTALL_SECRET = 'env-secret';
+ process.env.__T_INSTALL_ADMIN = 'env-admin';
+ fs.writeFileSync(
+ path.join(dir, 'install.json'),
+ JSON.stringify({
+ apiUrl: 'https://file-immich:2283',
+ apiKey: 'file-key',
+ authSecret: 'file-secret',
+ adminPassword: 'file-admin',
+ }),
+ );
+
+ expect(getInstallCredentials()).toEqual({
+ apiUrl: 'https://env-immich:2283',
+ apiKey: 'env-key',
+ authSecret: 'env-secret',
+ adminPassword: 'env-admin',
+ });
+ });
+
+ it('returns empty strings when neither source has values', async () => {
+ const { getInstallCredentials } = await loadInstall();
+ expect(getInstallCredentials()).toEqual({
+ apiUrl: '',
+ apiKey: '',
+ authSecret: '',
+ adminPassword: '',
+ });
+ });
+});
+
+describe('isInstalled', () => {
+ it('is false while gallery.yaml is missing', async () => {
+ const { isInstalled } = await loadInstall();
+ process.env.__T_INSTALL_URL = 'https://immich:2283';
+ process.env.__T_INSTALL_KEY = 'key';
+ expect(isInstalled()).toBe(false);
+ });
+
+ it('is false when credentials are missing even if gallery.yaml exists', async () => {
+ const { isInstalled } = await loadInstall();
+ fs.writeFileSync(path.join(dir, 'gallery.yaml'), 'albums: []\n');
+ expect(isInstalled()).toBe(false);
+ });
+
+ it('is true once gallery.yaml exists and credentials resolve', async () => {
+ const { isInstalled } = await loadInstall();
+ fs.writeFileSync(path.join(dir, 'gallery.yaml'), 'albums: []\n');
+ process.env.__T_INSTALL_URL = 'https://immich:2283';
+ process.env.__T_INSTALL_KEY = 'key';
+ expect(isInstalled()).toBe(true);
+ });
+});
+
+describe('completeInstall', () => {
+ it('writes gallery.yaml, settings.yaml and install.json', async () => {
+ const { completeInstall, getInstallCredentials } = await loadInstall();
+
+ await completeInstall({
+ apiUrl: 'https://immich.example',
+ apiKey: 'api-key-123',
+ siteTitle: 'My Portfolio',
+ siteSubtitle: 'A visual journal',
+ theme: 'noir',
+ albums: [ALBUM_ID],
+ adminPassword: 'hunter2',
+ });
+
+ const gallery = fs.readFileSync(path.join(dir, 'gallery.yaml'), 'utf8');
+ expect(gallery).toContain('albums');
+ expect(gallery).toContain(ALBUM_ID);
+
+ const settings = fs.readFileSync(path.join(dir, 'settings.yaml'), 'utf8');
+ expect(settings).toContain('My Portfolio');
+ expect(settings).toContain('noir');
+
+ const creds = JSON.parse(fs.readFileSync(path.join(dir, 'install.json'), 'utf8'));
+ expect(creds.apiUrl).toBe('https://immich.example');
+ expect(creds.apiKey).toBe('api-key-123');
+ // Stored as a hash, never as typed — an admin password is the one
+ // credential here a person picked and may have reused elsewhere.
+ expect(creds.adminPassword).not.toBe('hunter2');
+ expect(creds.adminPassword).toMatch(/^scrypt:[0-9a-f]+:[0-9a-f]+$/);
+ const { verifyScrypt } = await import('@/lib/password');
+ expect(await verifyScrypt('hunter2', creds.adminPassword)).toBe(true);
+ expect(await verifyScrypt('wrong', creds.adminPassword)).toBe(false);
+ // A fresh, unguessable site secret is generated.
+ expect(creds.authSecret).toBeTruthy();
+ expect(creds.authSecret.length).toBeGreaterThanOrEqual(32);
+
+ // The generated secret resolves through getInstallCredentials.
+ expect(getInstallCredentials().authSecret).toBe(creds.authSecret);
+ });
+
+ it('writes an empty albums list when no albums are selected', async () => {
+ const { completeInstall } = await loadInstall();
+ await completeInstall({
+ apiUrl: 'https://immich.example',
+ apiKey: 'api-key-123',
+ albums: [],
+ });
+
+ const gallery = fs.readFileSync(path.join(dir, 'gallery.yaml'), 'utf8');
+ expect(gallery).toContain('albums: []');
+ });
+
+ it('does not set an admin password when none is given', async () => {
+ const { completeInstall } = await loadInstall();
+ await completeInstall({
+ apiUrl: 'https://immich.example',
+ apiKey: 'api-key-123',
+ });
+
+ const creds = JSON.parse(fs.readFileSync(path.join(dir, 'install.json'), 'utf8'));
+ expect(creds.adminPassword).toBeUndefined();
+ });
+
+ it('makes isInstalled() true immediately — no restart required', async () => {
+ const { completeInstall, isInstalled } = await loadInstall();
+ expect(isInstalled()).toBe(false);
+
+ await completeInstall({
+ apiUrl: 'https://immich.example',
+ apiKey: 'api-key-123',
+ });
+
+ expect(isInstalled()).toBe(true);
+ });
+});
+
+describe('normalizeApiBase', () => {
+ it('appends /api to a base URL', async () => {
+ const { normalizeApiBase } = await loadInstall();
+ expect(normalizeApiBase('https://photos.example.com')).toBe('https://photos.example.com/api');
+ });
+
+ it('strips a trailing slash', async () => {
+ const { normalizeApiBase } = await loadInstall();
+ expect(normalizeApiBase('https://photos.example.com/')).toBe('https://photos.example.com/api');
+ });
+
+ it('does not double-append /api', async () => {
+ const { normalizeApiBase } = await loadInstall();
+ expect(normalizeApiBase('https://photos.example.com/api')).toBe(
+ 'https://photos.example.com/api',
+ );
+ });
+
+ it('rejects non-http(s) schemes and garbage', async () => {
+ const { normalizeApiBase } = await loadInstall();
+ expect(normalizeApiBase('ftp://photos.example.com')).toBeNull();
+ expect(normalizeApiBase('not a url')).toBeNull();
+ expect(normalizeApiBase('')).toBeNull();
+ });
+});
+
+describe('isInstallPath', () => {
+ it('matches the wizard route only', async () => {
+ const { isInstallPath } = await loadInstall();
+ expect(isInstallPath('/install')).toBe(true);
+ expect(isInstallPath('/install/')).toBe(true);
+ expect(isInstallPath('/api/install')).toBe(false);
+ expect(isInstallPath('/admin')).toBe(false);
+ expect(isInstallPath('/')).toBe(false);
+ expect(isInstallPath(null)).toBe(false);
+ });
+});
+
+describe('setup token', () => {
+ /**
+ * The reason this is a file and not a module variable: Next.js bundles the
+ * install page and the install route handlers separately, so each gets its
+ * own instance of lib/install.ts. A module-level token made /install render
+ * the wizard and /api/install reject that same token, inside one process —
+ * the wizard could not get past step 1. Two freshly imported module
+ * instances reproduce exactly that split.
+ */
+ it('agrees across separate module instances', async () => {
+ const pageSide = await loadInstall();
+ const token = pageSide.getSetupToken();
+
+ const apiSide = await loadInstall(); // fresh instance, as a second bundle would be
+ expect(apiSide.validateSetupToken(token)).toBe(true);
+ });
+
+ it('persists across a restart, so the operator keeps the logged token', async () => {
+ const first = await loadInstall();
+ const token = first.getSetupToken();
+
+ const afterRestart = await loadInstall();
+ expect(afterRestart.getSetupToken()).toBe(token);
+ });
+
+ it('writes the token 0600 — it is a credential, not a config value', async () => {
+ const { getSetupToken } = await loadInstall();
+ getSetupToken();
+
+ const mode = fs.statSync(path.join(dir, '.setup-token')).mode & 0o777;
+ expect(mode).toBe(0o600);
+ });
+
+ it('rejects a wrong or missing token', async () => {
+ const { getSetupToken, validateSetupToken } = await loadInstall();
+ getSetupToken();
+
+ expect(validateSetupToken('nope')).toBe(false);
+ expect(validateSetupToken('')).toBe(false);
+ expect(validateSetupToken(null)).toBe(false);
+ });
+
+ // Once install is done every install route answers 403, so the token guards
+ // nothing — leaving it on disk would just be a stray credential.
+ it('is removed once the install completes', async () => {
+ const { getSetupToken, completeInstall } = await loadInstall();
+ getSetupToken();
+ expect(fs.existsSync(path.join(dir, '.setup-token'))).toBe(true);
+
+ await completeInstall({ apiUrl: 'http://immich.local', apiKey: 'k', albums: [] });
+ expect(fs.existsSync(path.join(dir, '.setup-token'))).toBe(false);
+ });
+});
diff --git a/lib/__tests__/journal-escaping.test.ts b/lib/__tests__/journal-escaping.test.ts
new file mode 100644
index 0000000..4255aaf
--- /dev/null
+++ b/lib/__tests__/journal-escaping.test.ts
@@ -0,0 +1,96 @@
+import { describe, it, expect } from 'vitest';
+import {
+ sanitizeHtml,
+ decodeHtmlEntities,
+ renderInlineMarkdown,
+ parseJournalMarkdown,
+ serializeJournalMarkdown,
+} from '../journal';
+
+/**
+ * The previous sanitizer was a denylist: strip `'],
+ ])('renders %s links as plain text instead of an anchor', (_name, url) => {
+ const html = renderInlineMarkdown(`[click me](${url})`);
+ // The label survives, the URL does not become a link. A stray `)` may be
+ // left over because the link pattern stops at the first closing paren —
+ // pre-existing behaviour, and harmless as text.
+ expect(html).toContain('click me');
+ expect(html).not.toContain(' {
+ const html = renderInlineMarkdown('[x](" onmouseover=alert(1) x=")');
+ expect(html).not.toContain('onmouseover=alert(1)');
+ });
+
+ it('does not let a label inject markup', () => {
+ const html = renderInlineMarkdown('[ ](https://example.com)');
+ expect(html).not.toContain(' {
+ it('does not accumulate entities when parsing and serializing repeatedly', () => {
+ const source = '---\ntitle: "T"\n---\n\nAngle < bracket & ampersand "quoted"\n';
+
+ const once = serializeJournalMarkdown(parseJournalMarkdown(source));
+ const twice = serializeJournalMarkdown(parseJournalMarkdown(once));
+
+ expect(once).toContain('Angle < bracket & ampersand "quoted"');
+ expect(twice).toBe(once);
+ });
+
+ it('decodes exactly what sanitizeHtml encodes', () => {
+ const raw = ` 5 & 6 don't `;
+ expect(decodeHtmlEntities(sanitizeHtml(raw))).toBe(raw);
+ });
+});
diff --git a/lib/__tests__/journal-service-paths.test.ts b/lib/__tests__/journal-service-paths.test.ts
new file mode 100644
index 0000000..3751fcd
--- /dev/null
+++ b/lib/__tests__/journal-service-paths.test.ts
@@ -0,0 +1,108 @@
+import { describe, it, expect } from 'vitest';
+import path from 'path';
+import { resolveJournalFilePath } from '../admin/journal-service';
+import { parseJournalMarkdown, serializeJournalMarkdown, splitQuoteAuthor } from '../journal';
+
+describe('resolveJournalFilePath', () => {
+ it.each(['../../etc/passwd', 'foo/../../bar', '..', 'a/b', 'foo.md', '', '.'])(
+ 'refuses %j',
+ (slug) => {
+ expect(resolveJournalFilePath(slug)).toBeNull();
+ },
+ );
+
+ it('keeps a valid slug inside content/', () => {
+ const resolved = resolveJournalFilePath('my-entry');
+ expect(resolved).not.toBeNull();
+ expect(resolved!.startsWith(path.join(process.cwd(), 'content') + path.sep)).toBe(true);
+ expect(path.basename(resolved!)).toBe('my-entry.md');
+ });
+});
+
+describe('frontmatter escaping', () => {
+ const roundTrip = (title: string) => {
+ const once = serializeJournalMarkdown({
+ frontmatter: { title },
+ blocks: [],
+ referencedAssetIds: [],
+ });
+ const parsedOnce = parseJournalMarkdown(once);
+ const twice = serializeJournalMarkdown(parsedOnce);
+ return { first: parsedOnce.frontmatter.title, stable: once === twice };
+ };
+
+ it.each([
+ ['plain', 'A normal title'],
+ ['quotes', 'A "quoted" title'],
+ ['backslash', String.raw`Path C:\Users`],
+ ['both', String.raw`C:\Users "quoted"`],
+ ['colon', 'Title: with a colon'],
+ ['apostrophe', "Author's title"],
+ ])('survives a title with %s', (_name, title) => {
+ const { first, stable } = roundTrip(title);
+ expect(first).toBe(title);
+ expect(stable).toBe(true);
+ });
+});
+
+describe('parser behaviour is unchanged by the regex rework', () => {
+ const parse = (body: string) => parseJournalMarkdown(`---\ntitle: "T"\n---\n\n${body}\n`).blocks;
+
+ it('parses headings at every level', () => {
+ expect(parse('### Third level')[0]).toEqual({
+ type: 'heading',
+ level: 3,
+ text: 'Third level',
+ });
+ });
+
+ it('splits a quote from its author on the first separator', () => {
+ expect(parse('> Something quotable -- Some Author')[0]).toEqual({
+ type: 'quote',
+ text: 'Something quotable',
+ author: 'Some Author',
+ });
+ });
+
+ it('leaves a quote without a separator whole', () => {
+ expect(parse('> Just a quote')[0]).toEqual({
+ type: 'quote',
+ text: 'Just a quote',
+ author: undefined,
+ });
+ });
+
+ it('still nests italic inside bold', () => {
+ expect(parse('Text with **bold *and italic* inside**.')[0]).toEqual({
+ type: 'paragraph',
+ html: 'Text with bold and italic inside .',
+ });
+ });
+});
+
+describe('splitQuoteAuthor', () => {
+ it.each([
+ ['separator', 'Quote -- Author', { body: 'Quote', author: 'Author' }],
+ ['no separator', 'Just a quote', { body: 'Just a quote', author: undefined }],
+ [
+ 'first of several',
+ 'Quote -- Author -- Second',
+ { body: 'Quote', author: 'Author -- Second' },
+ ],
+ ['tabs', 'Quote\t--\tAuthor', { body: 'Quote', author: 'Author' }],
+ ['wide gap', 'Quote -- Author', { body: 'Quote', author: 'Author' }],
+ // Whitespace on both sides is required, so these are not separators.
+ ['no surrounding space', 'no--separator', { body: 'no--separator', author: undefined }],
+ ['leading', '-- author only', { body: '-- author only', author: undefined }],
+ ['trailing', 'Quote --', { body: 'Quote --', author: undefined }],
+ ['empty', '', { body: '', author: undefined }],
+ ])('handles %s', (_name, input, expected) => {
+ expect(splitQuoteAuthor(input as string)).toEqual(expected);
+ });
+
+ it('stays linear on a long run of whitespace with no separator', () => {
+ const started = performance.now();
+ splitQuoteAuthor(' '.repeat(50_000) + 'x');
+ expect(performance.now() - started).toBeLessThan(100);
+ });
+});
diff --git a/lib/__tests__/journal.test.ts b/lib/__tests__/journal.test.ts
new file mode 100644
index 0000000..09e95b1
--- /dev/null
+++ b/lib/__tests__/journal.test.ts
@@ -0,0 +1,174 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import {
+ parseJournalMarkdown,
+ serializeJournalMarkdown,
+ sanitizeHtml,
+ renderInlineMarkdown,
+ calculateReadingTime,
+ extractExcerpt,
+ 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 ';
+ 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('bold ');
+ expect(html).toContain('italic ');
+ expect(html).toContain(' {
+ 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();
+ });
+ });
+});
diff --git a/lib/__tests__/proxy.test.ts b/lib/__tests__/proxy.test.ts
index c2378df..0c9d7cf 100644
--- a/lib/__tests__/proxy.test.ts
+++ b/lib/__tests__/proxy.test.ts
@@ -47,6 +47,21 @@ describe('proxy', () => {
);
});
+ it('adds unsafe-eval to script-src in development mode for React dev tools and Fast Refresh', () => {
+ const envObj = process.env as Record;
+ const origEnv = envObj.NODE_ENV;
+ try {
+ envObj.NODE_ENV = 'development';
+ expect(run().headers.get('Content-Security-Policy')).toContain("'unsafe-eval'");
+ } finally {
+ envObj.NODE_ENV = origEnv;
+ }
+ });
+
+ it('omits unsafe-eval in production/test environments', () => {
+ expect(run().headers.get('Content-Security-Policy')).not.toContain("'unsafe-eval'");
+ });
+
it('keeps a matcher that excludes api and static assets but covers /admin', () => {
const source = config.matcher[0].source;
expect(source).toContain('api');
diff --git a/lib/admin/auth.ts b/lib/admin/auth.ts
index 7a96588..baccb1c 100644
--- a/lib/admin/auth.ts
+++ b/lib/admin/auth.ts
@@ -4,8 +4,9 @@
*/
import crypto from 'crypto';
-import { env } from '../env';
+import { getInstallCredentials } from '../install';
import { resolveAuthSecret } from '../secret';
+import { isScryptHash, verifyScrypt } from '../password';
import { cookies } from 'next/headers';
const COOKIE_NAME = 'folio_admin_session';
@@ -20,14 +21,48 @@ const SESSION_DURATION_MS = 24 * 60 * 60 * 1000; // 24 hours
*/
export const MAX_PASSWORD_LENGTH = 1024;
+/** Admin password as configured — env wins, wizard-installed value as fallback. */
+function getAdminPassword(): string {
+ return getInstallCredentials().adminPassword;
+}
+
+/**
+ * Derived session signing key, kept for the process lifetime.
+ *
+ * scrypt is deliberately slow, and getSigningKey() runs on every admin request
+ * — deriving per call would put ~24ms in front of each one. The cache holds the
+ * inputs it was derived from, so a rotated password or secret misses the cache
+ * and derives again.
+ *
+ * Those inputs are compared directly rather than through a digest of them: a
+ * digest here would be a fast hash of the admin password sitting in memory for
+ * the life of the process, which is the very thing scrypt was introduced below
+ * to avoid. Holding the strings costs nothing extra — they are the same objects
+ * the env and install caches already hold, whereas building a digest input
+ * allocates a fresh copy of the password.
+ */
+let cachedSigningKey: { secret: string; password: string; key: Buffer } | null = null;
+
function getSigningKey(): Buffer {
// Bind the key to ADMIN_PASSWORD as well, so rotating the password
// immediately invalidates every outstanding session token.
const secret = resolveAuthSecret();
- return crypto
- .createHash('sha256')
- .update(`admin:${secret}:${env.ADMIN_PASSWORD ?? ''}`)
- .digest();
+ const password = getAdminPassword();
+
+ if (cachedSigningKey?.secret === secret && cachedSigningKey.password === password) {
+ return cachedSigningKey.key;
+ }
+
+ /*
+ * scrypt rather than a plain SHA-256 digest: the admin password is an input
+ * here, and a single fast hash would make it cheap to recover by brute force
+ * if the derived key ever leaked. The salt has to be stable — a random one
+ * would change the key on every call and invalidate every session — so it is
+ * derived from AUTH_SECRET, which is deployment-specific.
+ */
+ const key = crypto.scryptSync(password, `folio-admin-session:${secret}`, 32);
+ cachedSigningKey = { secret, password, key };
+ return key;
}
/** Create a signed session token. */
@@ -83,11 +118,18 @@ export function verifyAdminToken(token: string): boolean {
* compares subpage passwords: the intermediate values are then useless to
* anyone who cannot also read the secret.
*/
-export function verifyAdminPassword(password: string): boolean {
- const adminPw = env.ADMIN_PASSWORD;
+export async function verifyAdminPassword(password: string): Promise {
+ const adminPw = getAdminPassword();
if (!adminPw) return false;
if (password.length > MAX_PASSWORD_LENGTH) return false;
+ // The wizard stores a hash; ADMIN_PASSWORD as an env var stays plaintext,
+ // because that is what every existing deployment has set. Both must work,
+ // or an upgrade locks the owner out of their own admin panel.
+ if (isScryptHash(adminPw)) {
+ return verifyScrypt(password, adminPw);
+ }
+
const secret = resolveAuthSecret();
const attemptHash = crypto.createHmac('sha256', secret).update(password).digest();
const expectedHash = crypto.createHmac('sha256', secret).update(adminPw).digest();
@@ -105,7 +147,7 @@ export async function isAdminAuthenticated(): Promise {
/** Check if admin panel is enabled (password is set). */
export function isAdminEnabled(): boolean {
- return !!env.ADMIN_PASSWORD;
+ return !!getAdminPassword();
}
export { COOKIE_NAME, SESSION_DURATION_MS };
diff --git a/lib/admin/journal-service.ts b/lib/admin/journal-service.ts
new file mode 100644
index 0000000..2d76231
--- /dev/null
+++ b/lib/admin/journal-service.ts
@@ -0,0 +1,216 @@
+/**
+ * Server-side Journal File & Storage Service.
+ * Handles reading, listing, atomic writing, backups, and deletion of journal markdown files.
+ */
+
+import fs from 'fs/promises';
+import nodeFs from 'fs';
+import path from 'path';
+import {
+ parseJournalMarkdown,
+ calculateReadingTime,
+ extractExcerpt,
+ isValidSlug,
+ type JournalEntrySummary,
+ type ParsedJournal,
+} from '../journal';
+
+const JOURNAL_DIR = path.join(process.cwd(), 'content', 'journal');
+const LEGACY_ESSAYS_DIR = path.join(process.cwd(), 'content', 'essays');
+const MAX_BACKUPS = 10;
+let tmpCounter = 0;
+
+/**
+ * Join a filename onto a content directory, or return null if the result would
+ * escape it.
+ *
+ * isValidSlug() already rejects dots and separators, so nothing should ever get
+ * this far — this is the second lock on the door. It also gives the taint
+ * analysis in CI a barrier it can see: the slug check lives in another module,
+ * which CodeQL does not follow, so every path built from a slug was reported as
+ * path injection.
+ */
+function containedPath(dir: string, filename: string): string | null {
+ const base = path.resolve(dir);
+ const resolved = path.resolve(base, filename);
+ if (resolved !== base && !resolved.startsWith(base + path.sep)) return null;
+ return resolved;
+}
+
+/** Resolve file path for a journal slug (checks content/journal/ then content/essays/) */
+export function resolveJournalFilePath(slug: string): string | null {
+ if (!isValidSlug(slug)) return null;
+ // Not `slug.endsWith('.md') ? slug : ...`: isValidSlug rejects dots, so that
+ // branch could never be taken.
+ const filename = `${slug}.md`;
+
+ const primaryPath = containedPath(JOURNAL_DIR, filename);
+ if (!primaryPath) return null;
+ if (nodeFs.existsSync(primaryPath)) return primaryPath;
+
+ const legacyPath = containedPath(LEGACY_ESSAYS_DIR, filename);
+ if (legacyPath && nodeFs.existsSync(legacyPath)) return legacyPath;
+
+ return primaryPath; // Target path for new writes
+}
+
+/** Load and parse an essay file from content/essays/ or content/journal/ */
+export function loadEssayFromFile(filename: string): ParsedJournal | null {
+ try {
+ const filePath = resolveJournalFilePath(filename);
+ if (!filePath || !nodeFs.existsSync(filePath)) {
+ console.warn(`[Essay] File not found: ${filename}`);
+ return null;
+ }
+
+ const content = nodeFs.readFileSync(filePath, 'utf-8');
+ return parseJournalMarkdown(content);
+ } catch (error) {
+ console.error(`[Essay] Failed to load essay file "${filename}":`, error);
+ return null;
+ }
+}
+
+/** List all journal entries */
+export async function listJournalEntries(): Promise {
+ const entries: JournalEntrySummary[] = [];
+ const seenSlugs = new Set();
+
+ const scanDir = async (dir: string) => {
+ try {
+ await fs.access(dir);
+ } catch {
+ return;
+ }
+
+ try {
+ const files = await fs.readdir(dir);
+ for (const file of files) {
+ if (!file.endsWith('.md')) continue;
+ const slug = file.replace(/\.md$/, '');
+ if (seenSlugs.has(slug)) continue;
+ seenSlugs.add(slug);
+
+ try {
+ const content = await fs.readFile(path.join(dir, file), 'utf8');
+ const parsed = parseJournalMarkdown(content);
+ const { words, minutes } = calculateReadingTime(content);
+ const excerpt = extractExcerpt(parsed);
+
+ entries.push({
+ slug,
+ filename: file,
+ frontmatter: parsed.frontmatter,
+ excerpt,
+ wordCount: words,
+ readingTimeMinutes: minutes,
+ });
+ } catch (err) {
+ console.error(`[Journal] Failed to parse ${file}:`, err);
+ }
+ }
+ } catch (err) {
+ console.error(`[Journal] Failed to read directory ${dir}:`, err);
+ }
+ };
+
+ await scanDir(JOURNAL_DIR);
+ await scanDir(LEGACY_ESSAYS_DIR);
+
+ // Sort: descending by date, fallback to title/slug
+ return entries.sort((a, b) => {
+ const dateA = a.frontmatter.date || '';
+ const dateB = b.frontmatter.date || '';
+ if (dateA && dateB) return dateB.localeCompare(dateA);
+ if (dateA) return -1;
+ if (dateB) return 1;
+ return (a.frontmatter.title || a.slug).localeCompare(b.frontmatter.title || b.slug);
+ });
+}
+
+/** Read a single journal entry by slug */
+export async function readJournalEntry(slug: string): Promise<{
+ slug: string;
+ rawMarkdown: string;
+ parsed: ParsedJournal;
+} | null> {
+ const filePath = resolveJournalFilePath(slug);
+ if (!filePath) return null;
+
+ try {
+ const rawMarkdown = await fs.readFile(filePath, 'utf8');
+ const parsed = parseJournalMarkdown(rawMarkdown);
+ return { slug, rawMarkdown, parsed };
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
+ throw err;
+ }
+}
+
+/** Atomically write/save a journal entry with backup rotation */
+export async function writeJournalEntry(slug: string, rawMarkdown: string): Promise {
+ if (!isValidSlug(slug)) {
+ throw new Error(`Invalid journal slug: "${slug}"`);
+ }
+
+ await fs.mkdir(JOURNAL_DIR, { recursive: true });
+ const filename = `${slug}.md`;
+ const filePath = containedPath(JOURNAL_DIR, filename);
+ if (!filePath) {
+ throw new Error(`Invalid journal slug: "${slug}"`);
+ }
+
+ // Create rolling backup if file already exists
+ try {
+ await fs.access(filePath);
+ const backupDir = path.join(JOURNAL_DIR, '.backups');
+ await fs.mkdir(backupDir, { recursive: true });
+
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const backupName = `${filename}.${timestamp}.bak`;
+ await fs.copyFile(filePath, path.join(backupDir, backupName));
+
+ // Prune backups
+ const backups = (await fs.readdir(backupDir))
+ .filter((f) => f.startsWith(`${filename}.`) && f.endsWith('.bak'))
+ .sort();
+ if (backups.length > MAX_BACKUPS) {
+ for (const old of backups.slice(0, backups.length - MAX_BACKUPS)) {
+ await fs.unlink(path.join(backupDir, old)).catch(() => {});
+ }
+ }
+ } catch {
+ // New file, no backup needed
+ }
+
+ // Atomic write via unique temp file
+ const tmpPath = `${filePath}.${process.pid}.${++tmpCounter}.tmp`;
+ try {
+ await fs.writeFile(tmpPath, rawMarkdown, 'utf8');
+ await fs.rename(tmpPath, filePath);
+ } catch (err) {
+ await fs.unlink(tmpPath).catch(() => {});
+ throw err;
+ }
+
+ console.log(`[Journal] ✅ Saved ${filename}`);
+}
+
+/** Safely delete a journal entry */
+export async function deleteJournalEntry(slug: string): Promise {
+ if (!isValidSlug(slug)) {
+ throw new Error(`Invalid journal slug: "${slug}"`);
+ }
+
+ const filePath = resolveJournalFilePath(slug);
+ if (!filePath) return false;
+
+ try {
+ await fs.unlink(filePath);
+ console.log(`[Journal] 🗑️ Deleted ${slug}.md`);
+ return true;
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
+ throw err;
+ }
+}
diff --git a/lib/auth.ts b/lib/auth.ts
index c66c465..d59cfcc 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -8,6 +8,7 @@
import crypto from 'crypto';
import { getConfig, SubpageConfig } from './config';
+import { verifyScrypt, generateScryptHash, isScryptHash } from './password';
const TOKEN_EXPIRY_HOURS = 24;
@@ -18,52 +19,6 @@ function isBcryptHash(str: string): boolean {
return str.startsWith('$2a$') || str.startsWith('$2b$') || str.startsWith('$2y$');
}
-/**
- * scrypt on the libuv threadpool rather than the main thread.
- *
- * scryptSync costs ~23ms per call, and that is 23ms during which the process
- * serves nothing else. The /api/auth rate limit (10/min) is keyed on the client
- * IP, but with the default TRUSTED_PROXY_HOPS=0 that IP comes from a spoofable
- * header (see lib/rate-limit.ts), so an attacker rotating the header gets
- * effectively unlimited buckets — each request buying 23ms of dead process.
- * Running async does not stop the spoofing, but it removes the amplification
- * that made it worth doing.
- */
-function scryptAsync(password: string, salt: string, keylen: number): Promise {
- return new Promise((resolve, reject) => {
- crypto.scrypt(password, salt, keylen, (err, derivedKey) => {
- if (err) reject(err);
- else resolve(derivedKey);
- });
- });
-}
-
-/**
- * Native SCrypt verify (format: 'scrypt:salt:hash_hex')
- */
-async function verifyScrypt(password: string, stored: string): Promise {
- if (!stored.startsWith('scrypt:')) return false;
-
- try {
- const [, salt, hashHex] = stored.split(':');
- const key = await scryptAsync(password, salt, 64);
- const expectedKey = Buffer.from(hashHex, 'hex');
- if (key.length !== expectedKey.length) return false;
- return crypto.timingSafeEqual(key, expectedKey);
- } catch {
- return false;
- }
-}
-
-/**
- * Helper to generate an scrypt string to print in logs.
- */
-async function generateScryptHash(password: string): Promise {
- const salt = crypto.randomBytes(16).toString('hex');
- const hash = (await scryptAsync(password, salt, 64)).toString('hex');
- return `scrypt:${salt}:${hash}`;
-}
-
function hmac(data: string): string {
return crypto.createHmac('sha256', getConfig().authSecret).update(data).digest('hex');
}
@@ -81,8 +36,14 @@ function authToken(key: string, passwordSecret: string, expiresAt: number): stri
return `${expiresAt}.${hmac(`${key}:${passwordSecret}:${expiresAt}`)}`;
}
-function cookieName(key: string, type: 'subpage' | 'album'): string {
- return type === 'subpage' ? `lb_auth_${key}` : `lb_auth_album_${key}`;
+import nodeFs from 'fs';
+import { parseFrontmatter } from './journal';
+import { resolveJournalFilePath } from './admin/journal-service';
+
+function cookieName(key: string, type: 'subpage' | 'album' | 'journal'): string {
+ if (type === 'subpage') return `lb_auth_${key}`;
+ if (type === 'journal') return `lb_auth_journal_${key}`;
+ return `lb_auth_album_${key}`;
}
/**
@@ -93,20 +54,33 @@ export function findSubpageBySlug(slug: string): SubpageConfig | undefined {
}
/**
- * Find the password secret for a given subpage slug or album ID.
+ * Find the password secret for a given subpage slug, album ID, or journal entry.
*/
-function findPassword(key: string, type: 'subpage' | 'album'): string | undefined {
+function findPassword(key: string, type: 'subpage' | 'album' | 'journal'): string | undefined {
const config = getConfig();
if (type === 'subpage') {
return config.subpages.find((sp) => sp.slug === key)?.password;
}
- return config.albumPasswords[key];
+ if (type === 'album') {
+ return config.albumPasswords[key];
+ }
+ if (type === 'journal') {
+ try {
+ const filePath = resolveJournalFilePath(key);
+ if (filePath && nodeFs.existsSync(filePath)) {
+ const raw = nodeFs.readFileSync(filePath, 'utf8');
+ return parseFrontmatter(raw).frontmatter.password;
+ }
+ } catch {}
+ return undefined;
+ }
+ return undefined;
}
/**
- * Check if a subpage slug or album ID is password-protected.
+ * Check if a subpage slug, album ID, or journal entry is password-protected.
*/
-export function isProtected(key: string, type: 'subpage' | 'album' = 'subpage'): boolean {
+export function isProtected(key: string, type: 'subpage' | 'album' | 'journal' = 'subpage'): boolean {
return !!findPassword(key, type);
}
@@ -117,7 +91,7 @@ export function isProtected(key: string, type: 'subpage' | 'album' = 'subpage'):
export async function authenticate(
key: string,
password: string,
- type: 'subpage' | 'album' = 'subpage',
+ type: 'subpage' | 'album' | 'journal' = 'subpage',
): Promise {
const storedPassword = findPassword(key, type);
if (!storedPassword) return null;
@@ -134,7 +108,7 @@ export async function authenticate(
return null;
}
- if (storedPassword.startsWith('scrypt:')) {
+ if (isScryptHash(storedPassword)) {
isValid = await verifyScrypt(password, storedPassword);
} else {
// Plaintext fallback (deprecated)
@@ -174,7 +148,7 @@ export async function authenticate(
export function isAuthenticated(
key: string,
getCookie: (name: string) => string | undefined,
- type: 'subpage' | 'album' = 'subpage',
+ type: 'subpage' | 'album' | 'journal' = 'subpage',
): boolean {
const storedPassword = findPassword(key, type);
if (!storedPassword) return true; // not protected
diff --git a/lib/config/index.ts b/lib/config/index.ts
index bf3d8a9..d2afef4 100644
--- a/lib/config/index.ts
+++ b/lib/config/index.ts
@@ -1,5 +1,6 @@
import { env } from '../env';
import { resolveAuthSecret } from '../secret';
+import { getInstallCredentials } from '../install';
import { loadYaml, clearYamlCache, validateUuid } from './parser';
import { resolveTheme, VALID_LAYOUTS } from './theme';
import { ALBUM_SORT_MODES, isAlbumSortMode, type AlbumSortMode } from '../albumSort';
@@ -90,8 +91,10 @@ export interface GalleryDerivation {
/**
* Derive the gallery half of AppConfig, throwing on a structure that cannot be
- * represented: no albums and no subpages, a subpage with no name, a subpage
- * with neither albums nor sections.
+ * represented: a subpage with no name, a subpage with neither albums nor
+ * sections. A gallery with no albums and no subpages is NOT an error — an
+ * empty gallery still renders (title, hero-less homepage) so the owner can
+ * add albums later via /admin.
*
* Extracted from getConfig() so the admin PUT can run the *real* derivation
* against a proposed gallery before writing it. Re-implementing the three
@@ -166,15 +169,6 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
const standaloneAlbumIds = (gallery.albums ?? []).map((entry) =>
processAlbumEntry(entry, 'gallery.yaml albums'),
);
- if (
- standaloneAlbumIds.length === 0 &&
- (!gallery.subpages ||
- (Array.isArray(gallery.subpages)
- ? gallery.subpages.length === 0
- : Object.keys(gallery.subpages).length === 0))
- ) {
- throw new Error('gallery.yaml must define at least one album or subpage');
- }
let subpages: SubpageConfig[] = [];
@@ -276,17 +270,21 @@ export function getConfig(): AppConfig {
// worker/process than page rendering, so a per-worker cache goes stale
// until restart. Freshness comes from the mtime-checked YAML cache in
// loadYaml() — a statSync per file, negligible next to Immich API calls.
- const apiUrl = env.IMMICH_API_URL;
- const apiKey = env.IMMICH_API_KEY;
+ const { apiUrl, apiKey } = getInstallCredentials();
const authSecret = resolveAuthSecret();
+ // The API URL may already end with /api (a user-supplied env var or a
+ // wizard-installed value). Appending unconditionally produces a double
+ // suffix and breaks every Immich request.
+ const immichApiUrl = apiUrl.endsWith('/api') ? apiUrl : `${apiUrl}/api`;
+
const gallery = loadYaml('gallery.yaml');
const settings = loadYaml('settings.yaml') || {};
if (!gallery || !apiKey || !apiUrl) {
// Return dummy config if gallery.yaml is missing
return {
- immich: { apiUrl: `${apiUrl}/api`, apiKey },
+ immich: { apiUrl: immichApiUrl, apiKey },
authSecret,
albums: [],
standaloneAlbums: [],
@@ -309,6 +307,7 @@ export function getConfig(): AppConfig {
legal: { enabled: false, name: '', address: '', zipCity: '', country: '' },
map: false,
transitions: false,
+ scrollToTop: false,
analytics: true,
proofing: { enabled: false, allowMailto: false },
albumOverrides: {},
@@ -343,7 +342,7 @@ export function getConfig(): AppConfig {
const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery';
return {
- immich: { apiUrl: `${apiUrl}/api`, apiKey },
+ immich: { apiUrl: immichApiUrl, apiKey },
authSecret,
albums: allAlbumIds,
standaloneAlbums,
@@ -399,6 +398,7 @@ export function getConfig(): AppConfig {
},
map: settings.map === true,
transitions: settings.transitions !== false,
+ scrollToTop: settings.scrollToTop !== false,
analytics: settings.analytics !== false,
proofing: {
enabled: settings.proofing?.enabled !== false,
diff --git a/lib/config/schema.ts b/lib/config/schema.ts
index e0314ba..237764e 100644
--- a/lib/config/schema.ts
+++ b/lib/config/schema.ts
@@ -104,6 +104,8 @@ export interface AppConfig {
legal: LegalConfig;
map: boolean;
transitions: boolean;
+ /** Floating back-to-top arrow in the frontend. */
+ scrollToTop: boolean;
analytics: boolean;
proofing: {
enabled: boolean;
@@ -195,6 +197,7 @@ export interface SettingsYaml {
exifOnHover?: boolean;
map?: boolean;
transitions?: boolean;
+ scrollToTop?: boolean;
analytics?: boolean;
proofing?: {
enabled?: boolean;
diff --git a/lib/env.ts b/lib/env.ts
index 8eef055..b5cb836 100644
--- a/lib/env.ts
+++ b/lib/env.ts
@@ -17,6 +17,7 @@ export interface Env {
TRUSTED_PROXY_HOPS: number;
WEBHOOK_SECRET?: string;
ADMIN_PASSWORD?: string;
+ INSTALL_CONTENT_DIR?: string;
}
function parseEnv(): Env {
@@ -109,6 +110,7 @@ function parseEnv(): Env {
TRUSTED_PROXY_HOPS: trustedProxyHops,
WEBHOOK_SECRET: process.env.WEBHOOK_SECRET || undefined,
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || undefined,
+ INSTALL_CONTENT_DIR: process.env.INSTALL_CONTENT_DIR || undefined,
};
}
diff --git a/lib/essay.ts b/lib/essay.ts
index c0bcd58..cbb651e 100644
--- a/lib/essay.ts
+++ b/lib/essay.ts
@@ -1,273 +1,15 @@
-export interface EssayFrontmatter {
- title?: string;
- subtitle?: string;
- date?: string;
- author?: string;
- coverAssetId?: string;
- layout?: string;
-}
+/**
+ * Essay module (re-exports from lib/journal for full backwards compatibility).
+ * Client-safe pure module.
+ */
-export type EssayBlock =
- | { type: 'heading'; level: number; text: string }
- | { type: 'paragraph'; html: string }
- | { type: 'quote'; text: string; author?: string }
- | { type: 'photo'; assetId: string; caption?: string; layout: 'fullbleed' | 'wide' | 'contained' }
- | { type: 'photo-pair'; assetIds: [string, string]; caption?: string };
-
-export interface ParsedEssay {
- frontmatter: EssayFrontmatter;
- blocks: EssayBlock[];
- referencedAssetIds: string[];
-}
-
-/** Sanitize inline HTML strings to prevent XSS attacks in essay blocks */
-export function sanitizeHtml(input: string): string {
- return input
- .replace(/