Files
2026-03-29 21:42:55 -07:00

407 lines
11 KiB
Go

package appview
import (
"crypto/md5"
"embed"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"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)
}
// AssetHash returns the cache-busting hash for an asset path
func AssetHash(path string) string {
if hash, ok := assetHashes[path]; ok {
return hash
}
return ""
}
// 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
},
"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)
},
"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,
"formatDate": func(t time.Time) string {
return t.Format("Jan 2, 2006")
},
"isZeroTime": func(t time.Time) bool {
return t.IsZero() || t.Year() < 2000
},
// 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
},
// 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 {
for k, v := range overrides.ExtraFuncMap {
funcMap[k] = v
}
}
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))
}