A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at label-service 362 lines 10 kB view raw
1package appview 2 3import ( 4 "crypto/md5" 5 "embed" 6 "encoding/json" 7 "fmt" 8 "html/template" 9 "io/fs" 10 "net/http" 11 "net/url" 12 "strings" 13 "time" 14 15 "atcr.io/pkg/appview/licenses" 16) 17 18// BrandingOverrides allows consumers to customize the AppView's public assets, 19// templates, CSS, and template functions. Pass nil for default atcr.io behavior. 20type BrandingOverrides struct { 21 // PublicFS overlays public/ assets (favicons, CSS, images, etc.). 22 // Files in this FS take priority over the embedded defaults. 23 PublicFS fs.FS 24 25 // TemplatesFS overlays templates/ (nav-brand.html, hero.html, etc.). 26 // Go's template.ParseFS replaces {{ define "name" }} blocks when 27 // called twice with the same name, so consumer templates naturally 28 // override defaults. 29 TemplatesFS fs.FS 30 31 // ExtraCSS is injected as a <style> block after the main stylesheet. 32 // Useful for DaisyUI color variable overrides without build tooling. 33 ExtraCSS string 34 35 // ExtraFuncMap is merged into the template FuncMap. 36 ExtraFuncMap template.FuncMap 37} 38 39// assetHashes stores MD5 hashes of embedded assets for cache busting 40var assetHashes = make(map[string]string) 41 42func init() { 43 // Compute MD5 hash of embedded default assets at startup. 44 // Consumers should call ComputeAssetHashes(overrides) to recompute 45 // with their overlay FS before serving requests. 46 computeAssetHashesFromFS(publicFS) 47} 48 49func computeAssetHashesFromFS(fsys fs.FS) { 50 files := []string{"css/style.css", "js/bundle.min.js"} 51 for _, f := range files { 52 var data []byte 53 var err error 54 55 if rfs, ok := fsys.(fs.ReadFileFS); ok { 56 data, err = rfs.ReadFile("public/" + f) 57 } else { 58 fh, openErr := fsys.Open("public/" + f) 59 if openErr != nil { 60 continue 61 } 62 defer fh.Close() 63 stat, statErr := fh.Stat() 64 if statErr != nil { 65 continue 66 } 67 data = make([]byte, stat.Size()) 68 _, err = fh.(interface{ Read([]byte) (int, error) }).Read(data) 69 } 70 71 if err != nil { 72 continue 73 } 74 assetHashes[f] = fmt.Sprintf("%x", md5.Sum(data))[:8] 75 } 76} 77 78// ComputeAssetHashes recomputes cache-busting hashes using the overlay FS. 79// Call this before serving requests if using BrandingOverrides. 80func ComputeAssetHashes(overrides *BrandingOverrides) { 81 fsys := resolvePublicFS(overrides) 82 computeAssetHashesFromFS(fsys) 83} 84 85// AssetHash returns the cache-busting hash for an asset path 86func AssetHash(path string) string { 87 if hash, ok := assetHashes[path]; ok { 88 return hash 89 } 90 return "" 91} 92 93// CacheMiddleware adds Cache-Control headers to static file responses 94func CacheMiddleware(h http.Handler, maxAge int) http.Handler { 95 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 96 w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge)) 97 h.ServeHTTP(w, r) 98 }) 99} 100 101//go:generate sh -c "command -v npm >/dev/null 2>&1 && cd ../.. && npm run build:appview || echo 'npm not found, skipping build'" 102 103//go:embed templates/**/*.html 104var templatesFS embed.FS 105 106//go:embed public 107var publicFS embed.FS 108 109// resolvePublicFS returns an fs.FS that layers overrides on top of the embedded default. 110func resolvePublicFS(overrides *BrandingOverrides) fs.FS { 111 if overrides == nil || overrides.PublicFS == nil { 112 return publicFS 113 } 114 return newOverlayFS(overrides.PublicFS, publicFS) 115} 116 117// resolveTemplatesFS returns an fs.FS that layers overrides on top of the embedded default. 118func resolveTemplatesFS(overrides *BrandingOverrides) fs.FS { 119 if overrides == nil || overrides.TemplatesFS == nil { 120 return templatesFS 121 } 122 return newOverlayFS(overrides.TemplatesFS, templatesFS) 123} 124 125// Templates returns parsed templates with helper functions. 126// Pass nil for default atcr.io behavior. 127func Templates(overrides *BrandingOverrides) (*template.Template, error) { 128 extraCSS := "" 129 if overrides != nil { 130 extraCSS = overrides.ExtraCSS 131 } 132 133 funcMap := template.FuncMap{ 134 "timeAgo": func(t time.Time) string { 135 duration := time.Since(t) 136 137 if duration < time.Minute { 138 return "just now" 139 } else if duration < time.Hour { 140 mins := int(duration.Minutes()) 141 if mins == 1 { 142 return "1 minute ago" 143 } 144 return fmt.Sprintf("%d minutes ago", mins) 145 } else if duration < 24*time.Hour { 146 hours := int(duration.Hours()) 147 if hours == 1 { 148 return "1 hour ago" 149 } 150 return fmt.Sprintf("%d hours ago", hours) 151 } else { 152 days := int(duration.Hours() / 24) 153 if days == 1 { 154 return "1 day ago" 155 } 156 return fmt.Sprintf("%d days ago", days) 157 } 158 }, 159 160 "timeAgoShort": func(t time.Time) string { 161 duration := time.Since(t) 162 163 if duration < time.Minute { 164 return "now" 165 } else if duration < time.Hour { 166 return fmt.Sprintf("%dm", int(duration.Minutes())) 167 } else if duration < 24*time.Hour { 168 return fmt.Sprintf("%dh", int(duration.Hours())) 169 } else if duration < 365*24*time.Hour { 170 return fmt.Sprintf("%dd", int(duration.Hours()/24)) 171 } else { 172 return fmt.Sprintf("%dy", int(duration.Hours()/(24*365))) 173 } 174 }, 175 176 "humanizeBytes": func(bytes int64) string { 177 const unit = 1024 178 if bytes < unit { 179 return fmt.Sprintf("%d B", bytes) 180 } 181 div, exp := int64(unit), 0 182 for n := bytes / unit; n >= unit; n /= unit { 183 div *= unit 184 exp++ 185 } 186 return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) 187 }, 188 189 "truncateDigest": func(digest string, length int) string { 190 if len(digest) <= length { 191 return digest 192 } 193 return digest[:length] + "..." 194 }, 195 196 "firstChar": func(s string) string { 197 if len(s) == 0 { 198 return "?" 199 } 200 return string([]rune(s)[0]) 201 }, 202 203 "trimPrefix": func(prefix, s string) string { 204 if len(s) >= len(prefix) && s[:len(prefix)] == prefix { 205 return s[len(prefix):] 206 } 207 return s 208 }, 209 210 "sanitizeID": func(s string) string { 211 // Replace special CSS selector characters with dashes 212 // e.g., "sha256:abc123" becomes "sha256-abc123" 213 // e.g., "v0.0.2" becomes "v0-0-2" 214 // e.g., "did:web:172.28.0.3%3A8080" becomes "did-web-172-28-0-3-3A8080" 215 s = strings.ReplaceAll(s, ":", "-") 216 s = strings.ReplaceAll(s, ".", "-") 217 s = strings.ReplaceAll(s, "%", "-") 218 return s 219 }, 220 221 "parseLicenses": func(licensesStr string) []licenses.LicenseInfo { 222 return licenses.ParseLicenses(licensesStr) 223 }, 224 225 "dict": func(values ...any) map[string]any { 226 dict := make(map[string]any, len(values)/2) 227 for i := 0; i < len(values); i += 2 { 228 key, _ := values[i].(string) 229 dict[key] = values[i+1] 230 } 231 return dict 232 }, 233 234 "resizeImage": func(imgURL string, width int) string { 235 if imgURL == "" { 236 return "" 237 } 238 // Only apply Cloudflare Image Resizing to imgs.blue URLs 239 parsed, err := url.Parse(imgURL) 240 if err != nil || parsed.Host != "imgs.blue" { 241 return imgURL 242 } 243 // Cloudflare uses /cdn-cgi/image/width=X/ path format 244 parsed.Path = fmt.Sprintf("/cdn-cgi/image/width=%d,format=auto%s", width, parsed.Path) 245 return parsed.String() 246 }, 247 248 "assetHash": AssetHash, 249 250 "formatDate": func(t time.Time) string { 251 return t.Format("Jan 2, 2006") 252 }, 253 254 "isZeroTime": func(t time.Time) bool { 255 return t.IsZero() || t.Year() < 2000 256 }, 257 258 // icon renders an SVG icon from the sprite sheet 259 // Usage: {{ icon "star" "size-4 text-amber-400" }} 260 // The name is the icon ID in icons.svg, classes are applied to the SVG element 261 "icon": func(name, classes string) template.HTML { 262 return template.HTML(fmt.Sprintf( 263 `<svg class="icon %s" aria-hidden="true"><use href="/icons.svg#%s"></use></svg>`, 264 template.HTMLEscapeString(classes), 265 template.HTMLEscapeString(name), 266 )) 267 }, 268 269 // jsonldScript renders a complete <script type="application/ld+json"> block. 270 // Returns the whole block as template.HTML to avoid html/template's JS context 271 // escaping that double-encodes JSON inside <script> tags. 272 // See https://github.com/golang/go/issues/20886 273 // Usage: {{ jsonldScript .SomeStruct }} 274 "jsonldScript": func(v any) template.HTML { 275 var jsonBytes []byte 276 if s, ok := v.(string); ok { 277 jsonBytes = []byte(s) 278 } else { 279 var err error 280 jsonBytes, err = json.MarshalIndent(v, " ", " ") 281 if err != nil { 282 jsonBytes = []byte("{}") 283 } 284 } 285 return template.HTML("<script type=\"application/ld+json\">\n " + string(jsonBytes) + "\n </script>") 286 }, 287 288 // extraCSS returns a <style> block with consumer CSS overrides, or empty string. 289 "extraCSS": func() template.HTML { 290 if extraCSS == "" { 291 return "" 292 } 293 return template.HTML("<style>" + extraCSS + "</style>") 294 }, 295 } 296 297 // Merge extra func map from overrides 298 if overrides != nil && overrides.ExtraFuncMap != nil { 299 for k, v := range overrides.ExtraFuncMap { 300 funcMap[k] = v 301 } 302 } 303 304 tmpl := template.New("").Funcs(funcMap) 305 306 // Parse default templates 307 tfs := resolveTemplatesFS(overrides) 308 tmpl, err := tmpl.ParseFS(tfs, "templates/**/*.html") 309 if err != nil { 310 return nil, err 311 } 312 313 return tmpl, nil 314} 315 316// PublicHandler returns HTTP handler for static files. 317// Pass nil for default atcr.io behavior. 318func PublicHandler(overrides *BrandingOverrides) http.Handler { 319 fsys := resolvePublicFS(overrides) 320 sub, err := fs.Sub(fsys, "public") 321 if err != nil { 322 panic(err) 323 } 324 return http.FileServer(http.FS(sub)) 325} 326 327// PublicRootFiles returns list of root-level files in static directory (not subdirectories). 328// Pass nil for default atcr.io behavior. 329func PublicRootFiles(overrides *BrandingOverrides) ([]string, error) { 330 fsys := resolvePublicFS(overrides) 331 var entries []fs.DirEntry 332 var err error 333 334 if rdfs, ok := fsys.(fs.ReadDirFS); ok { 335 entries, err = rdfs.ReadDir("public") 336 } else { 337 entries, err = fs.ReadDir(fsys, "public") 338 } 339 if err != nil { 340 return nil, err 341 } 342 343 var files []string 344 for _, entry := range entries { 345 // Only include files, not directories 346 if !entry.IsDir() { 347 files = append(files, entry.Name()) 348 } 349 } 350 return files, nil 351} 352 353// PublicSubdir returns an http.Handler for a subdirectory within public/. 354// Pass nil for default atcr.io behavior. 355func PublicSubdir(name string, overrides *BrandingOverrides) http.Handler { 356 fsys := resolvePublicFS(overrides) 357 sub, err := fs.Sub(fsys, "public/"+name) 358 if err != nil { 359 panic(err) 360 } 361 return http.FileServer(http.FS(sub)) 362}