CI / build (push) Successful in 30s
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>
330 lines
8.0 KiB
Go
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
|
|
}
|