Files
HODA/internal/icons/fetch.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

272 lines
7.9 KiB
Go

package icons
import (
"bytes"
"context"
"fmt"
"html"
"io"
"net/http"
"net/url"
"regexp"
"slices"
"strconv"
"strings"
"git.mariosem.es/msemes/hoda/internal/apps"
)
type candidate struct {
desc string
fetch func(context.Context) ([]byte, string, error)
}
// candidates lists where to look for an app's icon, best first: the icon
// label, the icon set by app name, then by image name, then the favicon.
func (r *Resolver) candidates(app apps.App) []candidate {
var out []candidate
tried := map[string]bool{}
hint := app.IconHint
if apps.IsWebURL(hint) {
out = append(out, candidate{"icon label " + hint, func(ctx context.Context) ([]byte, string, error) {
return r.image(ctx, r.webHTTP, hint)
}})
} else if name := iconName(hint); name != "" {
out = append(out, r.fromSet(name)...)
tried[name] = true
}
// The name was chosen for the dashboard; the image may be a generic base.
for _, name := range append([]string{slug(app.Name)}, imageNames(app.Image)...) {
if name != "" && !tried[name] {
out = append(out, r.fromSet(name)...)
tried[name] = true
}
}
page := app.InternalURL
if page == "" {
page = app.URL
}
out = append(out, candidate{"favicon of " + page, func(ctx context.Context) ([]byte, string, error) {
return r.favicon(ctx, page)
}})
return out
}
// fromSet looks a name up in the dashboard-icons set, SVG first.
func (r *Resolver) fromSet(name string) []candidate {
if r.cdn == "" {
return nil
}
var out []candidate
for _, format := range []string{"svg", "png"} {
link := r.cdn + "/" + format + "/" + name + "." + format
out = append(out, candidate{"dashboard-icons " + name + "." + format, func(ctx context.Context) ([]byte, string, error) {
return r.image(ctx, r.cdnHTTP, link)
}})
}
return out
}
// favicon reads the icon links of the app's page, largest first, then falls
// back to /favicon.ico.
func (r *Resolver) favicon(ctx context.Context, page string) ([]byte, string, error) {
base, err := url.Parse(page)
if err != nil {
return nil, "", err
}
var links []string
if resp, err := get(ctx, r.webHTTP, page, "text/html,*/*;q=0.8"); err == nil {
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxPageBytes))
resp.Body.Close()
links = iconLinks(body, resp.Request.URL)
}
if ico := base.ResolveReference(&url.URL{Path: "/favicon.ico"}).String(); !slices.Contains(links, ico) {
links = append(links, ico)
}
var lastErr error
for _, link := range links {
data, ctype, err := r.image(ctx, r.webHTTP, link)
if err == nil {
return data, ctype, nil
}
lastErr = err
}
return nil, "", lastErr
}
// image downloads link and checks that it really is an image: a login page
// served with status 200 must not end up as an app's icon.
func (r *Resolver) image(ctx context.Context, client *http.Client, link string) ([]byte, string, error) {
resp, err := get(ctx, client, link, "image/*")
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, maxIconBytes+1))
if err != nil {
return nil, "", err
}
if len(data) > maxIconBytes {
return nil, "", fmt.Errorf("%s: larger than %d KB", link, maxIconBytes>>10)
}
ctype := sniff(data)
if ctype == "" {
return nil, "", fmt.Errorf("%s: not an image", link)
}
return data, ctype, nil
}
func get(ctx context.Context, client *http.Client, link, accept string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", accept)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("%s: %s", link, resp.Status)
}
return resp, nil
}
// sniff returns the image type of data, or "" if it isn't an image.
func sniff(data []byte) string {
if len(data) == 0 {
return ""
}
if t := http.DetectContentType(data); strings.HasPrefix(t, "image/") {
return t
}
// SVG is XML, which DetectContentType reports as text.
head := bytes.ToLower(data[:min(len(data), 1024)])
if bytes.Contains(head, []byte("<svg")) && !bytes.Contains(head, []byte("<html")) {
return "image/svg+xml"
}
return ""
}
var (
linkTag = regexp.MustCompile(`(?is)<link\b[^>]*>`)
tagAttr = regexp.MustCompile(`(?is)([a-z][a-z0-9:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))`)
)
// iconLinks returns the icon links of an HTML page, largest first.
func iconLinks(page []byte, base *url.URL) []string {
type link struct {
href string
size int
}
var found []link
for _, tag := range linkTag.FindAll(page, -1) {
attrs := map[string]string{}
for _, m := range tagAttr.FindAllSubmatch(tag, -1) {
attrs[strings.ToLower(string(m[1]))] = html.UnescapeString(string(m[2]) + string(m[3]) + string(m[4]))
}
rel := strings.Fields(strings.ToLower(attrs["rel"]))
touch := slices.Contains(rel, "apple-touch-icon") || slices.Contains(rel, "apple-touch-icon-precomposed")
if !touch && !slices.Contains(rel, "icon") {
continue
}
href := strings.TrimSpace(attrs["href"])
if href == "" || strings.HasPrefix(href, "data:") {
continue
}
u, err := base.Parse(href)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
continue
}
found = append(found, link{u.String(), iconSize(touch, attrs["sizes"], attrs["type"], u.Path)})
}
slices.SortStableFunc(found, func(a, b link) int { return b.size - a.size })
var out []string
for _, l := range found {
if !slices.Contains(out, l.href) {
out = append(out, l.href)
}
}
return out
}
// iconSize ranks a link by its declared size. SVG scales, so it ranks first.
func iconSize(touch bool, sizes, typ, path string) int {
if strings.Contains(typ, "svg") || strings.HasSuffix(strings.ToLower(path), ".svg") || strings.EqualFold(strings.TrimSpace(sizes), "any") {
return 1 << 16
}
best := 0
for _, s := range strings.Fields(strings.ToLower(sizes)) {
if w, _, ok := strings.Cut(s, "x"); ok {
if n, err := strconv.Atoi(w); err == nil && n > best {
best = n
}
}
}
switch {
case best > 0:
return best
case touch:
return 180
default:
return 32
}
}
// genericImages run many different apps, so their logo says nothing about the app.
var genericImages = map[string]bool{
"alpine": true, "busybox": true, "caddy": true, "debian": true, "eclipse-temurin": true,
"golang": true, "httpd": true, "nginx": true, "nginx-unprivileged": true, "node": true,
"openjdk": true, "php": true, "python": true, "ruby": true, "ubuntu": true,
}
// imageNames turns an image reference into names to look up in the icon set:
// "lscr.io/linuxserver/jellyfin:latest" gives "jellyfin", and
// "ghcr.io/immich-app/immich-server:release" gives "immich-server", then "immich".
func imageNames(image string) []string {
if image == "" || strings.HasPrefix(image, "sha256:") {
return nil
}
ref, _, _ := strings.Cut(image, "@")
name := ref[strings.LastIndex(ref, "/")+1:]
name, _, _ = strings.Cut(name, ":")
if name = slug(name); name == "" || genericImages[name] {
return nil
}
out := []string{name}
for _, suffix := range []string{"-server", "-app", "-web", "-frontend", "-backend"} {
if base, ok := strings.CutSuffix(name, suffix); ok && base != "" {
out = append(out, base)
break
}
}
return out
}
// iconName turns an icon label into an icon set name: "Jellyfin.svg" gives "jellyfin".
func iconName(hint string) string {
lower := strings.ToLower(strings.TrimSpace(hint))
for _, ext := range []string{".svg", ".png", ".webp"} {
lower = strings.TrimSuffix(lower, ext)
}
return slug(lower)
}
// slug lowercases s and joins its letters and digits with dashes: "Uptime Kuma" gives "uptime-kuma".
func slug(s string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(s) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
dash = false
} else if b.Len() > 0 && !dash {
b.WriteByte('-')
dash = true
}
}
return strings.TrimSuffix(b.String(), "-")
}