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

85
internal/visitor/hash.go Normal file
View File

@@ -0,0 +1,85 @@
package visitor
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net"
"net/http"
"strings"
"sync"
"time"
)
type Salter interface {
SaltFor(day string) (string, error)
}
// Salter that persists the day's salt in a key/value store. The store interface
// is minimal so tests can pass a fake.
type storeSalter interface {
MetaGet(key string) (string, error)
MetaSet(key, value string) error
}
type DailySalter struct {
mu sync.Mutex
store storeSalter
cache map[string]string
}
func NewDailySalter(s storeSalter) *DailySalter {
return &DailySalter{store: s, cache: map[string]string{}}
}
func (d *DailySalter) SaltFor(day string) (string, error) {
d.mu.Lock()
defer d.mu.Unlock()
if v, ok := d.cache[day]; ok {
return v, nil
}
key := "salt:" + day
existing, err := d.store.MetaGet(key)
if err != nil {
return "", err
}
if existing != "" {
d.cache[day] = existing
return existing, nil
}
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", err
}
salt := hex.EncodeToString(buf)
if err := d.store.MetaSet(key, salt); err != nil {
return "", err
}
d.cache[day] = salt
return salt, nil
}
// Hash produces a 16-char hex fingerprint stable within a day.
func Hash(ip, ua, salt string) string {
h := sha256.Sum256([]byte(ip + "|" + ua + "|" + salt))
return hex.EncodeToString(h[:])[:16]
}
func ClientIP(r *http.Request) string {
if xf := r.Header.Get("X-Forwarded-For"); xf != "" {
parts := strings.Split(xf, ",")
return strings.TrimSpace(parts[0])
}
if xr := r.Header.Get("X-Real-IP"); xr != "" {
return strings.TrimSpace(xr)
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func Today() string {
return time.Now().Format("2006-01-02")
}