From 4a6df7fc51e609f31609a0678334f73a7ad1d57e Mon Sep 17 00:00:00 2001 From: Tayeb Mokni Date: Tue, 26 May 2026 13:14:51 +0200 Subject: [PATCH 1/2] feat(theme): child theme override resolution (#46) Adds ResolveTemplate(name, SearchPath) and ResolveWithChild(req, SearchPath) to packages/go/theme/templates. SearchPath is an ordered list of ThemeFiles walked child-first; the first hit wins, matching the WordPress child-theme contract. Pure functions, no I/O, safe for concurrent use. Tests cover parent-only, child-only, child-overrides-parent, nil/empty search paths, precedence walk across both stores, and the ErrNoIndex / ErrUnknownRequestType propagation. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Tayeb Mokni --- packages/go/theme/templates/child.go | 98 ++++++++++ packages/go/theme/templates/child_test.go | 212 ++++++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 packages/go/theme/templates/child.go create mode 100644 packages/go/theme/templates/child_test.go diff --git a/packages/go/theme/templates/child.go b/packages/go/theme/templates/child.go new file mode 100644 index 00000000..371569f4 --- /dev/null +++ b/packages/go/theme/templates/child.go @@ -0,0 +1,98 @@ +package templates + +// SearchPath is an ordered list of ThemeFiles backing stores, walked +// from index 0 (child / most-specific) toward the last (parent / +// least-specific). It is the cornerstone of child-theme override +// resolution: a child theme overrides a parent template by shipping +// the same filename under its own root; the resolver returns the +// child's file when present and falls back to the parent otherwise. +// +// The walk order matters: child-first matches the WordPress contract +// authors expect, where a child can shadow any parent template +// without forking the parent's directory. +// +// A nil SearchPath is treated as an empty one — ResolveTemplate +// returns ("", false) without panicking. An empty entry inside the +// slice is also skipped, so callers can build the path conditionally +// without filtering nils out beforehand. +type SearchPath []ThemeFiles + +// ResolveTemplate walks search in order and returns the first entry +// whose ThemeFiles.Has(name) reports the template present. The +// boolean return mirrors the comma-ok idiom Go callers already use +// for map lookups and registry probes, so the call site stays terse: +// +// idx, ok := templates.ResolveTemplate("single.tsx", search) +// if !ok { /* fall back */ } +// +// The returned int is the index in `search` that owned the match, +// not a pointer into the slice — that keeps the value immutable +// (callers cannot accidentally mutate the original ThemeFiles +// through it) and lets the caller distinguish "child won" (index 0) +// from "parent won" (index 1) for diagnostics or audit logging +// without re-walking. +// +// The function is pure: no I/O, no globals, no panics on a nil or +// empty path. It is safe to call concurrently as long as the +// underlying ThemeFiles implementations are safe themselves. +func ResolveTemplate(name string, search SearchPath) (int, bool) { + if name == "" { + return 0, false + } + for i, files := range search { + if files == nil { + continue + } + if files.Has(name) { + return i, true + } + } + return 0, false +} + +// ResolveWithChild is a thin convenience wrapper around the +// Resolver.Resolve precedence walk, layered over a SearchPath so a +// child theme can override any candidate the resolver would otherwise +// pick from the parent. It is the two-stage form most callers want +// in production: classify the request, then resolve against the +// child→parent path. +// +// The walk is depth-first across the precedence list rather than +// breadth-first across the path: for every candidate basename the +// resolver would try, ResolveWithChild walks the SearchPath in order +// and returns the first hit. This matches the WordPress child-theme +// contract — a child's single-book.tsx overrides the parent's +// single-book.tsx even though the parent also ships an index.tsx +// the child does not. +// +// On match it returns (filename, owningIndex, nil). owningIndex is +// the same "which entry in search owned the match" datum +// ResolveTemplate returns, so the caller can audit "this render came +// from the child theme" vs "this render came from the parent" without +// re-walking. +// +// On failure it returns ErrNoIndex (the SearchPath collectively did +// not ship an index template) or ErrUnknownRequestType (the Request +// was unclassified). The error semantics mirror DefaultResolver so +// switching from Resolve to ResolveWithChild is a one-line change. +func ResolveWithChild(req Request, search SearchPath) (string, int, error) { + candidates := buildCandidates(req) + if candidates == nil { + return "", 0, ErrUnknownRequestType + } + if len(search) == 0 { + return "", 0, ErrNoIndex + } + for _, base := range candidates { + if base == "" { + continue + } + for _, ext := range extensions { + name := base + ext + if idx, ok := ResolveTemplate(name, search); ok { + return name, idx, nil + } + } + } + return "", 0, ErrNoIndex +} diff --git a/packages/go/theme/templates/child_test.go b/packages/go/theme/templates/child_test.go new file mode 100644 index 00000000..0233217e --- /dev/null +++ b/packages/go/theme/templates/child_test.go @@ -0,0 +1,212 @@ +package templates_test + +import ( + "errors" + "testing" + + "github.com/Singleton-Solution/GoNext/packages/go/theme/templates" +) + +// mapFiles is an in-memory ThemeFiles implementation used throughout +// the templates test suite. Keys are the bare basenames the resolver +// queries; values are unused. The empty map is a theme that ships no +// files at all (used to assert "parent owns" cases). +type mapFiles map[string]struct{} + +func (m mapFiles) Has(name string) bool { + _, ok := m[name] + return ok +} + +// TestResolveTemplate_ParentOnly covers the fallback path: the child +// ships nothing, the parent ships the file, so the parent index wins. +func TestResolveTemplate_ParentOnly(t *testing.T) { + t.Parallel() + child := mapFiles{} + parent := mapFiles{"single.tsx": {}} + search := templates.SearchPath{child, parent} + + idx, ok := templates.ResolveTemplate("single.tsx", search) + if !ok { + t.Fatalf("ResolveTemplate: expected hit on parent, got miss") + } + if idx != 1 { + t.Errorf("owning index = %d; want 1 (parent)", idx) + } +} + +// TestResolveTemplate_ChildOnly covers the case where the parent +// doesn't ship the file at all and the child carries the whole +// template. Both common (child adds a new template parent never +// declared) and a regression check that a missing parent file +// doesn't blow up the walk. +func TestResolveTemplate_ChildOnly(t *testing.T) { + t.Parallel() + child := mapFiles{"taxonomy-genre.tsx": {}} + parent := mapFiles{"index.tsx": {}} + search := templates.SearchPath{child, parent} + + idx, ok := templates.ResolveTemplate("taxonomy-genre.tsx", search) + if !ok { + t.Fatalf("ResolveTemplate: expected hit on child, got miss") + } + if idx != 0 { + t.Errorf("owning index = %d; want 0 (child)", idx) + } +} + +// TestResolveTemplate_ChildOverridesParent is the headline case: both +// theme roots ship the same filename, and the child must win because +// the walk order is child→parent. +func TestResolveTemplate_ChildOverridesParent(t *testing.T) { + t.Parallel() + child := mapFiles{"single.tsx": {}, "extra.tsx": {}} + parent := mapFiles{"single.tsx": {}, "index.tsx": {}} + search := templates.SearchPath{child, parent} + + idx, ok := templates.ResolveTemplate("single.tsx", search) + if !ok { + t.Fatalf("ResolveTemplate: expected hit, got miss") + } + if idx != 0 { + t.Errorf("child must override parent; got owning index %d (want 0)", idx) + } +} + +// TestResolveTemplate_NilSearch documents the "pure function over an +// empty path" contract: no panic, no hit. +func TestResolveTemplate_NilSearch(t *testing.T) { + t.Parallel() + if _, ok := templates.ResolveTemplate("single.tsx", nil); ok { + t.Errorf("ResolveTemplate(nil) reported a hit; want miss") + } +} + +// TestResolveTemplate_EmptyName guards against a caller accidentally +// passing the empty string (the result must be a miss, not a match +// on the empty filename). +func TestResolveTemplate_EmptyName(t *testing.T) { + t.Parallel() + files := mapFiles{"": {}} + if _, ok := templates.ResolveTemplate("", templates.SearchPath{files}); ok { + t.Errorf("ResolveTemplate(\"\") reported a hit; want miss") + } +} + +// TestResolveTemplate_SkipsNilEntry asserts the walker tolerates a +// nil ThemeFiles in the search path. Production callers may +// conditionally build the path (e.g. "child only if it exists on +// disk") and we don't want to force them to filter nils out. +func TestResolveTemplate_SkipsNilEntry(t *testing.T) { + t.Parallel() + parent := mapFiles{"single.tsx": {}} + search := templates.SearchPath{nil, parent} + + idx, ok := templates.ResolveTemplate("single.tsx", search) + if !ok { + t.Fatalf("expected hit on parent past nil child") + } + if idx != 1 { + t.Errorf("owning index = %d; want 1", idx) + } +} + +// TestResolveWithChild_PrecedenceWalk asserts the two-stage resolver +// walks the precedence list in order, dropping down to less-specific +// candidates only after every entry in the search path missed on +// the more-specific one. This is the WP child-theme contract: a +// parent's archive-book.tsx still loses to the child's single.tsx +// for a single-post request. +func TestResolveWithChild_PrecedenceWalk(t *testing.T) { + t.Parallel() + child := mapFiles{"single.tsx": {}} + parent := mapFiles{"single-book.tsx": {}, "index.tsx": {}} + search := templates.SearchPath{child, parent} + req := templates.Request{ + Type: templates.RequestTypeSingular, + PostType: "book", + } + + name, idx, err := templates.ResolveWithChild(req, search) + if err != nil { + t.Fatalf("ResolveWithChild: %v", err) + } + // The precedence list says single-book wins over plain single, + // and the only single-book.* lives in the parent. + if name != "single-book.tsx" { + t.Errorf("name = %q; want %q", name, "single-book.tsx") + } + if idx != 1 { + t.Errorf("owning index = %d; want 1 (parent)", idx) + } +} + +// TestResolveWithChild_ChildOverridesAtSameLevel asserts the child +// wins when both ship the same precedence-level template. This is +// the headline case for #46. +func TestResolveWithChild_ChildOverridesAtSameLevel(t *testing.T) { + t.Parallel() + child := mapFiles{"single.tsx": {}} + parent := mapFiles{"single.tsx": {}, "index.tsx": {}} + search := templates.SearchPath{child, parent} + req := templates.Request{ + Type: templates.RequestTypeSingular, + PostType: "page", + } + + name, idx, err := templates.ResolveWithChild(req, search) + if err != nil { + t.Fatalf("ResolveWithChild: %v", err) + } + if name != "single.tsx" { + t.Errorf("name = %q; want %q", name, "single.tsx") + } + if idx != 0 { + t.Errorf("child must override parent; owning index = %d (want 0)", idx) + } +} + +// TestResolveWithChild_FallsBackToIndex covers the ultimate-fallback +// branch — neither child nor parent ships any of the more-specific +// candidates, so the resolver lands on index.tsx in the parent. +func TestResolveWithChild_FallsBackToIndex(t *testing.T) { + t.Parallel() + child := mapFiles{} + parent := mapFiles{"index.tsx": {}} + search := templates.SearchPath{child, parent} + req := templates.Request{Type: templates.RequestTypeSearch} + + name, idx, err := templates.ResolveWithChild(req, search) + if err != nil { + t.Fatalf("ResolveWithChild: %v", err) + } + if name != "index.tsx" { + t.Errorf("name = %q; want index.tsx", name) + } + if idx != 1 { + t.Errorf("owning index = %d; want 1 (parent)", idx) + } +} + +// TestResolveWithChild_ErrNoIndex asserts the contract that a search +// path missing index.* propagates the canonical sentinel error. +func TestResolveWithChild_ErrNoIndex(t *testing.T) { + t.Parallel() + search := templates.SearchPath{mapFiles{}, mapFiles{}} + req := templates.Request{Type: templates.RequestTypeHome} + _, _, err := templates.ResolveWithChild(req, search) + if !errors.Is(err, templates.ErrNoIndex) { + t.Errorf("err = %v; want ErrNoIndex", err) + } +} + +// TestResolveWithChild_UnknownRequestType asserts the unknown-type +// branch is reachable through the child-aware entry point too. +func TestResolveWithChild_UnknownRequestType(t *testing.T) { + t.Parallel() + search := templates.SearchPath{mapFiles{"index.tsx": {}}} + _, _, err := templates.ResolveWithChild(templates.Request{}, search) + if !errors.Is(err, templates.ErrUnknownRequestType) { + t.Errorf("err = %v; want ErrUnknownRequestType", err) + } +} From 9d9b50d77b295fbbace03036f6c2b94d45cf3121 Mon Sep 17 00:00:00 2001 From: Tayeb Mokni Date: Tue, 26 May 2026 13:23:23 +0200 Subject: [PATCH 2/2] feat(admin): theme installer + switcher (#13, #18, #65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API (apps/api/internal/admin/themes): - POST /api/v1/admin/themes/install — accepts .gntheme ZIP (raw or multipart), parses + validates theme.json, extracts atomically into themeDir// via a staging-dir rename. Rejects path traversal, symlinks, oversize entries, and zip bombs. - GET /api/v1/admin/themes — lists every directory under themeDir whose theme.json parses, plus the active slug from core.active_theme. - POST /api/v1/admin/themes/activate — switches the active theme after verifying the slug is installed on disk. Three sibling capabilities (install_themes / manage_themes / switch_themes) gate the routes. Admin UI: - /appearance/themes (#65) — umbrella page combining the gallery switcher and the drag-drop installer. Optimistic activate, live re-fetch after install. Empty-state encourages dropping a .gntheme. - /appearance/install (#13) — deep-link installer that redirects to the umbrella page on success. - Sidebar "Appearance" entry now points at /appearance/themes. Tests cover nested + flat archive layouts, missing/invalid manifests, existing-theme conflicts, path traversal, oversized uploads, and the inventory walk (skip broken / hidden directories). Co-Authored-By: Claude Opus 4.7 Signed-off-by: Tayeb Mokni --- .../(authenticated)/_components/Sidebar.tsx | 2 +- .../appearance/install/InstallerClient.tsx | 134 ++++++ .../appearance/install/page.tsx | 46 ++ .../appearance/themes/ThemesGalleryClient.tsx | 407 ++++++++++++++++++ .../appearance/themes/api-client.ts | 63 +++ .../(authenticated)/appearance/themes/api.ts | 94 ++++ .../appearance/themes/page.tsx | 43 ++ .../appearance/themes/types.ts | 24 ++ apps/api/cmd/server/main.go | 22 + apps/api/internal/admin/themes/doc.go | 27 ++ apps/api/internal/admin/themes/handler.go | 281 ++++++++++++ apps/api/internal/admin/themes/installer.go | 352 +++++++++++++++ .../internal/admin/themes/installer_test.go | 260 +++++++++++ apps/api/internal/admin/themes/inventory.go | 129 ++++++ apps/api/internal/admin/themes/store.go | 110 +++++ 15 files changed, 1993 insertions(+), 1 deletion(-) create mode 100644 apps/admin/src/app/(authenticated)/appearance/install/InstallerClient.tsx create mode 100644 apps/admin/src/app/(authenticated)/appearance/install/page.tsx create mode 100644 apps/admin/src/app/(authenticated)/appearance/themes/ThemesGalleryClient.tsx create mode 100644 apps/admin/src/app/(authenticated)/appearance/themes/api-client.ts create mode 100644 apps/admin/src/app/(authenticated)/appearance/themes/api.ts create mode 100644 apps/admin/src/app/(authenticated)/appearance/themes/page.tsx create mode 100644 apps/admin/src/app/(authenticated)/appearance/themes/types.ts create mode 100644 apps/api/internal/admin/themes/doc.go create mode 100644 apps/api/internal/admin/themes/handler.go create mode 100644 apps/api/internal/admin/themes/installer.go create mode 100644 apps/api/internal/admin/themes/installer_test.go create mode 100644 apps/api/internal/admin/themes/inventory.go create mode 100644 apps/api/internal/admin/themes/store.go diff --git a/apps/admin/src/app/(authenticated)/_components/Sidebar.tsx b/apps/admin/src/app/(authenticated)/_components/Sidebar.tsx index cc6a7538..3fa6dde3 100644 --- a/apps/admin/src/app/(authenticated)/_components/Sidebar.tsx +++ b/apps/admin/src/app/(authenticated)/_components/Sidebar.tsx @@ -106,7 +106,7 @@ const NAV_SECTIONS: readonly NavSection[] = [ { head: 'Studio', items: [ - { href: '/appearance/site-editor', label: 'Appearance', Icon: Palette }, + { href: '/appearance/themes', label: 'Appearance', Icon: Palette }, { href: '/appearance/customizer', label: 'Customize', Icon: Sliders }, { href: '/marketplace', label: 'Marketplace', Icon: Store }, { href: '/plugins', label: 'Plugins', Icon: Plug }, diff --git a/apps/admin/src/app/(authenticated)/appearance/install/InstallerClient.tsx b/apps/admin/src/app/(authenticated)/appearance/install/InstallerClient.tsx new file mode 100644 index 00000000..dee31477 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/appearance/install/InstallerClient.tsx @@ -0,0 +1,134 @@ +'use client'; + +/** + * Standalone .gntheme installer (issue #13). + * + * A drag-and-drop drop zone backed by the + * POST /api/v1/admin/themes/install endpoint. On success the user + * is forwarded to /appearance/themes so the new card lands in front + * of them; on failure the API's error message is surfaced inline. + * + * The drop zone, status banner, and inline file picker are + * intentionally the same primitives used by the umbrella themes + * page so the two surfaces feel like one feature even though they + * live in different routes. + */ + +import { useCallback, useState, type ChangeEvent, type DragEvent, type ReactElement } from 'react'; +import { useRouter } from 'next/navigation'; +import { Check, Loader2, Upload } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { installTheme } from '../themes/api-client'; + +type Status = + | { kind: 'idle' } + | { kind: 'busy'; message: string } + | { kind: 'success'; message: string } + | { kind: 'error'; message: string }; + +export function InstallerClient(): ReactElement { + const router = useRouter(); + const [status, setStatus] = useState({ kind: 'idle' }); + const [dragOver, setDragOver] = useState(false); + + const onUpload = useCallback( + async (file: File) => { + if (!/\.(gntheme|zip)$/i.test(file.name)) { + setStatus({ kind: 'error', message: 'Pick a .gntheme or .zip file.' }); + return; + } + setStatus({ kind: 'busy', message: `Uploading ${file.name}…` }); + try { + const result = await installTheme(file); + setStatus({ + kind: 'success', + message: `Installed “${result.title}”. Redirecting…`, + }); + // Push to the umbrella themes page so the operator sees the + // new card in the gallery. router.refresh() forces the + // server component to re-fetch. + setTimeout(() => { + router.push('/appearance/themes'); + router.refresh(); + }, 600); + } catch (err) { + setStatus({ + kind: 'error', + message: err instanceof Error ? err.message : 'Install failed.', + }); + } + }, + [router], + ); + + const onChange = (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) void onUpload(file); + e.target.value = ''; + }; + + const onDrop = (e: DragEvent) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files?.[0]; + if (file) void onUpload(file); + }; + + return ( +
+
{ + e.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={onDrop} + className={cn( + 'relative flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed bg-paper-2 px-6 py-14 text-center transition-colors', + dragOver + ? 'border-emerald-bright bg-emerald-soft/30' + : 'border-border hover:border-border-strong', + )} + > + +
+ Drop a .gntheme here +
+

+ The archive must contain a theme.json at the root or + inside a single top-level directory. +

+ +
+ {status.kind !== 'idle' && ( +
+ {status.kind === 'busy' && } + {status.kind === 'success' && } + {status.message} +
+ )} +
+ ); +} diff --git a/apps/admin/src/app/(authenticated)/appearance/install/page.tsx b/apps/admin/src/app/(authenticated)/appearance/install/page.tsx new file mode 100644 index 00000000..02fd2ea4 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/appearance/install/page.tsx @@ -0,0 +1,46 @@ +/** + * Theme installer page — `/appearance/install` (issue #13). + * + * Standalone landing for the `.gntheme` upload form. The umbrella + * `/appearance/themes` page (issue #65) embeds the same drop zone + * for operators who want install + switch on one screen; this route + * exists for the deep-link case ("send me the install URL") and as + * the destination of the "Install a theme" CTA in the customizer + * sidebar. + */ + +import type { ReactElement } from 'react'; +import Link from 'next/link'; +import { ArrowLeft } from 'lucide-react'; +import { Headline } from '@/components/ui/headline'; +import { InstallerClient } from './InstallerClient'; + +export const dynamic = 'force-dynamic'; + +export default function InstallPage(): ReactElement { + return ( +
+
+ + Back to themes + + + Install a theme. + +

+ Drop a .gntheme archive below. The installer + validates the manifest, refuses path-traversal entries, and writes atomically — a + half-installed theme can't happen. +

+
+ +
+ ); +} diff --git a/apps/admin/src/app/(authenticated)/appearance/themes/ThemesGalleryClient.tsx b/apps/admin/src/app/(authenticated)/appearance/themes/ThemesGalleryClient.tsx new file mode 100644 index 00000000..3b40d2c1 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/appearance/themes/ThemesGalleryClient.tsx @@ -0,0 +1,407 @@ +'use client'; + +/** + * Themes umbrella page — installed-theme switcher + drag/drop + * installer in one surface (issues #13, #18, #65). The page is a + * server component that hydrates the initial list, then this client + * component owns the optimistic "Activate" + the upload form. + * + * The list portion mirrors the existing ThemeBrowser card grid (same + * emerald-bright active-badge + paper-2 cards) but pulls real data + * from /api/v1/admin/themes instead of the hard-coded curated list. + * Themes that lack a screenshot.png fall back to a CSS preview + * scene matching the gn-hello starter — that keeps the gallery + * readable for themes uploaded straight from an editor that doesn't + * ship a hero image yet. + * + * The installer is a drop zone on the same page rather than a + * separate route: per the issue ("umbrella page combining installer + * + switcher"), an operator should land on /appearance/themes and + * see both surfaces. Uploading reruns the list fetch on success so + * the new card appears immediately. + */ + +import { useCallback, useEffect, useState, type ChangeEvent, type DragEvent, type ReactElement } from 'react'; +import { ArrowRight, Check, Loader2, Sparkles, Upload } from 'lucide-react'; +import { Headline } from '@/components/ui/headline'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { activateTheme, fetchThemesListClient, installTheme } from './api-client'; +import type { ThemeInfo } from './types'; + +interface Props { + initialThemes: ThemeInfo[]; + initialActiveSlug: string; +} + +type Status = + | { kind: 'idle' } + | { kind: 'busy'; message: string } + | { kind: 'success'; message: string } + | { kind: 'error'; message: string }; + +export function ThemesGalleryClient({ initialThemes, initialActiveSlug }: Props): ReactElement { + const [themes, setThemes] = useState(initialThemes); + const [activeSlug, setActiveSlug] = useState(initialActiveSlug); + const [busySlug, setBusySlug] = useState(null); + const [installStatus, setInstallStatus] = useState({ kind: 'idle' }); + const [activateStatus, setActivateStatus] = useState({ kind: 'idle' }); + const [dragOver, setDragOver] = useState(false); + + const refresh = useCallback(async () => { + const next = await fetchThemesListClient(); + if (next) { + setThemes(next.themes); + setActiveSlug(next.active_slug); + } + }, []); + + const onActivate = useCallback( + async (slug: string) => { + if (slug === activeSlug) return; + setBusySlug(slug); + setActivateStatus({ kind: 'busy', message: `Activating ${slug}…` }); + try { + await activateTheme(slug); + setActiveSlug(slug); + setActivateStatus({ kind: 'success', message: `${slug} is now active.` }); + } catch (err) { + setActivateStatus({ + kind: 'error', + message: err instanceof Error ? err.message : 'Activation failed.', + }); + } finally { + setBusySlug(null); + } + }, + [activeSlug], + ); + + const onUpload = useCallback( + async (file: File) => { + if (!/\.(gntheme|zip)$/i.test(file.name)) { + setInstallStatus({ + kind: 'error', + message: 'Pick a .gntheme or .zip file.', + }); + return; + } + setInstallStatus({ kind: 'busy', message: `Uploading ${file.name}…` }); + try { + const result = await installTheme(file); + setInstallStatus({ + kind: 'success', + message: `Installed “${result.title}” (${result.slug}).`, + }); + await refresh(); + } catch (err) { + setInstallStatus({ + kind: 'error', + message: err instanceof Error ? err.message : 'Install failed.', + }); + } + }, + [refresh], + ); + + // Clear transient status messages after a short delay so the user + // doesn't see stale toasts the next time they interact. + useEffect(() => { + if (activateStatus.kind === 'success' || activateStatus.kind === 'error') { + const t = setTimeout(() => setActivateStatus({ kind: 'idle' }), 4000); + return () => clearTimeout(t); + } + return undefined; + }, [activateStatus]); + + return ( +
+
+ + Appearance · {themes.length} themes installed + + + Themes & installer. + +

+ Switch the active theme, or drop a .gntheme archive + to install a new one. The installer validates the manifest before writing a byte to disk. +

+
+ + {/* Installer drop zone — top of the page so it's never hidden behind the gallery. */} + { + e.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files?.[0]; + if (file) void onUpload(file); + }} + onFileSelected={(file) => void onUpload(file)} + /> + + {/* Status banner for the activate flow. Lives between the + installer and the gallery so it's visible regardless of + scroll position when the user clicks Activate. */} + + + {/* Gallery proper. Same card visual the curated mock used, + minus the curated frame variants — we render a CSS preview + frame for themes without screenshot.png. */} + {themes.length === 0 ? ( + + ) : ( +
+ {themes.map((theme) => { + const isActive = theme.slug === activeSlug; + return ( + void onActivate(theme.slug)} + /> + ); + })} +
+ )} +
+ ); +} + +interface DropZoneProps { + status: Status; + dragOver: boolean; + onDragOver: (e: DragEvent) => void; + onDragLeave: () => void; + onDrop: (e: DragEvent) => void; + onFileSelected: (file: File) => void; +} + +function InstallDropZone(props: DropZoneProps): ReactElement { + const onChange = (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) props.onFileSelected(file); + // Reset so the same file can be picked twice in a row. + e.target.value = ''; + }; + return ( +
+
+ +
+ Drop a .gntheme here +
+

+ Or pick a file. The installer validates the theme.json + {' '}manifest, refuses path-traversal entries, and writes atomically via a rename. +

+ +
+ +
+ ); +} + +interface ThemeCardProps { + theme: ThemeInfo; + isActive: boolean; + isBusy: boolean; + onActivate: () => void; +} + +function ThemeCard({ theme, isActive, isBusy, onActivate }: ThemeCardProps): ReactElement { + return ( +
+ {isActive && ( + + + Active + + )} + + + +
+
+

{theme.title}

+ + v{theme.version} + +
+

+ {theme.description || ( + <> + Slug{' '} + + {theme.slug} + + + )} +

+ +
+ +
+
+
+ ); +} + +function ThemePreview({ slug, hasScreenshot }: { slug: string; hasScreenshot: boolean }): ReactElement { + const wrapperClass = + 'relative aspect-[4/3] overflow-hidden border-b border-border bg-paper'; + // If the theme ships a screenshot.png we surface it via the dedicated + // proxy endpoint a future server handler can serve; until then, + // every theme falls back to the CSS scene. + if (hasScreenshot) { + return ( +
+
+ {slug} +
+
+ ); + } + return ( +
+
+
+ + + +
+
+ + {slug} + + + A living theme. + +
+
+
+
+
+
+ ); +} + +function EmptyState(): ReactElement { + return ( +
+ + No themes yet. + +

+ Drop a .gntheme archive into the zone above to get + started. The seeder will normally provision gn-hello + on first boot. +

+
+ ); +} + +function StatusBanner({ status }: { status: Status }): ReactElement | null { + if (status.kind === 'idle') return null; + return ( +
+ {status.kind === 'busy' && } + {status.kind === 'success' && } + {status.message} +
+ ); +} diff --git a/apps/admin/src/app/(authenticated)/appearance/themes/api-client.ts b/apps/admin/src/app/(authenticated)/appearance/themes/api-client.ts new file mode 100644 index 00000000..910c7d4f --- /dev/null +++ b/apps/admin/src/app/(authenticated)/appearance/themes/api-client.ts @@ -0,0 +1,63 @@ +/** + * Client-side counterparts to api.ts. Same endpoints, but the + * fetches go through the browser (credentials: 'include') instead + * of forwarding cookies server-side. Split into a separate file so + * the server component never accidentally pulls in browser-only + * code at SSR time. + */ + +import { apiBaseUrl } from '@/lib/api-client'; +import type { InstallResponse, ThemesListResponse } from './types'; + +export async function fetchThemesListClient(): Promise { + try { + const res = await fetch(`${apiBaseUrl}/api/v1/admin/themes`, { + method: 'GET', + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + if (!res.ok) return null; + const body = (await res.json()) as ThemesListResponse; + return { + themes: Array.isArray(body.themes) ? body.themes : [], + active_slug: body.active_slug ?? '', + }; + } catch { + return null; + } +} + +export async function installTheme(file: File): Promise { + const formData = new FormData(); + formData.append('file', file, file.name); + const res = await fetch(`${apiBaseUrl}/api/v1/admin/themes/install`, { + method: 'POST', + credentials: 'include', + body: formData, + }); + if (!res.ok) { + throw new Error(await extractError(res, 'Install failed.')); + } + return (await res.json()) as InstallResponse; +} + +export async function activateTheme(slug: string): Promise { + const res = await fetch(`${apiBaseUrl}/api/v1/admin/themes/activate`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug }), + }); + if (!res.ok) { + throw new Error(await extractError(res, 'Activate failed.')); + } +} + +async function extractError(res: Response, fallback: string): Promise { + try { + const body = (await res.json()) as { error?: { message?: string } }; + return body?.error?.message ?? fallback; + } catch { + return fallback; + } +} diff --git a/apps/admin/src/app/(authenticated)/appearance/themes/api.ts b/apps/admin/src/app/(authenticated)/appearance/themes/api.ts new file mode 100644 index 00000000..1b2ac5a4 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/appearance/themes/api.ts @@ -0,0 +1,94 @@ +/** + * Themes admin API client — small fetch wrappers over the + * /api/v1/admin/themes surface. Server-side calls forward the + * inbound cookie header so the API auth middleware sees the + * session; client-side calls rely on `credentials: 'include'` to + * carry the cookie cross-origin (admin runs on :3001, api on :8080). + */ + +import { apiBaseUrl } from '@/lib/api-client'; +import type { InstallResponse, ThemesListResponse } from './types'; + +const LIST_URL = '/api/v1/admin/themes'; +const INSTALL_URL = '/api/v1/admin/themes/install'; +const ACTIVATE_URL = '/api/v1/admin/themes/activate'; + +/** + * Server-side list fetch. Returns `null` on any non-2xx so the + * caller can render an empty-state without short-circuiting the + * whole page render. + */ +export async function fetchThemesList(cookieHeader: string): Promise { + try { + const res = await fetch(`${apiBaseUrl}${LIST_URL}`, { + headers: { + Accept: 'application/json', + ...(cookieHeader ? { Cookie: cookieHeader } : {}), + }, + cache: 'no-store', + }); + if (!res.ok) { + return null; + } + const body = (await res.json()) as ThemesListResponse; + return { + themes: Array.isArray(body.themes) ? body.themes : [], + active_slug: body.active_slug ?? '', + }; + } catch { + return null; + } +} + +/** + * Client-side install request. The caller hands a File (the .gntheme + * ZIP); we wrap it in a FormData so the API's multipart parser + * accepts it. Returns the resolved {slug, title} on success. + */ +export async function installTheme(file: File): Promise { + const formData = new FormData(); + formData.append('file', file, file.name); + const res = await fetch(`${apiBaseUrl}${INSTALL_URL}`, { + method: 'POST', + credentials: 'include', + body: formData, + }); + if (!res.ok) { + const message = await extractError(res, 'Install failed.'); + throw new Error(message); + } + return (await res.json()) as InstallResponse; +} + +/** + * Client-side activate request. The API validates the slug exists + * on disk before flipping core.active_theme; an unknown slug + * surfaces as a 404 which we re-throw with the server's error + * message preserved. + */ +export async function activateTheme(slug: string): Promise { + const res = await fetch(`${apiBaseUrl}${ACTIVATE_URL}`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug }), + }); + if (!res.ok) { + const message = await extractError(res, 'Activate failed.'); + throw new Error(message); + } +} + +/** + * Pull a human-readable message out of a non-2xx response. The API + * uses {error: {code, message}} envelopes; we fall back to the + * provided default when the body doesn't parse. + */ +async function extractError(res: Response, fallback: string): Promise { + try { + const body = (await res.json()) as { error?: { message?: string } }; + return body?.error?.message ?? fallback; + } catch { + return fallback; + } +} diff --git a/apps/admin/src/app/(authenticated)/appearance/themes/page.tsx b/apps/admin/src/app/(authenticated)/appearance/themes/page.tsx new file mode 100644 index 00000000..be058ae8 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/appearance/themes/page.tsx @@ -0,0 +1,43 @@ +/** + * Themes umbrella page — `/appearance/themes` (issues #13, #18, #65). + * + * This route combines the theme switcher (issue #18) and the .gntheme + * installer (issue #13) into a single surface so operators only have + * one place to install + activate themes. The server component does + * the initial fetch against `GET /api/v1/admin/themes`, forwarding + * the inbound session cookie so the API auth middleware accepts the + * call; the client component owns the optimistic activate flow and + * the drag-drop upload form. + * + * The page deliberately renders even when the API list call errors + * (empty themes array + empty active slug). The drop zone is what + * the operator wants in that state anyway — install the first theme, + * the next render hydrates the gallery. + */ + +import type { ReactElement } from 'react'; +import { cookies } from 'next/headers'; +import { fetchThemesList } from './api'; +import { ThemesGalleryClient } from './ThemesGalleryClient'; + +export const dynamic = 'force-dynamic'; + +export default async function ThemesPage(): Promise { + let cookieHeader = ''; + try { + const store = await cookies(); + cookieHeader = store + .getAll() + .map((c) => `${c.name}=${c.value}`) + .join('; '); + } catch { + cookieHeader = ''; + } + const data = await fetchThemesList(cookieHeader); + return ( + + ); +} diff --git a/apps/admin/src/app/(authenticated)/appearance/themes/types.ts b/apps/admin/src/app/(authenticated)/appearance/themes/types.ts new file mode 100644 index 00000000..035336c2 --- /dev/null +++ b/apps/admin/src/app/(authenticated)/appearance/themes/types.ts @@ -0,0 +1,24 @@ +/** + * Themes admin API types — mirror the shape returned by + * apps/api/internal/admin/themes. + */ + +/** One row in the GET /api/v1/admin/themes response. */ +export interface ThemeInfo { + slug: string; + title: string; + description?: string; + version: number; + has_screenshot: boolean; +} + +export interface ThemesListResponse { + themes: ThemeInfo[]; + active_slug: string; +} + +export interface InstallResponse { + slug: string; + title: string; + version: number; +} diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index e9ee223a..bfead783 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -32,6 +32,7 @@ import ( admincomments "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/comments" adminmedia "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/media" + adminthemes "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/themes" restimg "github.com/Singleton-Solution/GoNext/apps/api/internal/rest/img" "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/customizer" adminredirects "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/redirects" @@ -590,6 +591,27 @@ func buildRouter(cfg *config.Config, pool *pgxpool.Pool, rdb *goredis.Client, se ) } + // Theme installer + switcher (issues #13, #18, #65). The list + // endpoint walks themeDir for every parseable theme.json; the + // install endpoint accepts a .gntheme ZIP, validates the + // manifest, and extracts to themeDir//; the activate + // endpoint writes core.active_theme. Three sibling capabilities + // gate the routes — install_themes, manage_themes, switch_themes + // — so a deploy that only wants the switch surface can withhold + // install_themes without losing the list endpoint. + if err := adminthemes.Mount(mux, "/api/v1/admin/themes", adminthemes.Deps{ + ThemeDir: themeDir, + Active: &adminthemes.PgxActiveStore{Pool: pool}, + Policy: policy.NewBasicPolicy(policy.DefaultRoleCapabilities()), + Logger: logger, + }); err != nil { + logger.Warn("admin/themes: failed to mount routes", slog.Any("err", err)) + } else { + logger.Info("admin/themes: routes mounted", + slog.String("base", "/api/v1/admin/themes"), + ) + } + // First-run install surface — the in-browser alternative to // `gonext init`. The two endpoints (/api/v1/setup/status and // /api/v1/setup/install) are mounted unconditionally; the lock diff --git a/apps/api/internal/admin/themes/doc.go b/apps/api/internal/admin/themes/doc.go new file mode 100644 index 00000000..e02592a7 --- /dev/null +++ b/apps/api/internal/admin/themes/doc.go @@ -0,0 +1,27 @@ +// Package themes implements the admin REST surface for the theme +// installer + switcher (issues #13, #18, #65). +// +// What's here: +// +// - GET /api/v1/admin/themes +// List installed themes (directories under ThemeDir whose +// theme.json parses + validates). The response carries the slug +// of the active theme so the switcher UI can render the "active" +// badge without an extra round trip. +// +// - POST /api/v1/admin/themes/install +// Accept a multipart upload of a .gntheme ZIP, validate its +// theme.json manifest, and extract into ThemeDir//. The +// installer is fail-closed: if the manifest is missing or fails +// validation, the ZIP is rejected without writing a byte to disk. +// +// - POST /api/v1/admin/themes/activate +// Switch the core.active_theme option. The handler validates the +// target slug exists on disk before writing the option so the +// next page render can't 500 on a missing manifest. +// +// House rule: the package owns the filesystem-mutation logic for +// theme directories (extract, validate, write the active-slug +// option). The customizer package keeps its narrow read+overrides +// surface untouched. +package themes diff --git a/apps/api/internal/admin/themes/handler.go b/apps/api/internal/admin/themes/handler.go new file mode 100644 index 00000000..9bd78f83 --- /dev/null +++ b/apps/api/internal/admin/themes/handler.go @@ -0,0 +1,281 @@ +package themes + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + + "github.com/Singleton-Solution/GoNext/apps/api/internal/rest/router" + "github.com/Singleton-Solution/GoNext/packages/go/policy" +) + +// maxUploadBytes caps the multipart body. The actual ZIP bytes are +// further limited by MaxThemeZipSize inside Install; this is the +// outer guard so an attacker can't burn server memory uploading 1 GiB +// of multipart noise before we ever look at the inner archive. +const maxUploadBytes = MaxThemeZipSize + 64*1024 // +64KiB for multipart framing + +// Deps is the dependency bag for Mount. +type Deps struct { + // ThemeDir is the absolute directory where installed themes + // live. Required. The installer extracts into a subdirectory + // under this path; the listing handler reads from it. + ThemeDir string + + // Active is the active-theme option store. Required for the + // activate handler; the list endpoint also reads through it to + // stamp the response with the current slug. + Active ActiveStore + + // Policy resolves the install / switch capability checks. + // Required. + Policy policy.Policy + + // Logger receives structured log lines. nil falls back to + // slog.Default — useful for tests but production wiring should + // always pass a service logger. + Logger *slog.Logger +} + +func (d Deps) validate() error { + if d.ThemeDir == "" { + return errors.New("admin/themes: ThemeDir is required") + } + if d.Active == nil { + return errors.New("admin/themes: Active is required") + } + if d.Policy == nil { + return errors.New("admin/themes: Policy is required") + } + return nil +} + +type handlers struct { + themeDir string + active ActiveStore + policy policy.Policy + logger *slog.Logger +} + +// Mount wires the themes admin routes onto mux under base (typically +// "/api/v1/admin/themes"). Returns an error rather than panicking if +// Deps is malformed so the boot path surfaces it cleanly. +// +// Route tree: +// +// GET {base} — list installed themes + active slug +// POST {base}/install — accept .gntheme ZIP upload +// POST {base}/activate — switch active theme +// +// Capabilities (per packages/go/policy/capabilities.go): +// +// GET {base} → manage_themes +// POST {base}/install → install_themes +// POST {base}/activate → switch_themes +func Mount(mux *http.ServeMux, base string, deps Deps) error { + if err := deps.validate(); err != nil { + return err + } + if deps.Logger == nil { + deps.Logger = slog.Default() + } + h := &handlers{ + themeDir: deps.ThemeDir, + active: deps.Active, + policy: deps.Policy, + logger: deps.Logger, + } + + base = strings.TrimRight(base, "/") + mux.Handle("GET "+base, h.gate(policy.CapManageThemes, h.list)) + mux.Handle("POST "+base+"/install", h.gate(policy.CapInstallThemes, h.install)) + mux.Handle("POST "+base+"/activate", h.gate(policy.CapSwitchThemes, h.activate)) + return nil +} + +// gate wraps a handler with the auth + capability check. Returns 401 +// when no principal is on the context, 403 when the principal lacks +// the capability. +func (h *handlers) gate(cap policy.Capability, next func(http.ResponseWriter, *http.Request, policy.Principal)) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pr, ok := policy.FromContext(r.Context()) + if !ok { + router.WriteError(w, http.StatusUnauthorized, "unauthenticated", "authentication required") + return + } + if d := h.policy.Can(pr, cap, nil); !d.Allowed { + router.WriteError(w, http.StatusForbidden, "forbidden", d.Reason) + return + } + next(w, r, pr) + }) +} + +// listResponse is the GET /themes response. Themes carries every +// directory whose theme.json parsed cleanly; ActiveSlug is the slug +// the switcher should render with the "Active" badge. +type listResponse struct { + Themes []ThemeInfo `json:"themes"` + ActiveSlug string `json:"active_slug"` +} + +func (h *handlers) list(w http.ResponseWriter, r *http.Request, _ policy.Principal) { + themes, err := ListInstalled(r.Context(), h.themeDir) + if err != nil { + h.logger.ErrorContext(r.Context(), "admin/themes: list failed", slog.Any("err", err)) + router.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to list themes") + return + } + active, err := h.active.Get(r.Context()) + if err != nil && !errors.Is(err, ErrNoActiveTheme) { + h.logger.ErrorContext(r.Context(), "admin/themes: active read failed", slog.Any("err", err)) + // Soft-fail: surface the list even when the active read errored. + // The switcher will render without a badge rather than 500ing. + active = "" + } + router.WriteJSON(w, http.StatusOK, listResponse{Themes: themes, ActiveSlug: active}) +} + +// installResponse is the POST /install response on success. Slug is +// the directory the theme landed under; Title is the manifest title +// so the UI can echo it back in a confirmation toast. +type installResponse struct { + Slug string `json:"slug"` + Title string `json:"title"` + Version int `json:"version"` +} + +func (h *handlers) install(w http.ResponseWriter, r *http.Request, pr policy.Principal) { + // Limit the request body before we touch it. http.MaxBytesReader + // wraps the body so any subsequent ReadAll bails with a 413-style + // error rather than letting the multipart parser eat the + // over-large body. + r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes) + + // The installer accepts both raw application/zip POST bodies and + // multipart/form-data with a single "file" field. The former is + // nicer for curl + scripts; the latter is what the browser sends + // by default. + contentType := r.Header.Get("Content-Type") + var data []byte + var readErr error + if strings.HasPrefix(contentType, "multipart/form-data") { + data, readErr = readMultipart(r) + } else { + data, readErr = io.ReadAll(r.Body) + } + if readErr != nil { + router.WriteError(w, http.StatusBadRequest, "invalid_upload", readErr.Error()) + return + } + if len(data) == 0 { + router.WriteError(w, http.StatusBadRequest, "empty_upload", "request body is empty") + return + } + + result, err := Install(h.themeDir, data) + if err != nil { + switch { + case errors.Is(err, ErrZipMissingManifest), + errors.Is(err, ErrInvalidManifest), + errors.Is(err, ErrInvalidSlug), + errors.Is(err, ErrUnsafePath), + errors.Is(err, ErrEntryTooLarge), + errors.Is(err, ErrTooManyFiles): + router.WriteError(w, http.StatusBadRequest, "invalid_theme", err.Error()) + case errors.Is(err, ErrThemeExists): + router.WriteError(w, http.StatusConflict, "theme_exists", err.Error()) + default: + h.logger.ErrorContext(r.Context(), "admin/themes: install failed", + slog.Any("err", err), + slog.String("by", pr.UserID), + ) + router.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to install theme") + } + return + } + h.logger.InfoContext(r.Context(), "admin/themes: theme installed", + slog.String("slug", result.Slug), + slog.String("by", pr.UserID), + ) + router.WriteJSON(w, http.StatusCreated, installResponse{ + Slug: result.Slug, + Title: result.Manifest.Title, + Version: result.Manifest.Version, + }) +} + +// activateRequest is the POST /activate body. +type activateRequest struct { + Slug string `json:"slug"` +} + +func (h *handlers) activate(w http.ResponseWriter, r *http.Request, pr policy.Principal) { + var req activateRequest + if err := json.NewDecoder(io.LimitReader(r.Body, 4*1024)).Decode(&req); err != nil { + router.WriteError(w, http.StatusBadRequest, "invalid_json", "request body is not valid JSON") + return + } + req.Slug = strings.TrimSpace(req.Slug) + if req.Slug == "" { + router.WriteError(w, http.StatusBadRequest, "missing_slug", "slug is required") + return + } + if !ThemeInstalled(h.themeDir, req.Slug) { + router.WriteError(w, http.StatusNotFound, "theme_not_installed", + fmt.Sprintf("theme %q is not installed", req.Slug)) + return + } + if err := h.active.Set(r.Context(), req.Slug); err != nil { + h.logger.ErrorContext(r.Context(), "admin/themes: activate write failed", + slog.String("slug", req.Slug), + slog.Any("err", err), + ) + router.WriteError(w, http.StatusInternalServerError, "internal_error", "failed to switch theme") + return + } + h.logger.InfoContext(r.Context(), "admin/themes: theme activated", + slog.String("slug", req.Slug), + slog.String("by", pr.UserID), + ) + router.WriteJSON(w, http.StatusOK, map[string]string{"active_slug": req.Slug}) +} + +// readMultipart pulls the first non-empty file part out of a +// multipart/form-data request. We do not enforce a specific field +// name — operators sometimes name the field "theme", "file", +// "upload", or "archive" depending on the form library they reach +// for; first non-empty file wins. +func readMultipart(r *http.Request) ([]byte, error) { + if err := r.ParseMultipartForm(MaxThemeZipSize); err != nil { + return nil, fmt.Errorf("multipart parse: %w", err) + } + if r.MultipartForm == nil { + return nil, errors.New("missing multipart form") + } + for _, files := range r.MultipartForm.File { + for _, fh := range files { + if fh.Size == 0 { + continue + } + f, err := fh.Open() + if err != nil { + return nil, fmt.Errorf("open part: %w", err) + } + defer f.Close() + body, err := io.ReadAll(io.LimitReader(f, MaxThemeZipSize+1)) + if err != nil { + return nil, fmt.Errorf("read part: %w", err) + } + if int64(len(body)) > MaxThemeZipSize { + return nil, fmt.Errorf("upload exceeds %d bytes", MaxThemeZipSize) + } + return body, nil + } + } + return nil, errors.New("no file uploaded") +} diff --git a/apps/api/internal/admin/themes/installer.go b/apps/api/internal/admin/themes/installer.go new file mode 100644 index 00000000..140a6a2c --- /dev/null +++ b/apps/api/internal/admin/themes/installer.go @@ -0,0 +1,352 @@ +package themes + +import ( + "archive/zip" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "regexp" + "strings" + + "github.com/Singleton-Solution/GoNext/packages/go/theme" +) + +// MaxThemeZipSize caps the upload at 10 MiB. The largest realistic +// theme (templates + parts + a hand-tuned screenshot.png) lands well +// under 1 MiB; the cap is "stop an operator from accidentally +// uploading a node_modules tarball" rather than a tight bound. +const MaxThemeZipSize = 10 * 1024 * 1024 + +// MaxThemeFileSize caps each entry inside the ZIP at 2 MiB. The same +// "stop a runaway upload" rationale as MaxThemeZipSize, applied per +// entry so a single file can't blow past the bound by hiding inside +// the archive. +const MaxThemeFileSize = 2 * 1024 * 1024 + +// MaxThemeFiles caps the number of entries the ZIP may carry. Themes +// in the wild ship a few dozen files; 500 is comfortable headroom +// without becoming a vector for zip-bomb-style inode exhaustion. +const MaxThemeFiles = 500 + +// slugPattern is the regex the installer enforces on the theme's +// chosen directory name. It is the same kebab-case alphabet +// theme.json's slug validation uses — keeping the two in lock-step +// means a slug that survives the manifest validator also survives +// the directory-write step. +var slugPattern = regexp.MustCompile(`^[a-z][a-z0-9-]{1,62}[a-z0-9]$`) + +// Errors returned by Install. Callers errors.Is against these +// sentinels rather than string-matching the wrapped error text. +var ( + // ErrZipMissingManifest fires when the upload contains no + // theme.json at the root of the archive (or at the root of its + // single top-level directory). Translates to HTTP 400 at the + // handler. + ErrZipMissingManifest = errors.New("themes: archive has no theme.json") + + // ErrInvalidManifest fires when theme.json parses but fails + // theme.Validate. The full validation list is attached as a + // wrapped error on the InstallResult. + ErrInvalidManifest = errors.New("themes: manifest validation failed") + + // ErrInvalidSlug fires when the resolved theme slug (directory + // name inside the archive, falling back to the manifest title) + // doesn't match slugPattern. + ErrInvalidSlug = errors.New("themes: invalid slug") + + // ErrThemeExists fires when a theme with the resolved slug + // already lives on disk under themeDir. The installer is + // intentionally non-clobbering so an operator can't overwrite a + // hand-edited theme by re-uploading. + ErrThemeExists = errors.New("themes: theme already installed") + + // ErrUnsafePath fires when an entry in the archive resolves + // outside the destination directory (path traversal attempt) or + // uses an absolute path. The installer aborts before writing + // anything when this triggers. + ErrUnsafePath = errors.New("themes: archive contains unsafe path") + + // ErrEntryTooLarge fires when a single entry inside the archive + // exceeds MaxThemeFileSize on decompression. + ErrEntryTooLarge = errors.New("themes: archive entry exceeds size limit") + + // ErrTooManyFiles fires when the archive carries more than + // MaxThemeFiles entries. + ErrTooManyFiles = errors.New("themes: archive has too many entries") +) + +// InstallResult is the success payload returned from Install. It +// carries the slug under which the theme landed plus the parsed +// manifest so the handler can render an immediate confirmation. +type InstallResult struct { + Slug string + Manifest *theme.ThemeJSON +} + +// Install extracts a .gntheme ZIP archive into themeDir, validating +// the embedded theme.json before writing anything to disk. The +// resolved slug is the basename of the single top-level directory +// inside the archive when one exists, falling back to the manifest's +// title slug. +// +// The function is fail-closed: any validation failure aborts the +// installation before files land on disk. Successful installs are +// committed via a rename from a temp directory into the final +// destination, so a concurrent reader of themeDir either sees the +// whole theme or none of it (no partial-write window). +// +// data is the raw ZIP bytes (the handler reads them off the +// multipart upload). themeDir is the absolute path of the themes +// directory. +func Install(themeDir string, data []byte) (*InstallResult, error) { + if themeDir == "" { + return nil, errors.New("themes: empty themeDir") + } + if int64(len(data)) > MaxThemeZipSize { + return nil, fmt.Errorf("themes: upload exceeds %d bytes", MaxThemeZipSize) + } + r, err := zip.NewReader(bytesReaderAt(data), int64(len(data))) + if err != nil { + return nil, fmt.Errorf("themes: open zip: %w", err) + } + if len(r.File) > MaxThemeFiles { + return nil, ErrTooManyFiles + } + + // Find theme.json. We accept either of two layouts: + // - flat: theme.json at the root of the archive + // - nested: /theme.json with every other file under that + // prefix + manifestEntry, prefix, findErr := findManifest(r.File) + if findErr != nil { + return nil, findErr + } + manifestBytes, err := readZipEntry(manifestEntry) + if err != nil { + return nil, fmt.Errorf("themes: read manifest: %w", err) + } + manifest, err := theme.Parse(manifestBytes) + if err != nil { + return nil, fmt.Errorf("themes: parse manifest: %w", err) + } + if errs := manifest.Validate(); len(errs) > 0 { + msgs := make([]string, 0, len(errs)) + for _, e := range errs { + msgs = append(msgs, e.Error()) + } + return nil, fmt.Errorf("%w: %s", ErrInvalidManifest, strings.Join(msgs, "; ")) + } + + slug := resolveSlug(prefix, manifest) + if !slugPattern.MatchString(slug) { + return nil, fmt.Errorf("%w: %q", ErrInvalidSlug, slug) + } + + dest := filepath.Join(themeDir, slug) + if _, statErr := os.Stat(dest); statErr == nil { + return nil, fmt.Errorf("%w: %q", ErrThemeExists, slug) + } + + // Extract to a sibling temp directory, then rename into place. + // Filepath.TempDir in the parent gives us atomic rename + // semantics on the same filesystem. + if err := os.MkdirAll(themeDir, 0o755); err != nil { + return nil, fmt.Errorf("themes: ensure dir: %w", err) + } + staging, err := os.MkdirTemp(themeDir, ".install-") + if err != nil { + return nil, fmt.Errorf("themes: temp dir: %w", err) + } + // On any failure past this point, sweep the staging dir. + committed := false + defer func() { + if !committed { + _ = os.RemoveAll(staging) + } + }() + + for _, f := range r.File { + if err := writeZipEntry(f, prefix, staging); err != nil { + return nil, err + } + } + + if err := os.Rename(staging, dest); err != nil { + return nil, fmt.Errorf("themes: rename to %s: %w", dest, err) + } + committed = true + return &InstallResult{Slug: slug, Manifest: manifest}, nil +} + +// findManifest scans the archive for theme.json. We tolerate both +// the flat layout and the single-top-level-dir layout; anything else +// (theme.json buried two levels deep, or no theme.json at all) is a +// packaging error. +// +// Returns the zip entry, the directory prefix to strip from every +// other entry, and an error. +func findManifest(files []*zip.File) (*zip.File, string, error) { + var flat *zip.File + var nested *zip.File + var nestedPrefix string + for _, f := range files { + name := path.Clean(f.Name) + if name == "theme.json" { + flat = f + continue + } + // Match "/theme.json". + if strings.HasSuffix(name, "/theme.json") { + parts := strings.SplitN(name, "/", 2) + if len(parts) == 2 && parts[1] == "theme.json" && !strings.Contains(parts[0], "/") { + // First match wins; a malformed archive with two + // nested manifests is rejected by falling through + // the loop without flagging an ambiguity error — + // the second one (alphabetical zip-order) is + // ignored. + if nested == nil { + nested = f + nestedPrefix = parts[0] + "/" + } + } + } + } + if flat != nil { + return flat, "", nil + } + if nested != nil { + return nested, nestedPrefix, nil + } + return nil, "", ErrZipMissingManifest +} + +// resolveSlug picks a slug for the new theme directory. The +// archive's top-level directory is the primary signal (operators +// expect the directory they zipped to be the directory they get +// back); we fall back to the manifest's title — slugified — when the +// archive was flat. +func resolveSlug(prefix string, manifest *theme.ThemeJSON) string { + if prefix != "" { + return strings.TrimSuffix(prefix, "/") + } + if manifest.Title != "" { + return slugifyTitle(manifest.Title) + } + return "" +} + +// slugifyTitle lowercases + replaces runs of non-alphanumeric with a +// single hyphen. Mirrors the kebab-case validator in the theme +// package so the produced slug round-trips through slugPattern. +func slugifyTitle(title string) string { + var b strings.Builder + b.Grow(len(title)) + prevDash := false + for _, r := range strings.ToLower(strings.TrimSpace(title)) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + prevDash = false + default: + if !prevDash && b.Len() > 0 { + b.WriteByte('-') + prevDash = true + } + } + } + return strings.Trim(b.String(), "-") +} + +// readZipEntry reads up to MaxThemeFileSize bytes from a zip entry. +// Anything past that limit signals a potential zip bomb (or an +// operator who packaged a theme with a multi-megabyte hero image); +// we error rather than truncating silently. +func readZipEntry(f *zip.File) ([]byte, error) { + if int64(f.UncompressedSize64) > MaxThemeFileSize { + return nil, fmt.Errorf("%w: %s (%d bytes)", ErrEntryTooLarge, f.Name, f.UncompressedSize64) + } + rc, err := f.Open() + if err != nil { + return nil, err + } + defer rc.Close() + limited := io.LimitReader(rc, MaxThemeFileSize+1) + body, err := io.ReadAll(limited) + if err != nil { + return nil, err + } + if int64(len(body)) > MaxThemeFileSize { + return nil, fmt.Errorf("%w: %s", ErrEntryTooLarge, f.Name) + } + return body, nil +} + +// writeZipEntry extracts a single archive entry into staging. We +// strip the optional top-level prefix, enforce a path-traversal +// guard, refuse symlinks (mode&os.ModeSymlink), and create parent +// directories on demand. +func writeZipEntry(f *zip.File, prefix, staging string) error { + name := f.Name + if prefix != "" { + if !strings.HasPrefix(name, prefix) { + // An entry outside the top-level directory in a + // nested-layout zip — refuse so we don't sprinkle + // random files across the staging root. + return fmt.Errorf("%w: %s outside prefix %q", ErrUnsafePath, name, prefix) + } + name = strings.TrimPrefix(name, prefix) + } + if name == "" { + // The entry IS the top-level directory; nothing to write. + return nil + } + // Refuse absolute or traversal paths defensively. filepath.Clean + // + abs/leading-".." check is the standard "zip slip" defense. + clean := path.Clean(name) + if strings.HasPrefix(clean, "/") || strings.HasPrefix(clean, "..") || strings.Contains(clean, "/../") { + return fmt.Errorf("%w: %s", ErrUnsafePath, f.Name) + } + target := filepath.Join(staging, filepath.FromSlash(clean)) + rel, err := filepath.Rel(staging, target) + if err != nil || strings.HasPrefix(rel, "..") { + return fmt.Errorf("%w: %s", ErrUnsafePath, f.Name) + } + + if f.FileInfo().IsDir() { + return os.MkdirAll(target, 0o755) + } + // Refuse symlinks — they're a path-traversal vector even with + // the guard above (an attacker could ship "config -> /etc/shadow" + // and read it via a subsequent renderer load). + if f.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("%w: %s (symlink)", ErrUnsafePath, f.Name) + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + body, err := readZipEntry(f) + if err != nil { + return err + } + return os.WriteFile(target, body, 0o644) +} + +// bytesReaderAt is the minimal io.ReaderAt over a byte slice; the +// stdlib's bytes.NewReader already provides ReadAt but we avoid the +// extra import by wrapping inline. +type bytesReaderAt []byte + +// ReadAt implements io.ReaderAt. +func (b bytesReaderAt) ReadAt(p []byte, off int64) (int, error) { + if off < 0 || off >= int64(len(b)) { + return 0, io.EOF + } + n := copy(p, b[off:]) + if n < len(p) { + return n, io.EOF + } + return n, nil +} diff --git a/apps/api/internal/admin/themes/installer_test.go b/apps/api/internal/admin/themes/installer_test.go new file mode 100644 index 00000000..e849f952 --- /dev/null +++ b/apps/api/internal/admin/themes/installer_test.go @@ -0,0 +1,260 @@ +package themes_test + +import ( + "archive/zip" + "bytes" + "errors" + "os" + "path/filepath" + "testing" + + adminthemes "github.com/Singleton-Solution/GoNext/apps/api/internal/admin/themes" +) + +// validManifest is a minimal but complete theme.json that passes the +// theme package validator. Reused across the installer tests below. +const validManifest = `{ + "$schema": "https://gonext.dev/schemas/theme.json/v1", + "version": 1, + "title": "Test Theme", + "settings": { + "color": { + "palette": [{ "slug": "ink", "name": "Ink", "color": "#000000" }] + } + } +}` + +// buildZip is the test helper that produces an in-memory ZIP archive +// from a name → bytes map. Used by every installer test to skip the +// "write a file, read it back, hand it to Install" round-trip. +func buildZip(t *testing.T, files map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for name, body := range files { + f, err := zw.Create(name) + if err != nil { + t.Fatalf("zip create %s: %v", name, err) + } + if _, err := f.Write([]byte(body)); err != nil { + t.Fatalf("zip write %s: %v", name, err) + } + } + if err := zw.Close(); err != nil { + t.Fatalf("zip close: %v", err) + } + return buf.Bytes() +} + +func TestInstall_NestedLayout(t *testing.T) { + t.Parallel() + dir := t.TempDir() + zipBytes := buildZip(t, map[string]string{ + "my-theme/theme.json": validManifest, + "my-theme/style.css": "body { color: black; }", + "my-theme/templates/index.tsx": "export default function Index() { return null; }", + }) + res, err := adminthemes.Install(dir, zipBytes) + if err != nil { + t.Fatalf("Install: %v", err) + } + if res.Slug != "my-theme" { + t.Errorf("slug = %q; want my-theme", res.Slug) + } + if _, err := os.Stat(filepath.Join(dir, "my-theme", "theme.json")); err != nil { + t.Errorf("manifest not written: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "my-theme", "style.css")); err != nil { + t.Errorf("style.css not written: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "my-theme", "templates", "index.tsx")); err != nil { + t.Errorf("templates/index.tsx not written: %v", err) + } +} + +func TestInstall_FlatLayout(t *testing.T) { + t.Parallel() + dir := t.TempDir() + zipBytes := buildZip(t, map[string]string{ + "theme.json": validManifest, + "style.css": "body { color: black; }", + }) + res, err := adminthemes.Install(dir, zipBytes) + if err != nil { + t.Fatalf("Install: %v", err) + } + // Flat archive falls back to the slugified title. + if res.Slug != "test-theme" { + t.Errorf("slug = %q; want test-theme", res.Slug) + } + if _, err := os.Stat(filepath.Join(dir, "test-theme", "style.css")); err != nil { + t.Errorf("style.css not written: %v", err) + } +} + +func TestInstall_MissingManifest(t *testing.T) { + t.Parallel() + dir := t.TempDir() + zipBytes := buildZip(t, map[string]string{ + "some-theme/style.css": "body {}", + }) + _, err := adminthemes.Install(dir, zipBytes) + if !errors.Is(err, adminthemes.ErrZipMissingManifest) { + t.Errorf("err = %v; want ErrZipMissingManifest", err) + } +} + +func TestInstall_InvalidManifest(t *testing.T) { + t.Parallel() + dir := t.TempDir() + zipBytes := buildZip(t, map[string]string{ + // Version: 2 trips the schema-version validator. + "bad-theme/theme.json": `{"version": 2, "settings": {}}`, + }) + _, err := adminthemes.Install(dir, zipBytes) + if !errors.Is(err, adminthemes.ErrInvalidManifest) { + t.Errorf("err = %v; want ErrInvalidManifest", err) + } + // Nothing should be on disk. + if _, statErr := os.Stat(filepath.Join(dir, "bad-theme")); statErr == nil { + t.Errorf("dir was created despite invalid manifest") + } +} + +func TestInstall_ConflictExisting(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Pre-create the destination so the second install collides. + if err := os.MkdirAll(filepath.Join(dir, "my-theme"), 0o755); err != nil { + t.Fatalf("setup: %v", err) + } + zipBytes := buildZip(t, map[string]string{ + "my-theme/theme.json": validManifest, + }) + _, err := adminthemes.Install(dir, zipBytes) + if !errors.Is(err, adminthemes.ErrThemeExists) { + t.Errorf("err = %v; want ErrThemeExists", err) + } +} + +func TestInstall_PathTraversal(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Build a zip by hand because buildZip's keys go through the + // stdlib's normalization. We want the entry name to carry a + // literal "..". + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + manifest, _ := zw.Create("evil/theme.json") + _, _ = manifest.Write([]byte(validManifest)) + escape, _ := zw.Create("evil/../etc/passwd") + _, _ = escape.Write([]byte("root:0:0")) + _ = zw.Close() + + _, err := adminthemes.Install(dir, buf.Bytes()) + if !errors.Is(err, adminthemes.ErrUnsafePath) { + t.Errorf("err = %v; want ErrUnsafePath", err) + } +} + +func TestInstall_AbsolutePath(t *testing.T) { + t.Parallel() + dir := t.TempDir() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + m, _ := zw.Create("/theme.json") + _, _ = m.Write([]byte(validManifest)) + _ = zw.Close() + _, err := adminthemes.Install(dir, buf.Bytes()) + // Either ErrZipMissingManifest (the absolute path doesn't match + // our "theme.json at root" pattern) or ErrUnsafePath is + // acceptable — both reject the upload. + if err == nil { + t.Errorf("absolute path accepted; expected rejection") + } +} + +func TestInstall_EmptyArchive(t *testing.T) { + t.Parallel() + dir := t.TempDir() + zipBytes := buildZip(t, map[string]string{}) + _, err := adminthemes.Install(dir, zipBytes) + if !errors.Is(err, adminthemes.ErrZipMissingManifest) { + t.Errorf("err = %v; want ErrZipMissingManifest", err) + } +} + +func TestInstall_OversizedUpload(t *testing.T) { + t.Parallel() + dir := t.TempDir() + huge := bytes.Repeat([]byte{0}, adminthemes.MaxThemeZipSize+1) + _, err := adminthemes.Install(dir, huge) + if err == nil { + t.Errorf("oversized upload accepted") + } +} + +func TestListInstalled_SkipsBrokenThemes(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Valid theme. + if err := os.MkdirAll(filepath.Join(dir, "good"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "good", "theme.json"), []byte(validManifest), 0o644); err != nil { + t.Fatal(err) + } + // Broken: no theme.json. + if err := os.MkdirAll(filepath.Join(dir, "broken"), 0o755); err != nil { + t.Fatal(err) + } + // Hidden: starts with dot. + if err := os.MkdirAll(filepath.Join(dir, ".hidden"), 0o755); err != nil { + t.Fatal(err) + } + + themes, err := adminthemes.ListInstalled(nil, dir) + if err != nil { + t.Fatalf("ListInstalled: %v", err) + } + if len(themes) != 1 { + t.Fatalf("len = %d; want 1; got %+v", len(themes), themes) + } + if themes[0].Slug != "good" { + t.Errorf("slug = %q; want good", themes[0].Slug) + } + if themes[0].Title != "Test Theme" { + t.Errorf("title = %q; want Test Theme", themes[0].Title) + } +} + +func TestListInstalled_MissingDir(t *testing.T) { + t.Parallel() + themes, err := adminthemes.ListInstalled(nil, filepath.Join(t.TempDir(), "does-not-exist")) + if err != nil { + t.Fatalf("ListInstalled: %v", err) + } + if len(themes) != 0 { + t.Errorf("len = %d; want 0", len(themes)) + } +} + +func TestThemeInstalled(t *testing.T) { + t.Parallel() + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "ok"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "ok", "theme.json"), []byte(validManifest), 0o644); err != nil { + t.Fatal(err) + } + if !adminthemes.ThemeInstalled(dir, "ok") { + t.Errorf("ok should be installed") + } + if adminthemes.ThemeInstalled(dir, "missing") { + t.Errorf("missing should not be installed") + } + if adminthemes.ThemeInstalled("", "ok") { + t.Errorf("empty dir should report not installed") + } +} diff --git a/apps/api/internal/admin/themes/inventory.go b/apps/api/internal/admin/themes/inventory.go new file mode 100644 index 00000000..32fc565e --- /dev/null +++ b/apps/api/internal/admin/themes/inventory.go @@ -0,0 +1,129 @@ +package themes + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/Singleton-Solution/GoNext/packages/go/theme" +) + +// ThemeInfo is the per-theme record returned by ListInstalled. It is +// the minimum the switcher UI needs: machine slug + display title + +// one-line description + whether a screenshot.png exists alongside +// the manifest. The full manifest is intentionally omitted here so +// the list endpoint stays cheap; the customizer's /active endpoint +// is the place to fetch the full ThemeJSON for the current theme. +type ThemeInfo struct { + // Slug is the on-disk directory name. It is also the stable + // identifier the active-theme option stores and the activate + // endpoint accepts. + Slug string `json:"slug"` + + // Title is the human-readable theme name read from theme.json. + // Falls back to the slug when the manifest doesn't declare one. + Title string `json:"title"` + + // Description is a short summary surfaced in the switcher card. + // Currently always empty (theme.json doesn't carry a description + // field in v1) — kept on the wire shape so a future manifest key + // lands without a UI break. + Description string `json:"description,omitempty"` + + // Version is the manifest schema version (currently always 1). + // Surfaced so the switcher can warn when a theme is on an + // unsupported schema. + Version int `json:"version"` + + // HasScreenshot reports whether a screenshot.png file sits next + // to the manifest. The UI uses this to decide between rendering + // a real preview image and a CSS scene placeholder. + HasScreenshot bool `json:"has_screenshot"` +} + +// ListInstalled walks themeDir and returns every subdirectory whose +// theme.json parses cleanly. Themes whose manifest is missing or +// malformed are skipped silently (we don't want one bad theme to +// blank the entire switcher); the caller can run the validator +// independently if it wants to surface those. +// +// The result is sorted by slug so the order is stable across calls +// — operators rely on the gallery not shuffling between page loads. +func ListInstalled(_ context.Context, themeDir string) ([]ThemeInfo, error) { + if themeDir == "" { + return nil, errors.New("themes: empty themeDir") + } + entries, err := os.ReadDir(themeDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + // A fresh deploy with no themes installed isn't an error + // — it's an empty switcher. The seeder normally creates + // the directory + the gn-hello theme on first boot. + return []ThemeInfo{}, nil + } + return nil, fmt.Errorf("themes: read %s: %w", themeDir, err) + } + + out := make([]ThemeInfo, 0, len(entries)) + for _, e := range entries { + if !e.IsDir() { + continue + } + // Directories starting with a dot are operator-private (e.g. + // editor scratch dirs) — skip them so they don't pollute the + // gallery. + if strings.HasPrefix(e.Name(), ".") { + continue + } + slug := e.Name() + manifestPath := filepath.Join(themeDir, slug, "theme.json") + data, readErr := os.ReadFile(manifestPath) + if readErr != nil { + // Missing manifest = not a theme directory; skip without + // noise. Permission errors fall here too; the operator + // can fix the chmod and the theme will appear on next + // list. + continue + } + manifest, parseErr := theme.Parse(data) + if parseErr != nil { + // Malformed manifest = skip, see comment above. + continue + } + _, hasScreenshot := os.Stat(filepath.Join(themeDir, slug, "screenshot.png")) + title := manifest.Title + if title == "" { + title = slug + } + out = append(out, ThemeInfo{ + Slug: slug, + Title: title, + Version: manifest.Version, + HasScreenshot: hasScreenshot == nil, + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].Slug < out[j].Slug }) + return out, nil +} + +// ThemeInstalled reports whether a theme directory with the given +// slug ships a parseable theme.json. Used by the activate handler to +// validate "you can switch to this slug" before writing the option. +func ThemeInstalled(themeDir, slug string) bool { + if themeDir == "" || slug == "" { + return false + } + manifestPath := filepath.Join(themeDir, slug, "theme.json") + data, err := os.ReadFile(manifestPath) + if err != nil { + return false + } + if _, err := theme.Parse(data); err != nil { + return false + } + return true +} diff --git a/apps/api/internal/admin/themes/store.go b/apps/api/internal/admin/themes/store.go new file mode 100644 index 00000000..b40f4509 --- /dev/null +++ b/apps/api/internal/admin/themes/store.go @@ -0,0 +1,110 @@ +package themes + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// ActiveThemeOptionKey is the options-table key that records the +// currently-active theme slug. Mirrors the constant of the same name +// in apps/api/internal/admin/customizer — re-declared here so this +// package doesn't pull customizer's surface into its own dependency +// graph (customizer is the read+overrides flow; this package is the +// install+switch flow, and the two are intentionally siblings). +const ActiveThemeOptionKey = "core.active_theme" + +// ErrNoActiveTheme is returned by ActiveStore.Get when the options +// row is absent. The theme seeder writes it on first boot, so this +// path is reached only on a fresh database or after an explicit wipe. +var ErrNoActiveTheme = errors.New("themes: no active theme") + +// ActiveStore reads + writes the active-theme slug on the options +// table. Two implementations exist: PgxActiveStore (production, +// wraps pgxpool.Pool) and MemoryActiveStore (tests). +type ActiveStore interface { + // Get returns the slug stored under core.active_theme. + // ErrNoActiveTheme when the row is missing. + Get(ctx context.Context) (string, error) + + // Set upserts the active-theme slug. Callers validate that the + // slug exists on disk before invoking this — the store does not + // check the filesystem. + Set(ctx context.Context, slug string) error +} + +// readActiveSQL fetches the JSONB value (as text) of the active +// theme option. The seeder writes it as a JSONB string literal, so +// we extract the text via the #>> '{}' operator. +const readActiveSQL = `SELECT value #>> '{}' FROM options WHERE key = $1` + +// writeActiveSQL upserts the active-theme row. The seeder writes +// JSONB; we mirror that here by passing the slug as a JSON string +// literal ("gn-hello" → JSON value "gn-hello"). autoload is TRUE +// because every renderer wakeup needs this key — keeping it in the +// autoload set avoids one cache miss per cold boot. +const writeActiveSQL = ` +INSERT INTO options (key, value, autoload, namespace, updated_at) +VALUES ($1, to_jsonb($2::text), TRUE, 'core', now()) +ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, + updated_at = now() +` + +// PgxActiveStore is the production ActiveStore backed by pgx. +type PgxActiveStore struct { + Pool *pgxpool.Pool +} + +// Get implements ActiveStore. +func (s *PgxActiveStore) Get(ctx context.Context) (string, error) { + if s.Pool == nil { + return "", ErrNoActiveTheme + } + var slug string + err := s.Pool.QueryRow(ctx, readActiveSQL, ActiveThemeOptionKey).Scan(&slug) + switch { + case errors.Is(err, pgx.ErrNoRows): + return "", ErrNoActiveTheme + case err != nil: + return "", fmt.Errorf("themes: read active: %w", err) + } + if slug == "" { + return "", ErrNoActiveTheme + } + return slug, nil +} + +// Set implements ActiveStore. +func (s *PgxActiveStore) Set(ctx context.Context, slug string) error { + if s.Pool == nil { + return errors.New("themes: pool is nil") + } + if _, err := s.Pool.Exec(ctx, writeActiveSQL, ActiveThemeOptionKey, slug); err != nil { + return fmt.Errorf("themes: write active: %w", err) + } + return nil +} + +// MemoryActiveStore is the test-friendly ActiveStore. Safe for +// concurrent use by tests that hit Mount with parallel requests. +type MemoryActiveStore struct { + Slug string +} + +// Get implements ActiveStore. +func (m *MemoryActiveStore) Get(_ context.Context) (string, error) { + if m.Slug == "" { + return "", ErrNoActiveTheme + } + return m.Slug, nil +} + +// Set implements ActiveStore. +func (m *MemoryActiveStore) Set(_ context.Context, slug string) error { + m.Slug = slug + return nil +}