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:
86
internal/server/admin.go
Normal file
86
internal/server/admin.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.hubris.network/dtoro/artifacto/internal/store"
|
||||
)
|
||||
|
||||
type loginData struct {
|
||||
Error string
|
||||
Next string
|
||||
}
|
||||
|
||||
func (s *Server) getLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// If already logged in, send to dashboard.
|
||||
if c, err := r.Cookie("artifacto_admin"); err == nil && s.admin.Verify(c.Value) {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.render(w, "login", loginData{Next: r.URL.Query().Get("next")})
|
||||
}
|
||||
|
||||
func (s *Server) postLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pw := r.FormValue("password")
|
||||
if !s.admin.VerifyPassword(pw) {
|
||||
s.render(w, "login", loginData{Error: "wrong password", Next: r.FormValue("next")})
|
||||
return
|
||||
}
|
||||
s.admin.SetCookie(w)
|
||||
next := r.FormValue("next")
|
||||
if next == "" {
|
||||
next = "/"
|
||||
}
|
||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) postLogout(w http.ResponseWriter, r *http.Request) {
|
||||
s.admin.ClearCookie(w)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type dashboardData struct {
|
||||
BaseURL string
|
||||
Stats store.DashboardStats
|
||||
Items []store.ListItem
|
||||
FlashSlug string // newly-created slug, for copy-link affordance
|
||||
}
|
||||
|
||||
func (s *Server) getDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.store.ListArtifacts()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
stats, err := s.store.Dashboard()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.render(w, "dashboard", dashboardData{
|
||||
BaseURL: s.cfg.BaseURL,
|
||||
Stats: stats,
|
||||
Items: items,
|
||||
FlashSlug: r.URL.Query().Get("new"),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) deleteArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
if err := s.store.DeleteArtifact(slug); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// HTMX removes the row on 200; non-HTMX redirects home.
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
Reference in New Issue
Block a user