mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 05:07:09 +00:00
201 lines
4.8 KiB
Go
201 lines
4.8 KiB
Go
package appview
|
|
|
|
import (
|
|
"crypto/md5"
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"atcr.io/pkg/appview/licenses"
|
|
)
|
|
|
|
// assetHashes stores MD5 hashes of embedded assets for cache busting
|
|
var assetHashes = make(map[string]string)
|
|
|
|
func init() {
|
|
// Compute MD5 hash of embedded assets at startup
|
|
files := []string{"css/style.css", "js/bundle.min.js"}
|
|
for _, f := range files {
|
|
data, err := publicFS.ReadFile("public/" + f)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
assetHashes[f] = fmt.Sprintf("%x", md5.Sum(data))[:8]
|
|
}
|
|
}
|
|
|
|
// 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 || echo 'npm not found, skipping build'"
|
|
|
|
//go:embed templates/**/*.html
|
|
var templatesFS embed.FS
|
|
|
|
//go:embed public
|
|
var publicFS embed.FS
|
|
|
|
// Templates returns parsed templates with helper functions
|
|
func Templates() (*template.Template, error) {
|
|
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)
|
|
}
|
|
},
|
|
|
|
"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"
|
|
s = strings.ReplaceAll(s, ":", "-")
|
|
s = strings.ReplaceAll(s, ".", "-")
|
|
return s
|
|
},
|
|
|
|
"parseLicenses": func(licensesStr string) []licenses.LicenseInfo {
|
|
return licenses.ParseLicenses(licensesStr)
|
|
},
|
|
|
|
"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%s", width, parsed.Path)
|
|
return parsed.String()
|
|
},
|
|
|
|
"assetHash": AssetHash,
|
|
}
|
|
|
|
tmpl := template.New("").Funcs(funcMap)
|
|
tmpl, err := tmpl.ParseFS(templatesFS, "templates/**/*.html")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return tmpl, nil
|
|
}
|
|
|
|
// PublicHandler returns HTTP handler for static files
|
|
func PublicHandler() http.Handler {
|
|
sub, err := fs.Sub(publicFS, "public")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return http.FileServer(http.FS(sub))
|
|
}
|
|
|
|
// PublicRootFiles returns list of root-level files in static directory (not subdirectories)
|
|
func PublicRootFiles() ([]string, error) {
|
|
entries, err := publicFS.ReadDir("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 fs.FS for a subdirectory within static/
|
|
func PublicSubdir(name string) http.Handler {
|
|
sub, err := fs.Sub(publicFS, "public/"+name)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return http.FileServer(http.FS(sub))
|
|
}
|