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:
2026-04-22 15:45:57 +02:00
commit c7d7ee287c
30 changed files with 2223 additions and 0 deletions

View 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
}