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:
160
internal/server/publish.go
Normal file
160
internal/server/publish.go
Normal file
@@ -0,0 +1,160 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user