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>
86 lines
1.7 KiB
Go
86 lines
1.7 KiB
Go
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")
|
|
}
|