Files
Artifacto/internal/server/server.go
dtoro 9e3443079f Sandbox artifact rendering via iframe + CSP
Splits /p/{slug} into a trusted wrapper (HTML with a sandboxed iframe)
and /p/{slug}/raw (the artifact itself, served with Content-Security-Policy:
sandbox). Artifact JS now runs in an opaque origin and can't read admin
cookies or make same-origin credentialed requests to /a/* or /api/*.
Password gating is enforced on both routes so /raw can't be used to bypass
the unlock flow.
2026-04-23 13:59:50 +02:00

149 lines
3.8 KiB
Go

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).
// /p/{slug} renders a sandbox-iframe wrapper; /p/{slug}/raw serves the
// artifact HTML itself with CSP: sandbox so cookies + same-origin XHR
// are unavailable to artifact JS whether loaded via iframe or directly.
r.Get("/p/{slug}", s.getArtifact)
r.Get("/p/{slug}/raw", s.getArtifactRaw)
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) {
// The raw artifact endpoint sets its own CSP: sandbox; skip ours here.
isRaw := strings.HasPrefix(r.URL.Path, "/p/") && strings.HasSuffix(r.URL.Path, "/raw")
if !isRaw {
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)
})
}