all repos — weimar @ b26a020c4b21a1c0d9a62bc6afdf405ea2097d82

Unnamed repository; edit this file 'description' to name the repository.

feat: configurable site title, favicon, and nav logo

Add site_title, favicon_path, and logo_path config options. Default favicon
is an embedded SVG with a red lowercase "w" on black. Custom favicons can
be SVG, PNG, JPG, or ICO — the /favicon handler serves them with the
correct Content-Type detected from the file extension. The /logo handler
serves a nav logo; when set, the layout shows an <img> instead of the text
site title. The GET /api/config endpoint exposes these URLs to the frontend.
Includes example config at configs/example.toml.

Co-authored-by: Crush
Maxwell Jensen maxwelljensen@posteo.net
Wed, 13 May 2026 20:05:05 +0200
commit

b26a020c4b21a1c0d9a62bc6afdf405ea2097d82

parent

a213da87e3a6d37de1c358472e176aaf8835b995

M AGENTS.mdAGENTS.md

@@ -206,6 +206,7 @@ [server]

host = "0.0.0.0" port = 8080 # behind_proxy = true # set when behind Caddy/nginx (enables Secure cookies) +# site_title = "My Site" # shown in nav and page title (default "weimar") [database] path = "./data/weimar.db"
M cmd/weimar/root.gocmd/weimar/root.go

@@ -76,6 +76,9 @@ func setDefaults() {

viper.SetDefault("server.host", "0.0.0.0") viper.SetDefault("server.port", 8080) viper.SetDefault("server.behind_proxy", false) + viper.SetDefault("server.site_title", "weimar") + viper.SetDefault("server.favicon_path", "") + viper.SetDefault("server.logo_path", "") viper.SetDefault("database.path", "./data/weimar.db") viper.SetDefault("storage.path", "./data/images") viper.SetDefault("upload.max_size_mb", 50)
A configs/example.toml

@@ -0,0 +1,47 @@

+# weimar configuration — all fields have sensible defaults. +# Config file is optional; every value can also be set via WEIMAR_* env vars. +# +# weimar serve --config weimar.toml +# WEIMAR_SERVER_PORT=9090 weimar serve + +[server] +# Address and port to listen on. +host = "0.0.0.0" +port = 8080 + +# Set to true when weimar is behind a TLS-terminating reverse proxy +# (e.g. Caddy, nginx). Enables the Secure flag on session cookies. +behind_proxy = false + +# Site title shown in the navigation bar and browser tab. +site_title = "weimar" + +# Path to a custom favicon file (.svg, .png, .jpg, .ico — any format works). +# If not set, the default "w" favicon is used. +# favicon_path = "/path/to/favicon.png" + +# Path to a logo image displayed in the top-left nav bar. +# If not set, the site_title is shown as text instead. +# logo_path = "/path/to/logo.png" + +[database] +# Path to the SQLite database file (created automatically). +path = "./data/weimar.db" + +[storage] +# Directory where uploaded files and thumbnails are stored. +path = "./data/images" + +[upload] +# Maximum file size in megabytes for uploads. +max_size_mb = 50 + +# Rate limit for uploads per minute (0 = unlimited). +rate_limit_per_minute = 0 + +[auth] +# Allow anyone to register an account via the web form. +allow_registration = true + +# Require authentication to browse the gallery. +require_auth_for_browse = false
A internal/api/config.go

@@ -0,0 +1,18 @@

+package api + +import "net/http" + +func (a *API) HandleConfig(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "site_title": a.Cfg.Server.SiteTitle, + } + + if a.Cfg.Server.FaviconPath != "" { + resp["favicon_url"] = "/favicon" + } + if a.Cfg.Server.LogoPath != "" { + resp["logo_url"] = "/logo" + } + + writeJSON(w, http.StatusOK, resp) +}
A internal/api/media.go

@@ -0,0 +1,92 @@

+package api + +import ( + "io/fs" + "mime" + "net/http" + "os" + "path/filepath" + "strings" +) + +// defaultFavicon is served when no favicon_path is configured. +var defaultFavicon []byte + +// SetDefaultFavicon sets the fallback favicon bytes (called at startup from +// the embedded web build so the binary doesn't embed a second copy). +func SetDefaultFavicon(data []byte) { + defaultFavicon = data +} + +// InitDefaultFavicon reads the favicon from the embedded web build so it can +// be served as the fallback for /favicon without an extra embedded copy. +func InitDefaultFavicon(webFS fs.FS) error { + sub, err := fs.Sub(webFS, "web/build") + if err != nil { + return nil + } + data, err := fs.ReadFile(sub, "favicon.svg") + if err != nil { + return nil + } + SetDefaultFavicon(data) + return nil +} + +func (a *API) HandleFavicon(w http.ResponseWriter, r *http.Request) { + if a.Cfg.Server.FaviconPath != "" { + serveFile(w, r, a.Cfg.Server.FaviconPath) + return + } + if len(defaultFavicon) > 0 { + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Cache-Control", "public, max-age=86400") + w.Write(defaultFavicon) + return + } + http.NotFound(w, r) +} + +func (a *API) HandleLogo(w http.ResponseWriter, r *http.Request) { + if a.Cfg.Server.LogoPath != "" { + serveFile(w, r, a.Cfg.Server.LogoPath) + return + } + http.NotFound(w, r) +} + +func serveFile(w http.ResponseWriter, r *http.Request, path string) { + ext := strings.ToLower(filepath.Ext(path)) + mimeType := mime.TypeByExtension(ext) + if mimeType == "" { + switch ext { + case ".svg": + mimeType = "image/svg+xml" + case ".ico": + mimeType = "image/x-icon" + default: + mimeType = "application/octet-stream" + } + } + + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + http.NotFound(w, r) + } else { + http.Error(w, "internal error", http.StatusInternalServerError) + } + return + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", mimeType) + w.Header().Set("Cache-Control", "public, max-age=86400") + http.ServeContent(w, r, filepath.Base(path), stat.ModTime(), f) +}
M internal/config/config.gointernal/config/config.go

