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>
272 lines
7.9 KiB
Go
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(), "-")
|
|
}
|