diff --git a/README.md b/README.md index 80d7efcf..3b366b35 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ Follow the [API](./docs/api.md) to learn how to access and use the REST API. Follow the [configuring authentication](./docs/auth.md) guide for chosing the method that meets your requirements. +## Branding and white-labeling +Follow the [branding guide](./docs/branding.md) to replace the server's default +look with your own logo, favicon, and color scheme — no rebuild required. + ## Running in production The [production guide](./docs/production.md) covers considerations when deploying the update server for production use. diff --git a/docs/branding.md b/docs/branding.md new file mode 100644 index 00000000..f8abe070 --- /dev/null +++ b/docs/branding.md @@ -0,0 +1,145 @@ +# Branding the Update Server + +You can visually rebrand the server at runtime — no recompilation or rebuild required. +Drop your assets into a directory, edit a JSON file, and restart. + +## The branding directory + +Branding files live in a `branding/` subdirectory of the server's data directory: + +``` +/branding/ +``` + +The directory is entirely optional. If it is absent, the server uses its built-in +default look with no further configuration needed. + +## branding.json + +Place a `branding.json` file in `/branding/` to control the server's +appearance. Every field is optional; any field you omit, or that carries an invalid +value, falls back silently to its built-in default. + +* `title` (string) — Application name shown in the top navigation bar and appended + to every browser tab title (for example, a title of `Acme Device Updates` makes + the Devices page read "Devices - Acme Device Updates"). Default: `Foundries Update Server`. + +* `logo` (string) — Filename of an image to display in the top navigation bar in + place of the title text. Any browser-displayable image format is accepted + (SVG, PNG, JPEG, …). The file must be a plain filename with no path components + and must exist in `/branding/`. If omitted or the file is missing, + the title text is shown instead. + +* `favicon` (string) — Filename of the browser tab and bookmark icon. Accepted + formats: `.svg`, `.ico`, `.png`. The file must be a plain filename in + `/branding/`. If omitted, or if the filename contains a path + component or has an unsupported extension, the built-in default favicon is served. + +* `colors` (object) — Light-mode theme colors (see also `colorsDark` for dark-mode + overrides). Each value is a CSS color: hex (`#rrggbb`), `rgb()` / `rgba()`, + `hsl()`, or a CSS named color. Invalid values are ignored and that color keeps + its default. + + * `primary` — Top bar background and navigation accents. Default: `rgb(2, 11, 64)`. + * `accent` — Hover and highlight color; also used as the interactive/link color in + dark mode. Default: `#a3b4ff`. + * `surface` — Page background. Default: `#f2f2f2`. + * `surface-alt` — Content and card background. Default: `#ffffff`. + * `text` — Main text color. Default: `#000000`. + +* `colorsDark` (object, optional) — Dark-mode surface and text overrides. Mirrors the + `colors` object in format but only three keys are applied: `surface`, `surface-alt`, + and `text`. The `primary` and `accent` brand colors are shared across both modes — + placing them inside `colorsDark` is accepted but has no effect; dark-mode interactive + color is always derived from the `accent` value in `colors`. Each value follows the + same CSS color formats and fail-soft validation as `colors` (an invalid value leaves + that dark field at its default). Omitting `colorsDark` entirely, or omitting + individual keys within it, keeps the built-in dark defaults for those fields. + + * `surface` — Page background in dark mode. Default: `#11191f`. + * `surface-alt` — Content and card background in dark mode. Default: `#1a2632`. + * `text` — Main text color in dark mode. Default: `#eef1f4`. + +> [!IMPORTANT] +> Branding is read once at startup. If `branding.json` is present but contains +> invalid JSON, the server logs a warning and falls back to all defaults for that +> run. After any change to `branding.json` or its assets, restart the server for +> the changes to take effect. + +## Logos and favicons + +Asset files must be plain filenames — no subdirectory paths. The server will not +serve a file whose name contains a `/` or `\`. Place all assets directly in +`/branding/` alongside `branding.json`. + +Supported favicon formats are `.svg`, `.ico`, and `.png`. Any other extension is +treated as invalid and the built-in favicon is used instead. + +If a `logo` filename is set but the file does not exist in `/branding/`, +the server falls back to displaying the title text. + +## Example + +A minimal rebranding for "Acme Corp" might look like this: + +``` +/branding/ + branding.json + logo.svg + favicon.svg +``` + +`branding.json`: + +```json +{ + "title": "Acme Device Updates", + "logo": "logo.svg", + "favicon": "favicon.svg", + "colors": { + "primary": "#0b3d2e", + "accent": "#7fd1ae" + }, + "colorsDark": { + "surface": "#12151c", + "surface-alt": "#1a1f29", + "text": "#e6e8ec" + } +} +``` + +Effect: the top bar background becomes dark green, the custom logo SVG replaces +the title text in the navigation bar, the browser tab shows the custom favicon, +and page backgrounds and text color remain at their defaults in light mode because +`surface`, `surface-alt`, and `text` are not overridden in `colors`. In dark mode +the page background becomes `#12151c`, card and content areas become `#1a1f29`, +and the main text is rendered in `#e6e8ec`; the accent color `#7fd1ae` carries +over automatically as the dark-mode interactive/link color. + +## Applying changes + +Branding is loaded once when the server starts. After editing `branding.json` or +replacing any asset file, restart `fioserver` to pick up the changes. + +## Defaults + +| Key | Default value | +|---|---| +| `title` | `Foundries Update Server` | +| `logo` | *(none — title text is shown)* | +| `favicon` | *(built-in default favicon)* | +| `colors.primary` | `rgb(2, 11, 64)` | +| `colors.accent` | `#a3b4ff` | +| `colors.surface` | `#f2f2f2` | +| `colors.surface-alt` | `#ffffff` | +| `colors.text` | `#000000` | +| `colorsDark.surface` | `#11191f` | +| `colorsDark.surface-alt` | `#1a2632` | +| `colorsDark.text` | `#eef1f4` | + +> [!IMPORTANT] +> Colors in `colors` apply to the light theme; colors in `colorsDark` apply to the +> dark theme. The UI automatically follows the browser or system light/dark preference — +> there is no manual toggle. Keep your `colorsDark` palette dark: the UI's small +> monochrome icons remain light-colored in dark mode by design and will not be +> legible against a light dark-surface. diff --git a/server/ui/server.go b/server/ui/server.go index 893a0a47..14b109d5 100644 --- a/server/ui/server.go +++ b/server/ui/server.go @@ -43,12 +43,18 @@ func NewServer(ctx context.Context, db *storage.DbHandle, fs *storage.FsHandle, } slog.Info("Using authentication provider", "name", provider.Name()) + brandingData, err := fs.Config.ReadBrandingConfig() + if err != nil { + return nil, fmt.Errorf("failed to read branding config: %w", err) + } + branding := webHandlers.LoadBranding(brandingData) + daemons := daemons.New(ctx, strg, users) srv := server.NewServer(ctx, e, serverName, bindAddr, nil) e.Use(auth.CsrfCheck) apiHandlers.RegisterHandlers(e, strg, provider) - webHandlers.RegisterHandlers(e, users, provider) + webHandlers.RegisterHandlers(e, users, provider, branding, fs.Config.BrandingDir()) return &apiServer{server: srv, daemons: daemons}, nil } diff --git a/server/ui/web/branding.go b/server/ui/web/branding.go new file mode 100644 index 00000000..0f3b8324 --- /dev/null +++ b/server/ui/web/branding.go @@ -0,0 +1,118 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +package web + +import ( + "encoding/json" + "log/slog" + "path/filepath" + "regexp" + "strings" +) + +// cssColor allows hex, rgb()/rgba()/hsl() functional notation, and CSS named +// colors — i.e. the characters those need. Anything else (e.g. ";", "{") is +// rejected so a bad branding.json can't inject or break the stylesheet. +var cssColor = regexp.MustCompile(`^[#a-zA-Z0-9(),.%\s/-]+$`) + +// faviconExts bounds the content type an operator favicon can declare. Anything +// else falls back to the embedded default. +var faviconExts = map[string]bool{".svg": true, ".ico": true, ".png": true} + +// Branding holds the resolved branding values (defaults applied) used by both +// HTML templates and the templated CSS. +type Branding struct { + Title string // app name in topbar + suffix + Logo string // filename under <data-dir>/branding/, "" = text brand only + Favicon string // favicon filename under <data-dir>/branding/, "" = built-in default + Primary string // --brand-primary + Accent string // --brand-accent + Surface string // --surface-1 (page background) + SurfaceAlt string // --surface-2 (content background) + Text string // --text-1 + // Dark-mode palette. Built-in defaults apply when colorsDark is omitted; + // brand primary/accent are shared across modes (not duplicated here). + SurfaceDark string // --surface-1 in dark + SurfaceAltDark string // --surface-2 in dark + TextDark string // --text-1 in dark +} + +// brandingColors is the on-disk color palette shape, reused for the light +// "colors" object and the dark "colorsDark" object. In the dark object, +// primary/accent are accepted for schema symmetry but not applied — brand +// colors are shared across modes (only surfaces + text vary by mode). +type brandingColors struct { + Primary string `json:"primary"` + Accent string `json:"accent"` + Surface string `json:"surface"` + SurfaceAlt string `json:"surface-alt"` + Text string `json:"text"` +} + +// brandingFile is the on-disk JSON shape. Empty fields (absent or "") fall back +// to the built-in defaults in Branding. +type brandingFile struct { + Title string `json:"title"` + Logo string `json:"logo"` + Favicon string `json:"favicon"` + Colors brandingColors `json:"colors"` + ColorsDark brandingColors `json:"colorsDark"` +} + +func defaultBranding() Branding { + return Branding{ + Title: "Foundries Update Server", + Logo: "", + Favicon: "", + Primary: "rgb(2, 11, 64)", + Accent: "#a3b4ff", + Surface: "#f2f2f2", + SurfaceAlt: "#ffffff", + Text: "#000000", + SurfaceDark: "#11191f", + SurfaceAltDark: "#1a2632", + TextDark: "#eef1f4", + } +} + +// LoadBranding applies any overrides in the given branding.json bytes onto the +// defaults. nil/empty input or a parse error yields the defaults unchanged. +func LoadBranding(data []byte) Branding { + b := defaultBranding() + if len(data) == 0 { + return b + } + var f brandingFile + if err := json.Unmarshal(data, &f); err != nil { + slog.Warn("ignoring invalid branding.json, using defaults", "error", err) + return b + } + set := func(dst *string, src string) { + if src != "" { + *dst = src + } + } + setColor := func(dst *string, src string) { + if src != "" && cssColor.MatchString(src) { + *dst = src + } + } + set(&b.Title, f.Title) + set(&b.Logo, f.Logo) + if f.Favicon != "" { + if filepath.Base(f.Favicon) == f.Favicon && faviconExts[strings.ToLower(filepath.Ext(f.Favicon))] { + b.Favicon = f.Favicon + } + // invalid (traversal or bad extension) → keep default "" (embedded favicon) + } + setColor(&b.Primary, f.Colors.Primary) + setColor(&b.Accent, f.Colors.Accent) + setColor(&b.Surface, f.Colors.Surface) + setColor(&b.SurfaceAlt, f.Colors.SurfaceAlt) + setColor(&b.Text, f.Colors.Text) + setColor(&b.SurfaceDark, f.ColorsDark.Surface) + setColor(&b.SurfaceAltDark, f.ColorsDark.SurfaceAlt) + setColor(&b.TextDark, f.ColorsDark.Text) + return b +} diff --git a/server/ui/web/branding_test.go b/server/ui/web/branding_test.go new file mode 100644 index 00000000..1d1f6243 --- /dev/null +++ b/server/ui/web/branding_test.go @@ -0,0 +1,128 @@ +// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries. +// SPDX-License-Identifier: BSD-3-Clause-Clear + +package web + +import "testing" + +func TestBrandingDefaultsWhenEmpty(t *testing.T) { + if got := LoadBranding(nil); got != defaultBranding() { + t.Errorf("LoadBranding(nil) = %+v, want defaults %+v", got, defaultBranding()) + } +} + +func TestBrandingOverrides(t *testing.T) { + input := []byte(`{"title":"Acme","logo":"logo.svg","colors":{"primary":"#cc0000"}}`) + b := LoadBranding(input) + if b.Title != "Acme" { + t.Errorf("Title = %q, want Acme", b.Title) + } + if b.Primary != "#cc0000" { + t.Errorf("Primary = %q, want #cc0000", b.Primary) + } + if b.Logo != "logo.svg" { + t.Errorf("Logo = %q, want logo.svg", b.Logo) + } + if b.Accent != "#a3b4ff" { + t.Errorf("Accent = %q, want default", b.Accent) + } +} + +func TestBrandingInvalidJSONFallsBackToDefaults(t *testing.T) { + if got := LoadBranding([]byte(`{not valid`)); got != defaultBranding() { + t.Errorf("LoadBranding(invalid) = %+v, want defaults %+v", got, defaultBranding()) + } +} + +func TestBrandingRejectsMaliciousColor(t *testing.T) { + input := []byte(`{"colors":{"primary":"red; } body { display:none } :root {"}}`) + b := LoadBranding(input) + if b.Primary != defaultBranding().Primary { + t.Errorf("Primary = %q, want default (malicious value rejected)", b.Primary) + } +} + +func TestBrandingAcceptsValidColors(t *testing.T) { + input := []byte(`{"colors":{"primary":"rgb(10, 20, 30)","accent":"hsl(200 50% 50%)","text":"#abc"}}`) + b := LoadBranding(input) + if b.Primary != "rgb(10, 20, 30)" || b.Accent != "hsl(200 50% 50%)" || b.Text != "#abc" { + t.Errorf("valid colors rejected: %+v", b) + } +} + +func TestBrandingFaviconValid(t *testing.T) { + for _, name := range []string{"favicon.svg", "logo.ico", "icon.png", "ICON.PNG"} { + b := LoadBranding([]byte(`{"favicon":"` + name + `"}`)) + if b.Favicon != name { + t.Errorf("Favicon = %q, want %q (valid extension kept)", b.Favicon, name) + } + } +} + +func TestBrandingFaviconRejectsBadExtension(t *testing.T) { + b := LoadBranding([]byte(`{"favicon":"evil.txt"}`)) + if b.Favicon != "" { + t.Errorf("Favicon = %q, want empty (bad extension rejected)", b.Favicon) + } +} + +func TestBrandingFaviconRejectsTraversal(t *testing.T) { + b := LoadBranding([]byte(`{"favicon":"../../etc/x.svg"}`)) + if b.Favicon != "" { + t.Errorf("Favicon = %q, want empty (traversal rejected)", b.Favicon) + } +} + +func TestBrandingFaviconDefaultsEmpty(t *testing.T) { + if b := LoadBranding(nil); b.Favicon != "" { + t.Errorf("Favicon = %q, want empty by default", b.Favicon) + } +} + +func TestBrandingDarkDefaults(t *testing.T) { + b := LoadBranding(nil) + if b.SurfaceDark != "#11191f" { + t.Errorf("SurfaceDark = %q, want default", b.SurfaceDark) + } + if b.SurfaceAltDark != "#1a2632" { + t.Errorf("SurfaceAltDark = %q, want default", b.SurfaceAltDark) + } + if b.TextDark != "#eef1f4" { + t.Errorf("TextDark = %q, want default", b.TextDark) + } +} + +func TestBrandingDarkOverrides(t *testing.T) { + input := []byte(`{"colorsDark":{"surface":"#12151c","surface-alt":"#1a1f29","text":"#e6e8ec"}}`) + b := LoadBranding(input) + if b.SurfaceDark != "#12151c" || b.SurfaceAltDark != "#1a1f29" || b.TextDark != "#e6e8ec" { + t.Errorf("dark overrides not applied: %+v", b) + } +} + +func TestBrandingDarkPartialKeepsDefaults(t *testing.T) { + b := LoadBranding([]byte(`{"colorsDark":{"surface":"#12151c"}}`)) + if b.SurfaceDark != "#12151c" { + t.Errorf("SurfaceDark = %q, want override", b.SurfaceDark) + } + if b.TextDark != "#eef1f4" { + t.Errorf("TextDark = %q, want default (unspecified)", b.TextDark) + } +} + +func TestBrandingDarkRejectsInvalidColor(t *testing.T) { + b := LoadBranding([]byte(`{"colorsDark":{"text":"red; } body{}"}}`)) + if b.TextDark != "#eef1f4" { + t.Errorf("TextDark = %q, want default (invalid rejected)", b.TextDark) + } +} + +func TestBrandingLightUnaffectedByDark(t *testing.T) { + b := LoadBranding([]byte(`{"colors":{"surface":"#fafafa"}}`)) + if b.Surface != "#fafafa" { + t.Errorf("Surface = %q, want light override", b.Surface) + } + if b.SurfaceDark != "#11191f" { + t.Errorf("SurfaceDark = %q, want default when colorsDark absent", b.SurfaceDark) + } +} diff --git a/server/ui/web/handlers.go b/server/ui/web/handlers.go index 597037c9..834fd8cd 100644 --- a/server/ui/web/handlers.go +++ b/server/ui/web/handlers.go @@ -4,11 +4,15 @@ package web import ( + "bytes" "crypto/md5" "fmt" "html/template" "io" + "log/slog" "net/http" + "os" + "path/filepath" "github.com/labstack/echo/v4" @@ -20,27 +24,36 @@ import ( ) type handlers struct { - users *users.Storage - provider auth.Provider - templates *template.Template - styleEtag string + users *users.Storage + provider auth.Provider + templates *template.Template + styleEtag string + branding Branding + brandingDir string } var EchoError = server.EchoError -func RegisterHandlers(e *echo.Echo, storage *users.Storage, authProvider auth.Provider) { - cssBytes, _ := templates.Assets.ReadFile("style.css") +func RegisterHandlers(e *echo.Echo, storage *users.Storage, authProvider auth.Provider, branding Branding, brandingDir string) { h := handlers{ - users: storage, - provider: authProvider, - styleEtag: fmt.Sprintf("%x", md5.Sum(cssBytes)), - templates: templates.Templates, + users: storage, + provider: authProvider, + templates: templates.Templates, + branding: branding, + brandingDir: brandingDir, } + var rendered bytes.Buffer + if err := h.templates.ExecuteTemplate(&rendered, "style.css", branding); err != nil { + slog.Error("failed to render style.css for etag", "error", err) + } + h.styleEtag = fmt.Sprintf("%x", md5.Sum(rendered.Bytes())) e.Renderer = h e.GET("/", h.index, h.requireSession) e.GET("/css/:filename", h.css) + e.GET("/branding/:filename", h.brandingAsset) + e.GET("/favicon", h.favicon) e.GET("/auth/logout", h.authLogout, h.requireSession) e.GET("/configs", h.configsList, h.requireSession, h.requireScope(users.ScopeDevicesR)) e.GET("/configs/device/:uuid", h.configsDeviceItem, h.requireSession, h.requireScope(users.ScopeDevicesR)) @@ -85,6 +98,8 @@ func RegisterHandlers(e *echo.Echo, storage *users.Storage, authProvider auth.Pr type baseCtx struct { User *users.User Title string + BrandName string + LogoPath string NavItems []navItem CsrfToken string Version string @@ -95,9 +110,15 @@ func (h handlers) baseCtx(c echo.Context, title, selected string) baseCtx { if cookie, err := c.Cookie(auth.CsrfCookieName); err == nil { csrfToken = cookie.Value } + logoPath := "" + if h.branding.Logo != "" { + logoPath = "/branding/" + h.branding.Logo + } return baseCtx{ User: CtxGetSession(c.Request().Context()).User, Title: title, + BrandName: h.branding.Title, + LogoPath: logoPath, NavItems: h.genNavItems(selected), CsrfToken: csrfToken, Version: version.Version, @@ -115,13 +136,41 @@ func (h handlers) css(c echo.Context) error { c.Response().Header().Set("ETag", h.styleEtag) c.Response().Header().Set("Cache-Control", "public, max-age=3600") // 1 hour in seconds c.Response().Header().Set("Content-Type", "text/css") - return h.Render(c.Response(), c.Param("filename"), nil, c) + return h.Render(c.Response(), c.Param("filename"), h.branding, c) } func (h handlers) index(c echo.Context) error { return c.Redirect(http.StatusTemporaryRedirect, "/devices") } +func (h handlers) brandingAsset(c echo.Context) error { + logo := h.branding.Logo + if logo == "" || c.Param("filename") != logo || filepath.Base(logo) != logo { + return echo.ErrNotFound + } + return c.File(filepath.Join(h.brandingDir, logo)) +} + +func (h handlers) favicon(c echo.Context) error { + // Operator override from disk. Extension + Base already validated at load + // time; Base re-checked here as defense-in-depth (matches brandingAsset). + if f := h.branding.Favicon; f != "" && filepath.Base(f) == f { + p := filepath.Join(h.brandingDir, f) + if _, err := os.Stat(p); err == nil { + return c.File(p) // Content-Type by extension + Last-Modified from disk + } + // configured file missing → fall through to embedded default + } + // Add .ico/legacy links only if a real client needs them — modern browsers + // all honor the SVG. + b, err := templates.Assets.ReadFile("favicon.svg") + if err != nil { + return echo.ErrNotFound + } + c.Response().Header().Set("Cache-Control", "public, max-age=3600") + return c.Blob(http.StatusOK, "image/svg+xml", b) +} + type navItem struct { Title string Href string diff --git a/server/ui/web/templates/base.html b/server/ui/web/templates/base.html index 481b3d6c..bbd8f2e8 100644 --- a/server/ui/web/templates/base.html +++ b/server/ui/web/templates/base.html @@ -7,7 +7,8 @@ <meta name="color-scheme" content="light dark"> {{ if .CsrfToken }}<meta name="csrf-token" content="{{.CsrfToken}}">{{ end }} - <title>{{.Title}} - Update Server + {{.Title}} - {{.BrandName}} + @@ -34,7 +35,8 @@