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:
85
internal/visitor/hash.go
Normal file
85
internal/visitor/hash.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user