@@ -13,7 +13,10 @@

type ServerConfig struct { Host string Port int - BehindProxy bool `mapstructure:"behind_proxy"` + BehindProxy bool `mapstructure:"behind_proxy"` + SiteTitle string `mapstructure:"site_title"` + FaviconPath string `mapstructure:"favicon_path"` + LogoPath string `mapstructure:"logo_path"` } func (s ServerConfig) SecureCookie() bool {

@@ -44,6 +47,9 @@ Server: ServerConfig{

Host: viper.GetString("server.host"), Port: viper.GetInt("server.port"), BehindProxy: viper.GetBool("server.behind_proxy"), + SiteTitle: viper.GetString("server.site_title"), + FaviconPath: viper.GetString("server.favicon_path"), + LogoPath: viper.GetString("server.logo_path"), }, Database: DatabaseConfig{ Path: viper.GetString("database.path"),
M internal/server/server.gointernal/server/server.go

@@ -21,10 +21,20 @@ return nil, err

} h := api.New(database, sm, st, cfg) + // Load the default favicon from the embedded web build. + api.InitDefaultFavicon(webFS) + mux := http.NewServeMux() // Health check (public). mux.HandleFunc("GET /api/health", handleHealth) + + // Public config. + mux.HandleFunc("GET /api/config", h.HandleConfig) + + // Favicon and logo (public). + mux.HandleFunc("GET /favicon", h.HandleFavicon) + mux.HandleFunc("GET /logo", h.HandleLogo) s := &Server{ cfg: cfg,
M web/src/app.htmlweb/src/app.html

@@ -6,6 +6,7 @@ <meta name="viewport" content="width=device-width, initial-scale=1" />

<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Oswald:wght@500;600;700&display=swap" rel="stylesheet" /> + <link rel="icon" href="/favicon" /> <title>weimar</title> %sveltekit.head% </head>
M web/src/lib/api.tsweb/src/lib/api.ts

@@ -134,6 +134,16 @@ export async function getMe(): Promise<CurrentUser> {

return request<CurrentUser>('/users/me'); } +export interface SiteConfig { + site_title: string; + favicon_url?: string; + logo_url?: string; +} + +export async function getConfig(): Promise<SiteConfig> { + return request<SiteConfig>('/config'); +} + export async function addTags(imageId: number, tags: string[]): Promise<{ tags_detail: TagDetail[] }> { return request<{ tags_detail: TagDetail[] }>(`/images/${imageId}/tags`, { method: 'POST',
M web/src/routes/+layout.svelteweb/src/routes/+layout.svelte

@@ -1,14 +1,18 @@

<script lang="ts"> import { onMount } from 'svelte'; - import { logout, getMe, type CurrentUser } from '$lib/api'; + import { logout, getMe, getConfig, type CurrentUser, type SiteConfig } from '$lib/api'; import Avatar from '$lib/Avatar.svelte'; let { children } = $props(); let user = $state<CurrentUser | null>(null); let checking = $state(true); + let siteConfig = $state<SiteConfig | null>(null); onMount(() => { + getConfig() + .then((c) => { siteConfig = c; document.title = c.site_title; }) + .catch(() => {}); getMe() .then((u) => { user = u; }) .catch(() => {})

@@ -24,7 +28,13 @@ </script>

<nav> <div class="nav-brand"> - <a href="/" class="nav-logo">WEIMAR</a> + <a href="/" class="nav-logo"> + {#if siteConfig?.logo_url} + <img src={siteConfig.logo_url} alt={siteConfig.site_title} class="nav-logo-img" /> + {:else} + {siteConfig?.site_title ?? 'weimar'} + {/if} + </a> <span class="nav-divider"></span> </div> <div class="nav-links">

@@ -116,6 +126,11 @@ }

.nav-logo:hover { text-decoration: none; color: #ef5350; + } + .nav-logo-img { + display: block; + height: 1.8rem; + width: auto; } .nav-divider {
A web/static/favicon.svg

@@ -0,0 +1,4 @@

+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> + <rect width="32" height="32" rx="0" fill="#1a1a1a"/> + <text x="16" y="23" text-anchor="middle" font-family="Oswald,sans-serif" font-weight="700" font-size="22" fill="#c62828">w</text> +</svg>