Files
Sandbar/internal/sandbar/server.go
T
VeillaxandClaude Sonnet 5.5 b4fd31a182
Release / release (push) Successful in 3m49s
CI / test (push) Successful in 4m45s
Add website analytics
A Tianji-style Websites section: a small tracking script, a public collection endpoint, and a
page per website with visitors, page views, visits, bounce rate, a chart and top lists for pages,
referrers, browsers, systems, devices, countries, languages and events. No cookies and no stored
IP address or user agent. SANDBAR_TRUST_PROXY makes the client address come from the proxy.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 04:22:55 -06:00

140 lines
3.4 KiB
Go

package sandbar
import (
_ "embed"
"log"
"net/http"
"sync"
"time"
)
//go:embed web/index.html
var indexHTML []byte
//go:embed web/status.html
var statusHTML []byte
type SMTPConfig struct{ Host, Port, User, Password, From string }
// Options configures a Server. Zero values are sensible defaults.
type Options struct {
DataDir string
URL string // public URL, used for links in notifications
MasterKey string
MetricsToken string
SMTP SMTPConfig
MessageDays int // how long topic messages are kept (default 30)
CheckDays int // how long monitor results are kept (default 30)
HitDays int // how long website analytics are kept (default 365)
TrustProxy bool // take the client address from X-Forwarded-For, for use behind a reverse proxy
// Self-update (see update.go). Version is the running release, such as v0.2.0; anything else
// ("dev") never updates. Restart is called after a new binary is installed and should close
// what the process holds open before re-executing it.
Version string
AutoUpdate bool
UpdateServer string // default https://git.veillax.com
UpdateRepo string // default Veillax/sandbar
Restart func(exe string) error
// Tests replace these.
Now func() time.Time
HTTPClient *http.Client
Mailer func(to, subject, body string) error
}
type Server struct {
opts Options
st *Store
hub *hub
// monitor runtime state
mu sync.Mutex
running map[string]bool // monitor ids with a check in flight
loginMu sync.Mutex
loginTry map[string][]time.Time
upd updateState
anaMu sync.Mutex // website analytics: the visitor salt and a per-address rate limit
anaSecret string
anaTry map[string][]time.Time
stop chan struct{}
wg sync.WaitGroup
}
func New(opts Options, st *Store) *Server {
if opts.MessageDays <= 0 {
opts.MessageDays = 30
}
if opts.CheckDays <= 0 {
opts.CheckDays = 30
}
if opts.HitDays <= 0 {
opts.HitDays = 365
}
return &Server{opts: opts, st: st, hub: newHub(), running: map[string]bool{}, loginTry: map[string][]time.Time{}, anaTry: map[string][]time.Time{}, stop: make(chan struct{})}
}
func (s *Server) now() time.Time {
if s.opts.Now != nil {
return s.opts.Now()
}
return time.Now()
}
// Start runs the background workers: monitor scheduler, escalation, pruning.
func (s *Server) Start() {
s.every(time.Second, func() { s.runDueMonitors(s.now()) })
s.every(5*time.Second, func() { s.escalate(s.now()) })
s.every(time.Hour, func() { s.st.prune(s.now(), s.opts.MessageDays, s.opts.CheckDays, s.opts.HitDays) })
}
func (s *Server) Close() {
close(s.stop)
s.wg.Wait()
}
func (s *Server) every(d time.Duration, f func()) {
s.wg.Add(1)
go func() {
defer s.wg.Done()
t := time.NewTicker(d)
defer t.Stop()
for {
select {
case <-s.stop:
return
case <-t.C:
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("worker panic: %v", r)
}
}()
f()
}()
}
}
}()
}
func (s *Server) link(path string) string {
if s.opts.URL == "" {
return ""
}
return s.opts.URL + path
}
// BootstrapAdmin creates the first administrator from the environment when there are no users yet.
func (s *Server) BootstrapAdmin(email, password string) {
if email == "" || password == "" || s.countUsers() > 0 {
return
}
if _, err := s.createUser("Admin", email, password, "admin", ""); err != nil {
log.Printf("SANDBAR_ADMIN_EMAIL: %v", err)
}
}