Files
HODA/internal/icons/icons.go
T
TMCandClaude Opus 5.5 9d6a921c34
CI / build (push) Successful in 30s
Start HODA: a homelab dashboard built from Docker labels
Apps opt in with sema.dashboard.* labels in their own compose file. The
primary reads its host's containers through a read-only socket proxy,
follows Docker events, and merges in what the agent on the second host
reports. Icons come from the icon label, the dashboard-icons set or the
app's favicon, with a letter as the fallback.

Go with only the standard library, a distroless image, and a Gitea
Actions workflow copied from FiMa's: checks in the Dockerfile's check
stage, a Trivy scan, and a push to git.mariosem.es/msemes/hoda.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:18:20 +01:00

330 lines
8.0 KiB
Go

// Package icons finds an icon for each app and keeps them cached on disk. It
// tries, in order: the icon label, the dashboard-icons set by image name and
// app name, then the app's own favicon. Apps with no icon get a letter in the UI.
package icons
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"git.mariosem.es/msemes/hoda/internal/apps"
)
const (
maxIconBytes = 512 << 10
maxPageBytes = 1 << 20
fetchTimeout = 3 * time.Second
missRetry = time.Hour // an app that had no icon may simply have been down
workers = 4
indexFile = "index.json"
userAgent = "Mozilla/5.0 (compatible; HODA; +https://git.mariosem.es/msemes/HODA)"
)
// Icon is a cached image, ready to serve.
type Icon struct {
Data []byte
ContentType string
Version string
}
type entry struct {
Key string `json:"key"` // hash of the inputs; a change triggers a new lookup
Source string `json:"source,omitempty"`
ContentType string `json:"contentType,omitempty"`
Version string `json:"version,omitempty"` // hash of the image; empty when none was found
Checked time.Time `json:"checked"`
data []byte
}
// Resolver looks icons up in the background. URL and Lookup never wait on the network.
type Resolver struct {
dir string
cdn string
refresh time.Duration
log *slog.Logger
cdnHTTP *http.Client
webHTTP *http.Client
mu sync.RWMutex
entries map[string]*entry
queued map[string]bool
queue chan apps.App
saveMu sync.Mutex
}
// New returns a resolver that caches under dir; an empty dir keeps icons in
// memory only. cdn is the dashboard-icons base URL; empty skips the icon set.
func New(dir, cdn string, refresh time.Duration, log *slog.Logger) *Resolver {
r := &Resolver{
dir: dir,
cdn: strings.TrimSuffix(cdn, "/"),
refresh: refresh,
log: log,
cdnHTTP: &http.Client{Timeout: fetchTimeout},
webHTTP: &http.Client{
Timeout: fetchTimeout,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
// Homelab apps often run on self-signed certificates, and only images are read.
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
// A redirect to another host is a login page (Pangolin's SSO, for one), not the app.
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("too many redirects")
}
if req.URL.Host != via[0].URL.Host {
return fmt.Errorf("redirected to %s, likely a login page", req.URL.Host)
}
return nil
},
},
entries: map[string]*entry{},
queued: map[string]bool{},
queue: make(chan apps.App, 256),
}
r.load()
return r
}
// Run resolves queued apps until ctx ends.
func (r *Resolver) Run(ctx context.Context) {
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case app := <-r.queue:
r.resolve(ctx, app)
}
}
}()
}
wg.Wait()
}
// Ensure queues a lookup for every app whose icon is missing, stale or based
// on labels that changed, and forgets the icons of apps that are gone.
func (r *Resolver) Ensure(list []apps.App) {
now := time.Now()
present := make(map[string]bool, len(list))
var todo []apps.App
var gone []string
r.mu.Lock()
for _, app := range list {
present[app.ID] = true
e := r.entries[app.ID]
if r.queued[app.ID] || (e != nil && e.Key == inputKey(app) && !r.stale(e, now)) {
continue
}
r.queued[app.ID] = true
todo = append(todo, app)
}
for id := range r.entries {
if !present[id] {
delete(r.entries, id)
gone = append(gone, id)
}
}
r.mu.Unlock()
if len(gone) > 0 {
r.forget(gone)
}
for _, app := range todo {
select {
case r.queue <- app:
default: // queue full: the next sync queues it again
r.mu.Lock()
delete(r.queued, app.ID)
r.mu.Unlock()
}
}
}
// URL is the path the browser loads an app's icon from, or "" for a letter.
// The version in it lets browsers cache the icon until it changes.
func (r *Resolver) URL(id string) string {
r.mu.RLock()
defer r.mu.RUnlock()
if e := r.entries[id]; e != nil && e.Version != "" {
return "/icons/" + id + "?v=" + e.Version
}
return ""
}
// Lookup returns an app's cached icon.
func (r *Resolver) Lookup(id string) (Icon, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
e := r.entries[id]
if e == nil || e.Version == "" {
return Icon{}, false
}
return Icon{Data: e.data, ContentType: e.ContentType, Version: e.Version}, true
}
func (r *Resolver) stale(e *entry, now time.Time) bool {
ttl := r.refresh
if e.Version == "" {
ttl = min(ttl, missRetry)
}
return now.Sub(e.Checked) > ttl
}
func (r *Resolver) resolve(ctx context.Context, app apps.App) {
e := &entry{Key: inputKey(app), Checked: time.Now()}
for _, c := range r.candidates(app) {
data, ctype, err := c.fetch(ctx)
if err != nil {
r.log.Debug("icon candidate failed", "app", app.Name, "candidate", c.desc, "err", err)
continue
}
sum := sha256.Sum256(data)
e.Source, e.ContentType, e.Version, e.data = c.desc, ctype, hex.EncodeToString(sum[:6]), data
break
}
r.mu.Lock()
prev := r.entries[app.ID]
delete(r.queued, app.ID)
r.entries[app.ID] = e
r.mu.Unlock()
level := slog.LevelDebug
if prev == nil || prev.Key != e.Key || prev.Version != e.Version {
level = slog.LevelInfo
}
if e.Version != "" {
r.log.Log(ctx, level, "icon found", "app", app.Name, "source", e.Source)
} else {
r.log.Log(ctx, level, "no icon found, showing a letter", "app", app.Name)
}
r.persist(app.ID, e)
}
// inputKey hashes everything that decides which icon an app gets.
func inputKey(app apps.App) string {
sum := sha256.Sum256([]byte(strings.Join([]string{app.IconHint, app.Image, app.Name, app.URL, app.InternalURL}, "\x00")))
return hex.EncodeToString(sum[:8])
}
func (r *Resolver) load() {
if r.dir == "" {
return
}
if err := os.MkdirAll(r.dir, 0o755); err != nil {
r.log.Warn("icon cache disabled, keeping icons in memory", "dir", r.dir, "err", err)
r.dir = ""
return
}
raw, err := os.ReadFile(filepath.Join(r.dir, indexFile))
if err != nil {
return // first start
}
var index map[string]*entry
if err := json.Unmarshal(raw, &index); err != nil {
r.log.Warn("ignoring an unreadable icon index", "err", err)
return
}
for id, e := range index {
if !apps.ValidID(id) || e == nil {
continue
}
if e.Version != "" {
if e.data, err = os.ReadFile(filepath.Join(r.dir, id)); err != nil {
continue
}
}
r.entries[id] = e
}
}
func (r *Resolver) persist(id string, e *entry) {
if r.dir == "" {
return
}
r.saveMu.Lock()
defer r.saveMu.Unlock()
path := filepath.Join(r.dir, id)
var err error
if e.Version != "" {
err = writeAtomic(path, e.data)
} else {
err = removeIfExists(path)
}
if err == nil {
err = r.saveIndex()
}
if err != nil {
r.log.Warn("writing the icon cache failed", "err", err)
}
}
func (r *Resolver) forget(ids []string) {
if r.dir == "" {
return
}
r.saveMu.Lock()
defer r.saveMu.Unlock()
for _, id := range ids {
if err := removeIfExists(filepath.Join(r.dir, id)); err != nil {
r.log.Warn("removing a cached icon failed", "err", err)
}
}
if err := r.saveIndex(); err != nil {
r.log.Warn("writing the icon cache failed", "err", err)
}
}
// saveIndex writes the index; the caller holds saveMu.
func (r *Resolver) saveIndex() error {
r.mu.RLock()
raw, err := json.MarshalIndent(r.entries, "", " ")
r.mu.RUnlock()
if err != nil {
return err
}
return writeAtomic(filepath.Join(r.dir, indexFile), raw)
}
func writeAtomic(path string, data []byte) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
func removeIfExists(path string) error {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}