Files
Artifacto/internal/server/publish.go
dtoro c7d7ee287c 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>
2026-04-22 15:45:57 +02:00

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")
}