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:
97
internal/server/artifact.go
Normal file
97
internal/server/artifact.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.hubris.network/dtoro/artifacto/internal/auth"
|
||||
)
|
||||
|
||||
type unlockData struct {
|
||||
Slug string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *Server) getArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
art, err := s.store.GetArtifact(slug)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if art.ExpiresAt != nil && time.Now().After(*art.ExpiresAt) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if art.HasPassword && !s.artifact.Verify(r, slug) {
|
||||
s.render(w, "unlock", unlockData{Slug: slug})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(s.store.ArtifactPath(slug))
|
||||
if err != nil {
|
||||
http.Error(w, "artifact missing on disk", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write(body)
|
||||
|
||||
// Log view asynchronously. Failures are non-fatal.
|
||||
go s.logView(r, slug)
|
||||
}
|
||||
|
||||
func (s *Server) postUnlock(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
art, err := s.store.GetArtifact(slug)
|
||||
if err != nil || !art.HasPassword {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
vid := s.visitorID(r)
|
||||
key := slug + "|" + vid
|
||||
|
||||
if !s.rateLimiter.Allow(key) {
|
||||
w.Header().Set("Retry-After", "600")
|
||||
http.Error(w, "too many attempts, try again in ~10 minutes", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
ok := auth.VerifyPassword(art.PasswordHash, r.FormValue("password"))
|
||||
_ = s.store.LogUnlockAttempt(slug, vid, ok, time.Now())
|
||||
if !ok {
|
||||
s.render(w, "unlock", unlockData{Slug: slug, Error: "wrong password"})
|
||||
return
|
||||
}
|
||||
s.artifact.SetCookie(w, slug)
|
||||
http.Redirect(w, r, "/p/"+slug, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) logView(r *http.Request, slug string) {
|
||||
now := time.Now()
|
||||
vid := s.visitorID(r)
|
||||
ref := refererHost(r.Referer())
|
||||
_ = s.store.LogView(slug, vid, ref, now)
|
||||
_ = s.store.TouchView(slug, now)
|
||||
}
|
||||
|
||||
func refererHost(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(u.Host)
|
||||
}
|
||||
Reference in New Issue
Block a user