Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion server/ui/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
85 changes: 85 additions & 0 deletions server/ui/web/branding.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (c) Qualcomm Technologies, Inc. and/or its subsidiaries.
// SPDX-License-Identifier: BSD-3-Clause-Clear

package web

import (
"encoding/json"
"log/slog"
"regexp"
)

// 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/-]+$`)

// 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 + <title> suffix
Logo string // filename under <data-dir>/branding/, "" = text brand only
Primary string // --brand-primary
Accent string // --brand-accent
Surface string // --surface-1 (page background)
SurfaceAlt string // --surface-2 (content background)
Text string // --text-1
}

// brandingFile is the on-disk JSON shape. Pointers distinguish "absent" (keep
// default) from "explicitly set".
type brandingFile struct {
Title *string `json:"title"`
Logo *string `json:"logo"`
Colors struct {
Primary *string `json:"primary"`
Accent *string `json:"accent"`
Surface *string `json:"surface"`
SurfaceAlt *string `json:"surface-alt"`
Text *string `json:"text"`
} `json:"colors"`
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I get the value of having two different structs? I think I'd stick with just the brandingFile idea but get rid of the pointers do length == 0 checks instead checks for nil.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pointers are overkill, yeah. I'll switch to plain strings and drop the nil checks.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding the two structs, you mean brandingFile and Branding?

In that case I think it's still worth keeping them both, as they serve different purposes.

brandingFile mirrors the raw JSON of the branding.json file used to customize the UI colors.

Branding is the flat version, template-friendly with defaults applied: this is guaranteed to be populated and valid via LoadBranding.

Staying only with brandingFile would lead to move the "default value resolution/validation" logic into the templates, and we might have to duplicate it in multiple places.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking more of having something like an "applyDefaults" method added to the brandingFile struct. Then pass that to the templates and everything would be in place for it?

If that approach looks bad - stick with this. I'm fine either way. Merge at your leisure.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only reasons I still prefer the two structs approach is that the only way to get the branding is via calling that LoadBranding func. With applyDefaults and a single struct there would be less code, but there's nothing stopping a caller to forget to call it, handing down to a template a struct with non-default values.

I'll keep it as is for now, but we can rework it later if it feels too much.


func defaultBranding() Branding {
return Branding{
Title: "Foundries Update Server",
Logo: "",
Primary: "rgb(2, 11, 64)",
Accent: "#a3b4ff",
Surface: "#f2f2f2",
SurfaceAlt: "#ffffff",
Text: "#000000",
}
}

// 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 != nil {
*dst = *src
}
}
setColor := func(dst *string, src *string) {
if src != nil && cssColor.MatchString(*src) {
*dst = *src
}
}
set(&b.Title, f.Title)
set(&b.Logo, f.Logo)
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)
return b
}
51 changes: 51 additions & 0 deletions server/ui/web/branding_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// 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)
}
}
49 changes: 38 additions & 11 deletions server/ui/web/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
package web

import (
"bytes"
"crypto/md5"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"path/filepath"

"github.com/labstack/echo/v4"

Expand All @@ -20,27 +23,35 @@ 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()))
Comment thread
doanac marked this conversation as resolved.
Dismissed

e.Renderer = h

e.GET("/", h.index, h.requireSession)
e.GET("/css/:filename", h.css)
e.GET("/branding/:filename", h.brandingAsset)
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))
Expand Down Expand Up @@ -85,6 +96,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
Expand All @@ -95,9 +108,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,
Expand All @@ -115,13 +134,21 @@ 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))
}

type navItem struct {
Title string
Href string
Expand Down
5 changes: 3 additions & 2 deletions server/ui/web/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<meta name="color-scheme" content="light dark">
{{ if .CsrfToken }}<meta name="csrf-token" content="{{.CsrfToken}}">{{ end }}

<title>{{.Title}} - Update Server</title>
<title>{{.Title}} - {{.BrandName}}</title>

<link rel="stylesheet" href="/css/pico_min_211.css">
<link rel="stylesheet" href="/css/style.css">
Expand All @@ -34,7 +34,8 @@
<body>
<nav id="topbar">
<div class="container">
<strong class="brand">Foundries Update Server</strong>
{{ if .LogoPath }}<img src="{{.LogoPath}}" alt="{{.BrandName}}" class="brand-logo">{{ end }}
<strong class="brand">{{.BrandName}}</strong>

<div style="display: flex; align-items: center; gap: 1rem;">
<a href="https://github.com/foundriesio/update-server/blob/main/README.md" style="color: white; text-decoration: none;">Docs</a>
Expand Down
38 changes: 27 additions & 11 deletions server/ui/web/templates/style.css
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
:root {
color-scheme: light dark;
font-size: 15px;
--bg-body: #f2f2f2;
--bg-content: white;
--text-dark: black;

/* Brand tokens — the operator-customizable design system (colors only) */
--brand-primary: {{ .Primary }};
--brand-accent: {{ .Accent }};
--surface-1: {{ .Surface }};
--surface-2: {{ .SurfaceAlt }};
--text-1: {{ .Text }};

/* Bridge tokens into Pico so its components inherit the brand */
--pico-primary: var(--brand-primary);
--pico-primary-hover: var(--brand-accent);

/* Back-compat aliases for existing rules below */
--bg-body: var(--surface-1);
--bg-content: var(--surface-2);
--text-dark: var(--text-1);
}

html, body {
Expand All @@ -14,7 +27,7 @@ html, body {
}

nav#topbar {
background-color: rgb(2, 11, 64);
background-color: var(--brand-primary);
padding: 0.75rem 0;
}

Expand All @@ -32,14 +45,16 @@ nav#topbar .container {
color: white;
}

#topbar .brand-logo { height: 1.8rem; width: auto; margin-right: 0.5rem; vertical-align: middle; }

nav#topbar a,
nav#topbar summary {
color: white;
}

nav#topbar a:hover,
nav#topbar summary:hover {
color: #d0d8ff;
color: var(--brand-accent);
}

/* User dropdown */
Expand All @@ -63,7 +78,7 @@ nav#subnav .container {
}

nav#subnav a {
color: rgb(2, 11, 64);
color: var(--brand-primary);
font-size: 1.2rem;
text-decoration: none !important; /* remove underline */
padding-bottom: 2px;
Expand All @@ -72,11 +87,11 @@ nav#subnav a {

nav#subnav a:hover {
text-decoration: none !important;
border-bottom-color: rgb(2, 11, 64);
border-bottom-color: var(--brand-primary);
}

nav#subnav a.selected {
border-bottom: 2px solid rgb(2, 11, 64);
border-bottom: 2px solid var(--brand-primary);
}

th.sortable a {
Expand Down Expand Up @@ -355,6 +370,7 @@ dialog.file-content > article > section > ul {
min-width: 20em;
}

/* ponytail: dark-mode kept on fixed values for v1; tokenize in the redesign (step 2) */
@media (prefers-color-scheme: dark) {
:root {
--bg-body: #11191f;
Expand All @@ -363,13 +379,13 @@ dialog.file-content > article > section > ul {
}

nav#subnav a {
color: #a3b4ff;
color: var(--brand-accent);
}
nav#subnav a:hover {
border-bottom-color: #a3b4ff;
border-bottom-color: var(--brand-accent);
}
nav#subnav a.selected {
border-bottom-color: #a3b4ff;
border-bottom-color: var(--brand-accent);
}

.content-section {
Expand Down
Loading