initial commit: Artifacto v0.1
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>
This commit is contained in:
143
internal/server/server.go
Normal file
143
internal/server/server.go
Normal file
@@ -0,0 +1,143 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user