mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
615 lines
17 KiB
Go
615 lines
17 KiB
Go
package appview
|
|
|
|
import (
|
|
"crypto/md5"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"maps"
|
|
"math/rand/v2"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"atcr.io/pkg/appview/licenses"
|
|
)
|
|
|
|
// BrandingOverrides allows consumers to customize the AppView's public assets,
|
|
// templates, CSS, and template functions. Pass nil for default atcr.io behavior.
|
|
type BrandingOverrides struct {
|
|
// PublicFS overlays public/ assets (favicons, CSS, images, etc.).
|
|
// Files in this FS take priority over the embedded defaults.
|
|
PublicFS fs.FS
|
|
|
|
// TemplatesFS overlays templates/ (nav-brand.html, hero.html, etc.).
|
|
// Go's template.ParseFS replaces {{ define "name" }} blocks when
|
|
// called twice with the same name, so consumer templates naturally
|
|
// override defaults.
|
|
TemplatesFS fs.FS
|
|
|
|
// ExtraCSS is injected as a <style> block after the main stylesheet.
|
|
// Useful for DaisyUI color variable overrides without build tooling.
|
|
ExtraCSS string
|
|
|
|
// ExtraFuncMap is merged into the template FuncMap.
|
|
ExtraFuncMap template.FuncMap
|
|
}
|
|
|
|
// assetHashes stores MD5 hashes of embedded assets for cache busting
|
|
var assetHashes = make(map[string]string)
|
|
|
|
func init() {
|
|
// Compute MD5 hash of embedded default assets at startup.
|
|
// Consumers should call ComputeAssetHashes(overrides) to recompute
|
|
// with their overlay FS before serving requests.
|
|
computeAssetHashesFromFS(publicFS)
|
|
}
|
|
|
|
func computeAssetHashesFromFS(fsys fs.FS) {
|
|
files := []string{"css/style.css", "js/bundle.min.js"}
|
|
for _, f := range files {
|
|
var data []byte
|
|
var err error
|
|
|
|
if rfs, ok := fsys.(fs.ReadFileFS); ok {
|
|
data, err = rfs.ReadFile("public/" + f)
|
|
} else {
|
|
fh, openErr := fsys.Open("public/" + f)
|
|
if openErr != nil {
|
|
continue
|
|
}
|
|
defer fh.Close()
|
|
stat, statErr := fh.Stat()
|
|
if statErr != nil {
|
|
continue
|
|
}
|
|
data = make([]byte, stat.Size())
|
|
_, err = fh.(interface{ Read([]byte) (int, error) }).Read(data)
|
|
}
|
|
|
|
if err != nil {
|
|
continue
|
|
}
|
|
assetHashes[f] = fmt.Sprintf("%x", md5.Sum(data))[:8]
|
|
}
|
|
}
|
|
|
|
// ComputeAssetHashes recomputes cache-busting hashes using the overlay FS.
|
|
// Call this before serving requests if using BrandingOverrides.
|
|
func ComputeAssetHashes(overrides *BrandingOverrides) {
|
|
fsys := resolvePublicFS(overrides)
|
|
computeAssetHashesFromFS(fsys)
|
|
}
|
|
|
|
// humanizeCount renders any integer kind with a compact suffix (1.2K, 3.4M,
|
|
// 5.6B). Accepts any so templates can pass int, int64, etc. without an
|
|
// explicit conversion. Non-integer values render as "0".
|
|
func humanizeCount(v any) string {
|
|
var n int64
|
|
switch x := v.(type) {
|
|
case int:
|
|
n = int64(x)
|
|
case int8:
|
|
n = int64(x)
|
|
case int16:
|
|
n = int64(x)
|
|
case int32:
|
|
n = int64(x)
|
|
case int64:
|
|
n = x
|
|
case uint:
|
|
n = int64(x)
|
|
case uint8:
|
|
n = int64(x)
|
|
case uint16:
|
|
n = int64(x)
|
|
case uint32:
|
|
n = int64(x)
|
|
case uint64:
|
|
n = int64(x)
|
|
default:
|
|
return "0"
|
|
}
|
|
neg := n < 0
|
|
if neg {
|
|
n = -n
|
|
}
|
|
var s string
|
|
switch {
|
|
case n < 1000:
|
|
s = fmt.Sprintf("%d", n)
|
|
case n < 1_000_000:
|
|
s = strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1000), ".0") + "K"
|
|
case n < 1_000_000_000:
|
|
s = strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000_000), ".0") + "M"
|
|
default:
|
|
s = strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000_000_000), ".0") + "B"
|
|
}
|
|
if neg {
|
|
return "-" + s
|
|
}
|
|
return s
|
|
}
|
|
|
|
// AssetHash returns the cache-busting hash for an asset path
|
|
func AssetHash(path string) string {
|
|
if hash, ok := assetHashes[path]; ok {
|
|
return hash
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// fontPreloadPaths returns the list of /fonts/*.woff2 files that exist in
|
|
// the resolved public FS. Skips *-ext (extended glyph) subsets to avoid
|
|
// preloading weights the primary latin face already covers, and skips
|
|
// non-canonical weights so the critical preload burst stays focused on
|
|
// the faces needed above the fold. Commit Mono 700 (~47KB) is loaded
|
|
// on demand via @font-face with font-display: swap.
|
|
func fontPreloadPaths(overrides *BrandingOverrides) []string {
|
|
fsys := resolvePublicFS(overrides)
|
|
entries, err := fs.ReadDir(fsys, "public/fonts")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var paths []string
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
if !strings.HasSuffix(name, ".woff2") {
|
|
continue
|
|
}
|
|
if strings.Contains(name, "-ext") {
|
|
continue
|
|
}
|
|
if strings.Contains(name, "italic") {
|
|
continue
|
|
}
|
|
if strings.Contains(name, "-700") {
|
|
continue
|
|
}
|
|
paths = append(paths, "/fonts/"+name)
|
|
}
|
|
return paths
|
|
}
|
|
|
|
// CacheMiddleware adds Cache-Control headers to static file responses
|
|
func CacheMiddleware(h http.Handler, maxAge int) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
|
|
h.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
//go:generate sh -c "command -v npm >/dev/null 2>&1 && cd ../.. && npm run build:appview || echo 'npm not found, skipping build'"
|
|
|
|
//go:embed templates/**/*.html
|
|
var templatesFS embed.FS
|
|
|
|
//go:embed public
|
|
var publicFS embed.FS
|
|
|
|
// resolvePublicFS returns an fs.FS that layers overrides on top of the embedded default.
|
|
func resolvePublicFS(overrides *BrandingOverrides) fs.FS {
|
|
if overrides == nil || overrides.PublicFS == nil {
|
|
return publicFS
|
|
}
|
|
return newOverlayFS(overrides.PublicFS, publicFS)
|
|
}
|
|
|
|
// resolveTemplatesFS returns an fs.FS that layers overrides on top of the embedded default.
|
|
func resolveTemplatesFS(overrides *BrandingOverrides) fs.FS {
|
|
if overrides == nil || overrides.TemplatesFS == nil {
|
|
return templatesFS
|
|
}
|
|
return newOverlayFS(overrides.TemplatesFS, templatesFS)
|
|
}
|
|
|
|
// Templates returns parsed templates with helper functions.
|
|
// Pass nil for default atcr.io behavior.
|
|
func Templates(overrides *BrandingOverrides) (*template.Template, error) {
|
|
extraCSS := ""
|
|
if overrides != nil {
|
|
extraCSS = overrides.ExtraCSS
|
|
}
|
|
|
|
funcMap := template.FuncMap{
|
|
"timeAgo": func(t time.Time) string {
|
|
duration := time.Since(t)
|
|
|
|
if duration < time.Minute {
|
|
return "just now"
|
|
} else if duration < time.Hour {
|
|
mins := int(duration.Minutes())
|
|
if mins == 1 {
|
|
return "1 minute ago"
|
|
}
|
|
return fmt.Sprintf("%d minutes ago", mins)
|
|
} else if duration < 24*time.Hour {
|
|
hours := int(duration.Hours())
|
|
if hours == 1 {
|
|
return "1 hour ago"
|
|
}
|
|
return fmt.Sprintf("%d hours ago", hours)
|
|
} else {
|
|
days := int(duration.Hours() / 24)
|
|
if days == 1 {
|
|
return "1 day ago"
|
|
}
|
|
return fmt.Sprintf("%d days ago", days)
|
|
}
|
|
},
|
|
|
|
"timeAgoShort": func(t time.Time) string {
|
|
duration := time.Since(t)
|
|
|
|
if duration < time.Minute {
|
|
return "now"
|
|
} else if duration < time.Hour {
|
|
return fmt.Sprintf("%dm", int(duration.Minutes()))
|
|
} else if duration < 24*time.Hour {
|
|
return fmt.Sprintf("%dh", int(duration.Hours()))
|
|
} else if duration < 365*24*time.Hour {
|
|
return fmt.Sprintf("%dd", int(duration.Hours()/24))
|
|
} else {
|
|
return fmt.Sprintf("%dy", int(duration.Hours()/(24*365)))
|
|
}
|
|
},
|
|
|
|
"humanizeBytes": func(bytes int64) string {
|
|
const unit = 1024
|
|
if bytes < unit {
|
|
return fmt.Sprintf("%d B", bytes)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for n := bytes / unit; n >= unit; n /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
|
},
|
|
|
|
"truncateDigest": func(digest string, length int) string {
|
|
if len(digest) <= length {
|
|
return digest
|
|
}
|
|
return digest[:length] + "..."
|
|
},
|
|
|
|
"firstChar": func(s string) string {
|
|
if len(s) == 0 {
|
|
return "?"
|
|
}
|
|
return string([]rune(s)[0])
|
|
},
|
|
|
|
"trimPrefix": func(prefix, s string) string {
|
|
if len(s) >= len(prefix) && s[:len(prefix)] == prefix {
|
|
return s[len(prefix):]
|
|
}
|
|
return s
|
|
},
|
|
|
|
"hasPrefix": strings.HasPrefix,
|
|
|
|
"displayHoldDID": func(holdDID string) string {
|
|
// did:web:hold01.atcr.io → hold01.atcr.io
|
|
if after, ok := strings.CutPrefix(holdDID, "did:web:"); ok {
|
|
return after
|
|
}
|
|
// did:plc:opaque... → did:plc:opaque...xxxx (truncated)
|
|
if len(holdDID) > 20 {
|
|
return holdDID[:20] + "…"
|
|
}
|
|
return holdDID
|
|
},
|
|
|
|
"sanitizeID": func(s string) string {
|
|
// Replace special CSS selector characters with dashes
|
|
// e.g., "sha256:abc123" becomes "sha256-abc123"
|
|
// e.g., "v0.0.2" becomes "v0-0-2"
|
|
// e.g., "did:web:172.28.0.3%3A8080" becomes "did-web-172-28-0-3-3A8080"
|
|
s = strings.ReplaceAll(s, ":", "-")
|
|
s = strings.ReplaceAll(s, ".", "-")
|
|
s = strings.ReplaceAll(s, "%", "-")
|
|
return s
|
|
},
|
|
|
|
"parseLicenses": func(licensesStr string) []licenses.LicenseInfo {
|
|
return licenses.ParseLicenses(licensesStr)
|
|
},
|
|
|
|
"derefTime": func(t *time.Time) time.Time {
|
|
if t == nil {
|
|
return time.Time{}
|
|
}
|
|
return *t
|
|
},
|
|
|
|
"sub": func(a, b int) int {
|
|
return a - b
|
|
},
|
|
|
|
"sub64": func(a, b int64) int64 {
|
|
return a - b
|
|
},
|
|
|
|
"absInt": func(n int) int {
|
|
if n < 0 {
|
|
return -n
|
|
}
|
|
return n
|
|
},
|
|
|
|
"humanizeByteDelta": func(bytes int64) string {
|
|
prefix := "+"
|
|
if bytes < 0 {
|
|
prefix = "-"
|
|
bytes = -bytes
|
|
} else if bytes == 0 {
|
|
return "no change"
|
|
}
|
|
const unit = 1024
|
|
if bytes < unit {
|
|
return fmt.Sprintf("%s%d B", prefix, bytes)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for n := bytes / unit; n >= unit; n /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%s%.1f %cB", prefix, float64(bytes)/float64(div), "KMGTPE"[exp])
|
|
},
|
|
|
|
"dict": func(values ...any) map[string]any {
|
|
dict := make(map[string]any, len(values)/2)
|
|
for i := 0; i < len(values); i += 2 {
|
|
key, _ := values[i].(string)
|
|
dict[key] = values[i+1]
|
|
}
|
|
return dict
|
|
},
|
|
|
|
"resizeImage": func(imgURL string, width int) string {
|
|
if imgURL == "" {
|
|
return ""
|
|
}
|
|
// Only apply Cloudflare Image Resizing to imgs.blue URLs
|
|
parsed, err := url.Parse(imgURL)
|
|
if err != nil || parsed.Host != "imgs.blue" {
|
|
return imgURL
|
|
}
|
|
// Cloudflare uses /cdn-cgi/image/width=X/ path format
|
|
parsed.Path = fmt.Sprintf("/cdn-cgi/image/width=%d,format=auto%s", width, parsed.Path)
|
|
return parsed.String()
|
|
},
|
|
|
|
"assetHash": AssetHash,
|
|
|
|
// seamarkHeroTagline picks a random Seamark hero tagline on the
|
|
// server so the Seamark hero renders with its final copy on first
|
|
// paint (crawlers get a real tagline; users see no JS flash). The
|
|
// prior approach rewrote innerHTML ~50ms after load, which caused
|
|
// a visible flash and always served "your beacon at sea." to bots.
|
|
"seamarkHeroTagline": func() []string {
|
|
options := [][]string{
|
|
{"guiding you ", "at", " sea."},
|
|
{"your beacon ", "at", " sea."},
|
|
{"never lost ", "at", " sea."},
|
|
{"find your way ", "at", " sea."},
|
|
{"charting courses ", "at", " sea."},
|
|
}
|
|
return options[rand.IntN(len(options))]
|
|
},
|
|
|
|
// fontPreloads returns the list of woff2 fonts present in /fonts/
|
|
// so head.html can emit <link rel="preload"> tags without hardcoding
|
|
// filenames (which 404 silently when renamed). The *-ext subset
|
|
// variants are skipped — we only preload the primary-latin subsets
|
|
// that are used above the fold.
|
|
"fontPreloads": func() []string {
|
|
return fontPreloadPaths(overrides)
|
|
},
|
|
|
|
"formatDate": func(t time.Time) string {
|
|
return t.Format("Jan 2, 2006")
|
|
},
|
|
|
|
"isZeroTime": func(t time.Time) bool {
|
|
return t.IsZero() || t.Year() < 2000
|
|
},
|
|
|
|
// pluralize picks singular/plural based on count. Use whenever a
|
|
// template would otherwise write "{{ if gt .N 1 }}s{{ end }}", which
|
|
// breaks for 0, negatives, and anything non-English.
|
|
// Usage: {{ pluralize .Count "package" "packages" }}
|
|
"pluralize": func(n int, singular, plural string) string {
|
|
if n == 1 || n == -1 {
|
|
return singular
|
|
}
|
|
return plural
|
|
},
|
|
|
|
// humanizeTime renders an absolute, human-readable timestamp.
|
|
// Distinct from timeAgo (relative) — use for tooltips and places
|
|
// where a stable formatted date is preferable to "3 days ago".
|
|
// Zero times render as empty to avoid "Jan 1, 0001" leakage.
|
|
"humanizeTime": func(t time.Time) string {
|
|
if t.IsZero() || t.Year() < 2000 {
|
|
return ""
|
|
}
|
|
return t.Format("Jan 2, 2006 at 3:04 PM MST")
|
|
},
|
|
|
|
// humanizeCount renders integers with compact suffix (1.2K, 3.4M, 5.6B).
|
|
// Numbers below 1000 render as-is. Negative values are prefixed with
|
|
// a minus sign (rare for counts but correctness beats surprise).
|
|
// Accepts any integer kind via reflect so templates can pass `int`,
|
|
// `int64`, etc. without an explicit conversion helper.
|
|
"humanizeCount": humanizeCount,
|
|
|
|
// severityLabel maps a severity code (C/H/M/L/N/U or a full name) to
|
|
// its canonical full-word label. Use alongside the color class so
|
|
// screen readers announce the word even when sighted users see only
|
|
// the initial: <span class="sr-only">{{ severityLabel "C" }}</span>C
|
|
"severityLabel": func(code string) string {
|
|
switch strings.ToUpper(strings.TrimSpace(code)) {
|
|
case "C", "CRIT", "CRITICAL":
|
|
return "Critical"
|
|
case "H", "HIGH":
|
|
return "High"
|
|
case "M", "MED", "MEDIUM":
|
|
return "Medium"
|
|
case "L", "LOW":
|
|
return "Low"
|
|
case "N", "NEG", "NEGLIGIBLE":
|
|
return "Negligible"
|
|
case "U", "UNK", "UNKNOWN":
|
|
return "Unknown"
|
|
default:
|
|
return code
|
|
}
|
|
},
|
|
|
|
// icon renders an SVG icon from the sprite sheet
|
|
// Usage: {{ icon "star" "size-4 text-amber-400" }}
|
|
// The name is the icon ID in icons.svg, classes are applied to the SVG element
|
|
"icon": func(name, classes string) template.HTML {
|
|
return template.HTML(fmt.Sprintf(
|
|
`<svg class="icon %s" aria-hidden="true"><use href="/icons.svg#%s"></use></svg>`,
|
|
template.HTMLEscapeString(classes),
|
|
template.HTMLEscapeString(name),
|
|
))
|
|
},
|
|
|
|
// jsonldScript renders a complete <script type="application/ld+json"> block.
|
|
// Returns the whole block as template.HTML to avoid html/template's JS context
|
|
// escaping that double-encodes JSON inside <script> tags.
|
|
// See https://github.com/golang/go/issues/20886
|
|
// Usage: {{ jsonldScript .SomeStruct }}
|
|
"jsonldScript": func(v any) template.HTML {
|
|
var jsonBytes []byte
|
|
if s, ok := v.(string); ok {
|
|
jsonBytes = []byte(s)
|
|
} else {
|
|
var err error
|
|
jsonBytes, err = json.MarshalIndent(v, " ", " ")
|
|
if err != nil {
|
|
jsonBytes = []byte("{}")
|
|
}
|
|
}
|
|
return template.HTML("<script type=\"application/ld+json\">\n " + string(jsonBytes) + "\n </script>")
|
|
},
|
|
|
|
// ociClientName returns the OCI client name, defaulting to "docker" if empty.
|
|
// Usage: {{ ociClientName .OciClient }}
|
|
"ociClientName": func(client string) string {
|
|
if client == "" {
|
|
return "docker"
|
|
}
|
|
return client
|
|
},
|
|
|
|
// pullPrefix returns the "<client> pull " prefix for a pull command, or an
|
|
// empty string when the user has selected "none" (image reference only).
|
|
// Usage: {{ pullPrefix .OciClient }}
|
|
"pullPrefix": func(client string) string {
|
|
if client == "none" {
|
|
return ""
|
|
}
|
|
if client == "" {
|
|
return "docker pull "
|
|
}
|
|
return client + " pull "
|
|
},
|
|
|
|
// toJSON marshals any value to a JSON string safe for use in HTML attributes.
|
|
// json.Marshal escapes <, >, & and properly escapes " inside strings,
|
|
// so the result can be used as template.HTML without further escaping.
|
|
// Usage: hx-vals='{{ dict "repo" .Repo "tag" .Tag | toJSON }}'
|
|
"toJSON": func(v any) template.HTML {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return template.HTML("{}")
|
|
}
|
|
return template.HTML(b)
|
|
},
|
|
|
|
// extraCSS returns a <style> block with consumer CSS overrides, or empty string.
|
|
"extraCSS": func() template.HTML {
|
|
if extraCSS == "" {
|
|
return ""
|
|
}
|
|
return template.HTML("<style>" + extraCSS + "</style>")
|
|
},
|
|
}
|
|
|
|
// Merge extra func map from overrides
|
|
if overrides != nil && overrides.ExtraFuncMap != nil {
|
|
maps.Copy(funcMap, overrides.ExtraFuncMap)
|
|
}
|
|
|
|
tmpl := template.New("").Funcs(funcMap)
|
|
|
|
// Parse default templates
|
|
tfs := resolveTemplatesFS(overrides)
|
|
tmpl, err := tmpl.ParseFS(tfs, "templates/**/*.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return tmpl, nil
|
|
}
|
|
|
|
// PublicHandler returns HTTP handler for static files.
|
|
// Pass nil for default atcr.io behavior.
|
|
func PublicHandler(overrides *BrandingOverrides) http.Handler {
|
|
fsys := resolvePublicFS(overrides)
|
|
sub, err := fs.Sub(fsys, "public")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return http.FileServer(http.FS(sub))
|
|
}
|
|
|
|
// PublicRootFiles returns list of root-level files in static directory (not subdirectories).
|
|
// Pass nil for default atcr.io behavior.
|
|
func PublicRootFiles(overrides *BrandingOverrides) ([]string, error) {
|
|
fsys := resolvePublicFS(overrides)
|
|
var entries []fs.DirEntry
|
|
var err error
|
|
|
|
if rdfs, ok := fsys.(fs.ReadDirFS); ok {
|
|
entries, err = rdfs.ReadDir("public")
|
|
} else {
|
|
entries, err = fs.ReadDir(fsys, "public")
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var files []string
|
|
for _, entry := range entries {
|
|
// Only include files, not directories
|
|
if !entry.IsDir() {
|
|
files = append(files, entry.Name())
|
|
}
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
// PublicSubdir returns an http.Handler for a subdirectory within public/.
|
|
// Pass nil for default atcr.io behavior.
|
|
func PublicSubdir(name string, overrides *BrandingOverrides) http.Handler {
|
|
fsys := resolvePublicFS(overrides)
|
|
sub, err := fs.Sub(fsys, "public/"+name)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return http.FileServer(http.FS(sub))
|
|
}
|