initial commit: Artifacto v0.1
Self-hosted HTML artifact publisher. Single Go binary, SQLite metadata, HTML on disk. Features: password-protected artifacts, per-artifact expiration, admin dashboard with view metrics (privacy-preserving daily-salt visitor hash, sparklines, 30-day SVG chart, top referrers), rate-limited unlock endpoint. Packaged as a Docker image (two-stage, CGO_ENABLED=0, pure-Go SQLite). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
134
internal/server/templates.go
Normal file
134
internal/server/templates.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var funcs = template.FuncMap{
|
||||
"humanTime": humanTime,
|
||||
"humanSize": humanSize,
|
||||
"sparkline": sparkline,
|
||||
"seq": seq,
|
||||
"maxInt": maxInt,
|
||||
"inc": func(i int) int { return i + 1 },
|
||||
}
|
||||
|
||||
func parseTemplates() (*template.Template, error) {
|
||||
t := template.New("").Funcs(funcs)
|
||||
err := fs.WalkDir(templateFS, "templates", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(path, ".html") {
|
||||
return nil
|
||||
}
|
||||
b, err := fs.ReadFile(templateFS, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := strings.TrimSuffix(strings.TrimPrefix(path, "templates/"), ".html")
|
||||
_, err = t.New(name).Parse(string(b))
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func humanTime(v any) string {
|
||||
var t time.Time
|
||||
switch x := v.(type) {
|
||||
case time.Time:
|
||||
t = x
|
||||
case *time.Time:
|
||||
if x == nil {
|
||||
return "—"
|
||||
}
|
||||
t = *x
|
||||
default:
|
||||
return "—"
|
||||
}
|
||||
if t.IsZero() {
|
||||
return "—"
|
||||
}
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
case d < 30*24*time.Hour:
|
||||
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
|
||||
default:
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
|
||||
func humanSize(b int64) string {
|
||||
const k = 1024
|
||||
switch {
|
||||
case b < k:
|
||||
return fmt.Sprintf("%d B", b)
|
||||
case b < k*k:
|
||||
return fmt.Sprintf("%.1f KB", float64(b)/k)
|
||||
case b < k*k*k:
|
||||
return fmt.Sprintf("%.1f MB", float64(b)/(k*k))
|
||||
default:
|
||||
return fmt.Sprintf("%.1f GB", float64(b)/(k*k*k))
|
||||
}
|
||||
}
|
||||
|
||||
var sparkGlyphs = []rune("▁▂▃▄▅▆▇█")
|
||||
|
||||
func sparkline(vals []int64) string {
|
||||
if len(vals) == 0 {
|
||||
return ""
|
||||
}
|
||||
var max int64
|
||||
for _, v := range vals {
|
||||
if v > max {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
if max == 0 {
|
||||
return strings.Repeat("·", len(vals))
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, v := range vals {
|
||||
if v == 0 {
|
||||
b.WriteRune('·')
|
||||
continue
|
||||
}
|
||||
idx := int(float64(v)/float64(max)*float64(len(sparkGlyphs)-1) + 0.5)
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
if idx >= len(sparkGlyphs) {
|
||||
idx = len(sparkGlyphs) - 1
|
||||
}
|
||||
b.WriteRune(sparkGlyphs[idx])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func seq(n int) []int {
|
||||
out := make([]int, n)
|
||||
for i := range out {
|
||||
out[i] = i
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user