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.
149 lines
3.8 KiB
Go
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)
|
|
})
|
|
}
|