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>
56 lines
1.1 KiB
Go
56 lines
1.1 KiB
Go
package server
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Simple token bucket keyed by arbitrary string. Thread-safe.
|
|
type bucket struct {
|
|
tokens float64
|
|
lastRef time.Time
|
|
}
|
|
|
|
type RateLimiter struct {
|
|
mu sync.Mutex
|
|
buckets map[string]*bucket
|
|
capacity float64
|
|
refill float64 // tokens per second
|
|
}
|
|
|
|
// NewRateLimiter: e.g. capacity=5, per=10min → 5 tokens, refilled at 5/600 tokens/sec.
|
|
func NewRateLimiter(capacity int, per time.Duration) *RateLimiter {
|
|
return &RateLimiter{
|
|
buckets: map[string]*bucket{},
|
|
capacity: float64(capacity),
|
|
refill: float64(capacity) / per.Seconds(),
|
|
}
|
|
}
|
|
|
|
// Allow returns true if the key has a token to spend.
|
|
func (l *RateLimiter) Allow(key string) bool {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
b, ok := l.buckets[key]
|
|
now := time.Now()
|
|
if !ok {
|
|
b = &bucket{tokens: l.capacity, lastRef: now}
|
|
l.buckets[key] = b
|
|
}
|
|
elapsed := now.Sub(b.lastRef).Seconds()
|
|
b.tokens = minF(l.capacity, b.tokens+elapsed*l.refill)
|
|
b.lastRef = now
|
|
if b.tokens >= 1 {
|
|
b.tokens--
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func minF(a, b float64) float64 {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|