package server import ( "embed" "html/template" "io/fs" "log/slog" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "git.hubris.network/dtoro/artifacto/internal/auth" "git.hubris.network/dtoro/artifacto/internal/store" "git.hubris.network/dtoro/artifacto/internal/visitor" ) //go:embed templates/*.html var templateFS embed.FS //go:embed static/* var staticFS embed.FS type Config struct { BaseURL string MaxUploadMB int64 Secure bool // set cookies as Secure Logger *slog.Logger } type Server struct { cfg Config store *store.Store admin *auth.Admin artifact *auth.Artifact salter *visitor.DailySalter rateLimiter *RateLimiter tmpl *template.Template } func New(cfg Config, s *store.Store, admin *auth.Admin, art *auth.Artifact) (*Server, error) { t, err := parseTemplates() if err != nil { return nil, err } return &Server{ cfg: cfg, store: s, admin: admin, artifact: art, salter: visitor.NewDailySalter(s), rateLimiter: NewRateLimiter(5, 10*time.Minute), tmpl: t, }, nil } func (s *Server) Handler() http.Handler { r := chi.NewRouter() r.Use(middleware.RealIP) r.Use(middleware.RequestID) r.Use(middleware.Recoverer) r.Use(securityHeaders) r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) // Static assets sub, _ := fs.Sub(staticFS, "static") r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) // Public routes r.Get("/login", s.getLogin) r.Post("/login", s.postLogin) // Artifact serving (public; password gating is per-artifact) r.Get("/p/{slug}", s.getArtifact) r.Post("/p/{slug}/unlock", s.postUnlock) // Admin-only routes r.Group(func(r chi.Router) { r.Use(s.admin.Middleware) r.Get("/", s.getDashboard) r.Post("/logout", s.postLogout) r.Get("/a/{slug}", s.getArtifactDetail) r.Delete("/a/{slug}", s.deleteArtifact) r.Post("/api/publish", s.postPublish) }) return r } func (s *Server) visitorID(r *http.Request) string { salt, err := s.salter.SaltFor(visitor.Today()) if err != nil { return "nosalt" } return visitor.Hash(visitor.ClientIP(r), r.UserAgent(), salt) } // RollupLoop rolls up yesterday's raw views into daily_stats and prunes old raw rows. // Runs on a rough daily cadence; also fires once at startup to catch up after downtime. func (s *Server) RollupLoop(stop <-chan struct{}) { do := func() { yesterday := time.Now().AddDate(0, 0, -1) if err := s.store.Rollup(yesterday); err != nil { s.cfg.Logger.Error("rollup failed", "err", err) } if err := s.store.PruneRawViews(30); err != nil { s.cfg.Logger.Error("prune failed", "err", err) } } do() t := time.NewTicker(6 * time.Hour) defer t.Stop() for { select { case <-t.C: do() case <-stop: return } } } func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Note: no CSP on /p/{slug} since artifact HTML often uses inline scripts + CDNs. if !strings.HasPrefix(r.URL.Path, "/p/") { w.Header().Set("Content-Security-Policy", "default-src 'self'; "+ "script-src 'self' https://unpkg.com https://cdn.tailwindcss.com 'unsafe-inline'; "+ "style-src 'self' https://cdn.tailwindcss.com 'unsafe-inline'; "+ "img-src 'self' data:; "+ "font-src 'self' data:") } w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") next.ServeHTTP(w, r) }) }