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>
161 lines
3.9 KiB
Go
161 lines
3.9 KiB
Go
package server
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.hubris.network/dtoro/artifacto/internal/auth"
|
|
"git.hubris.network/dtoro/artifacto/internal/store"
|
|
)
|
|
|
|
var customSlugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,31}$`)
|
|
var titleRe = regexp.MustCompile(`(?is)<title>(.*?)</title>`)
|
|
|
|
func (s *Server) postPublish(w http.ResponseWriter, r *http.Request) {
|
|
maxBytes := s.cfg.MaxUploadMB * 1024 * 1024
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
|
|
|
contentType := r.Header.Get("Content-Type")
|
|
var body []byte
|
|
var slug, customTitle, password, expiresIn string
|
|
|
|
if strings.HasPrefix(contentType, "multipart/form-data") {
|
|
if err := r.ParseMultipartForm(maxBytes); err != nil {
|
|
http.Error(w, "upload too large or malformed", http.StatusBadRequest)
|
|
return
|
|
}
|
|
slug = r.FormValue("slug")
|
|
customTitle = r.FormValue("title")
|
|
password = r.FormValue("password")
|
|
expiresIn = r.FormValue("expires_in")
|
|
body = []byte(r.FormValue("html"))
|
|
if f, fh, err := r.FormFile("file"); err == nil {
|
|
defer f.Close()
|
|
if fh.Size > maxBytes {
|
|
http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
|
|
return
|
|
}
|
|
b, err := io.ReadAll(f)
|
|
if err != nil {
|
|
http.Error(w, "read file", http.StatusBadRequest)
|
|
return
|
|
}
|
|
body = b
|
|
}
|
|
} else {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "bad form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
slug = r.FormValue("slug")
|
|
customTitle = r.FormValue("title")
|
|
password = r.FormValue("password")
|
|
expiresIn = r.FormValue("expires_in")
|
|
body = []byte(r.FormValue("html"))
|
|
}
|
|
|
|
body = []byte(strings.TrimSpace(string(body)))
|
|
if len(body) == 0 {
|
|
http.Error(w, "empty HTML body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
if !looksLikeHTML(body) {
|
|
http.Error(w, "body does not look like HTML", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
slug = strings.ToLower(strings.TrimSpace(slug))
|
|
if slug != "" && !customSlugRe.MatchString(slug) {
|
|
http.Error(w, "slug must match [a-z0-9][a-z0-9-]{2,31}", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
title := strings.TrimSpace(customTitle)
|
|
if title == "" {
|
|
title = extractTitle(body)
|
|
}
|
|
|
|
var pwHash string
|
|
if password != "" {
|
|
h, err := auth.HashPassword(password)
|
|
if err != nil {
|
|
http.Error(w, "hash password: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
pwHash = h
|
|
}
|
|
|
|
var expiresAt *time.Time
|
|
if expiresIn != "" && expiresIn != "never" {
|
|
if d, ok := parseDuration(expiresIn); ok {
|
|
t := time.Now().Add(d)
|
|
expiresAt = &t
|
|
}
|
|
}
|
|
|
|
art, err := s.store.CreateArtifact(store.CreateOptions{
|
|
Slug: slug,
|
|
Title: title,
|
|
Body: body,
|
|
PasswordHash: pwHash,
|
|
ExpiresAt: expiresAt,
|
|
})
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Redirect browsers back to dashboard; return JSON-ish link to API clients.
|
|
if wantsJSON(r) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"slug":"` + art.Slug + `","url":"` + s.cfg.BaseURL + "/p/" + art.Slug + `"}`))
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/?new="+art.Slug, http.StatusSeeOther)
|
|
}
|
|
|
|
func looksLikeHTML(b []byte) bool {
|
|
lower := strings.ToLower(strings.TrimSpace(string(b)))
|
|
if strings.HasPrefix(lower, "<!doctype") || strings.HasPrefix(lower, "<html") {
|
|
return true
|
|
}
|
|
// Allow fragments that contain at least one tag.
|
|
return strings.Contains(lower, "<") && strings.Contains(lower, ">")
|
|
}
|
|
|
|
func extractTitle(b []byte) string {
|
|
m := titleRe.FindSubmatch(b)
|
|
if len(m) < 2 {
|
|
return ""
|
|
}
|
|
t := strings.TrimSpace(string(m[1]))
|
|
if len(t) > 120 {
|
|
t = t[:120]
|
|
}
|
|
return t
|
|
}
|
|
|
|
func parseDuration(s string) (time.Duration, bool) {
|
|
switch s {
|
|
case "1h":
|
|
return time.Hour, true
|
|
case "24h":
|
|
return 24 * time.Hour, true
|
|
case "7d":
|
|
return 7 * 24 * time.Hour, true
|
|
case "30d":
|
|
return 30 * 24 * time.Hour, true
|
|
}
|
|
if d, err := time.ParseDuration(s); err == nil {
|
|
return d, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func wantsJSON(r *http.Request) bool {
|
|
return strings.Contains(r.Header.Get("Accept"), "application/json")
|
|
}
|