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>
135 lines
2.4 KiB
Go
135 lines
2.4 KiB
Go
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
|
|
}
|