A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
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}