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:
2026-04-22 15:45:57 +02:00
commit c7d7ee287c
30 changed files with 2223 additions and 0 deletions

86
internal/server/admin.go Normal file
View 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)
}

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

136
internal/server/metrics.go Normal file
View File

@@ -0,0 +1,136 @@
package server
import (
"fmt"
"html/template"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"git.hubris.network/dtoro/artifacto/internal/store"
)
type detailData struct {
BaseURL string
Artifact *store.Artifact
Series []store.DayPoint
Referrers []store.ReferrerCount
ChartSVG template.HTML
}
func (s *Server) getArtifactDetail(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
}
series, err := s.store.SeriesFor(slug, 30)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
refs, err := s.store.TopReferrers(slug, 10)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.render(w, "detail", detailData{
BaseURL: s.cfg.BaseURL,
Artifact: art,
Series: series,
Referrers: refs,
ChartSVG: template.HTML(renderChart(series)),
})
}
// renderChart produces a tiny SVG line+area chart with day labels.
func renderChart(series []store.DayPoint) string {
const w = 720
const h = 160
const padLeft = 30
const padBottom = 20
const padTop = 10
const padRight = 10
innerW := w - padLeft - padRight
innerH := h - padTop - padBottom
var max int64
for _, p := range series {
if p.Views > max {
max = p.Views
}
}
if max < 1 {
max = 1
}
var b strings.Builder
fmt.Fprintf(&b, `<svg viewBox="0 0 %d %d" class="w-full h-40 text-slate-400" xmlns="http://www.w3.org/2000/svg">`, w, h)
// axes
fmt.Fprintf(&b, `<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-width="1"/>`,
padLeft, padTop, padLeft, padTop+innerH)
fmt.Fprintf(&b, `<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="currentColor" stroke-width="1"/>`,
padLeft, padTop+innerH, padLeft+innerW, padTop+innerH)
// horizontal gridline at max
fmt.Fprintf(&b, `<text x="%d" y="%d" font-size="10" fill="currentColor" text-anchor="end">%d</text>`,
padLeft-4, padTop+6, max)
fmt.Fprintf(&b, `<text x="%d" y="%d" font-size="10" fill="currentColor" text-anchor="end">0</text>`,
padLeft-4, padTop+innerH)
n := len(series)
if n == 0 {
b.WriteString(`</svg>`)
return b.String()
}
step := float64(innerW) / float64(maxInt(n-1, 1))
// build polyline path
var pts strings.Builder
for i, p := range series {
x := float64(padLeft) + float64(i)*step
y := float64(padTop+innerH) - float64(p.Views)/float64(max)*float64(innerH)
if i > 0 {
pts.WriteByte(' ')
}
fmt.Fprintf(&pts, "%.1f,%.1f", x, y)
}
// area under curve
var area strings.Builder
fmt.Fprintf(&area, "M%d,%d ", padLeft, padTop+innerH)
for i, p := range series {
x := float64(padLeft) + float64(i)*step
y := float64(padTop+innerH) - float64(p.Views)/float64(max)*float64(innerH)
fmt.Fprintf(&area, "L%.1f,%.1f ", x, y)
}
fmt.Fprintf(&area, "L%.1f,%d Z", float64(padLeft)+float64(n-1)*step, padTop+innerH)
fmt.Fprintf(&b, `<path d="%s" fill="rgb(99 102 241 / 0.15)" stroke="none"/>`, area.String())
fmt.Fprintf(&b, `<polyline fill="none" stroke="rgb(99 102 241)" stroke-width="2" points="%s"/>`, pts.String())
// x-axis tick labels: first, middle, last day
labelIdx := []int{0, n / 2, n - 1}
for _, i := range labelIdx {
if i < 0 || i >= n {
continue
}
x := float64(padLeft) + float64(i)*step
fmt.Fprintf(&b, `<text x="%.1f" y="%d" font-size="10" fill="currentColor" text-anchor="middle">%s</text>`,
x, padTop+innerH+14, shortDay(series[i].Day))
}
b.WriteString(`</svg>`)
return b.String()
}
func shortDay(iso string) string {
// yyyy-mm-dd → mm-dd
if len(iso) >= 10 {
return iso[5:10]
}
return iso
}

160
internal/server/publish.go Normal file
View 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")
}

View File

@@ -0,0 +1,55 @@
package server
import (
"sync"
"time"
)
// Simple token bucket keyed by arbitrary string. Thread-safe.
type bucket struct {
tokens float64
lastRef time.Time
}
type RateLimiter struct {
mu sync.Mutex
buckets map[string]*bucket
capacity float64
refill float64 // tokens per second
}
// NewRateLimiter: e.g. capacity=5, per=10min → 5 tokens, refilled at 5/600 tokens/sec.
func NewRateLimiter(capacity int, per time.Duration) *RateLimiter {
return &RateLimiter{
buckets: map[string]*bucket{},
capacity: float64(capacity),
refill: float64(capacity) / per.Seconds(),
}
}
// Allow returns true if the key has a token to spend.
func (l *RateLimiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
b, ok := l.buckets[key]
now := time.Now()
if !ok {
b = &bucket{tokens: l.capacity, lastRef: now}
l.buckets[key] = b
}
elapsed := now.Sub(b.lastRef).Seconds()
b.tokens = minF(l.capacity, b.tokens+elapsed*l.refill)
b.lastRef = now
if b.tokens >= 1 {
b.tokens--
return true
}
return false
}
func minF(a, b float64) float64 {
if a < b {
return a
}
return b
}

16
internal/server/render.go Normal file
View File

@@ -0,0 +1,16 @@
package server
import (
"bytes"
"net/http"
)
func (s *Server) render(w http.ResponseWriter, name string, data any) {
var buf bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil {
http.Error(w, "template: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(buf.Bytes())
}

143
internal/server/server.go Normal file
View File

@@ -0,0 +1,143 @@
package server
import (
"embed"
"html/template"
"io/fs"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"git.hubris.network/dtoro/artifacto/internal/auth"
"git.hubris.network/dtoro/artifacto/internal/store"
"git.hubris.network/dtoro/artifacto/internal/visitor"
)
//go:embed templates/*.html
var templateFS embed.FS
//go:embed static/*
var staticFS embed.FS
type Config struct {
BaseURL string
MaxUploadMB int64
Secure bool // set cookies as Secure
Logger *slog.Logger
}
type Server struct {
cfg Config
store *store.Store
admin *auth.Admin
artifact *auth.Artifact
salter *visitor.DailySalter
rateLimiter *RateLimiter
tmpl *template.Template
}
func New(cfg Config, s *store.Store, admin *auth.Admin, art *auth.Artifact) (*Server, error) {
t, err := parseTemplates()
if err != nil {
return nil, err
}
return &Server{
cfg: cfg,
store: s,
admin: admin,
artifact: art,
salter: visitor.NewDailySalter(s),
rateLimiter: NewRateLimiter(5, 10*time.Minute),
tmpl: t,
}, nil
}
func (s *Server) Handler() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RealIP)
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)
r.Use(securityHeaders)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
// Static assets
sub, _ := fs.Sub(staticFS, "static")
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(sub))))
// Public routes
r.Get("/login", s.getLogin)
r.Post("/login", s.postLogin)
// Artifact serving (public; password gating is per-artifact)
r.Get("/p/{slug}", s.getArtifact)
r.Post("/p/{slug}/unlock", s.postUnlock)
// Admin-only routes
r.Group(func(r chi.Router) {
r.Use(s.admin.Middleware)
r.Get("/", s.getDashboard)
r.Post("/logout", s.postLogout)
r.Get("/a/{slug}", s.getArtifactDetail)
r.Delete("/a/{slug}", s.deleteArtifact)
r.Post("/api/publish", s.postPublish)
})
return r
}
func (s *Server) visitorID(r *http.Request) string {
salt, err := s.salter.SaltFor(visitor.Today())
if err != nil {
return "nosalt"
}
return visitor.Hash(visitor.ClientIP(r), r.UserAgent(), salt)
}
// RollupLoop rolls up yesterday's raw views into daily_stats and prunes old raw rows.
// Runs on a rough daily cadence; also fires once at startup to catch up after downtime.
func (s *Server) RollupLoop(stop <-chan struct{}) {
do := func() {
yesterday := time.Now().AddDate(0, 0, -1)
if err := s.store.Rollup(yesterday); err != nil {
s.cfg.Logger.Error("rollup failed", "err", err)
}
if err := s.store.PruneRawViews(30); err != nil {
s.cfg.Logger.Error("prune failed", "err", err)
}
}
do()
t := time.NewTicker(6 * time.Hour)
defer t.Stop()
for {
select {
case <-t.C:
do()
case <-stop:
return
}
}
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Note: no CSP on /p/{slug} since artifact HTML often uses inline scripts + CDNs.
if !strings.HasPrefix(r.URL.Path, "/p/") {
w.Header().Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' https://unpkg.com https://cdn.tailwindcss.com 'unsafe-inline'; "+
"style-src 'self' https://cdn.tailwindcss.com 'unsafe-inline'; "+
"img-src 'self' data:; "+
"font-src 'self' data:")
}
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}

View File

View File

@@ -0,0 +1,134 @@
package server
import (
"fmt"
"html/template"
"io/fs"
"strings"
"time"
)
var funcs = template.FuncMap{
"humanTime": humanTime,
"humanSize": humanSize,
"sparkline": sparkline,
"seq": seq,
"maxInt": maxInt,
"inc": func(i int) int { return i + 1 },
}
func parseTemplates() (*template.Template, error) {
t := template.New("").Funcs(funcs)
err := fs.WalkDir(templateFS, "templates", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".html") {
return nil
}
b, err := fs.ReadFile(templateFS, path)
if err != nil {
return err
}
name := strings.TrimSuffix(strings.TrimPrefix(path, "templates/"), ".html")
_, err = t.New(name).Parse(string(b))
return err
})
if err != nil {
return nil, err
}
return t, nil
}
func humanTime(v any) string {
var t time.Time
switch x := v.(type) {
case time.Time:
t = x
case *time.Time:
if x == nil {
return "—"
}
t = *x
default:
return "—"
}
if t.IsZero() {
return "—"
}
d := time.Since(t)
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
case d < 30*24*time.Hour:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
default:
return t.Format("2006-01-02")
}
}
func humanSize(b int64) string {
const k = 1024
switch {
case b < k:
return fmt.Sprintf("%d B", b)
case b < k*k:
return fmt.Sprintf("%.1f KB", float64(b)/k)
case b < k*k*k:
return fmt.Sprintf("%.1f MB", float64(b)/(k*k))
default:
return fmt.Sprintf("%.1f GB", float64(b)/(k*k*k))
}
}
var sparkGlyphs = []rune("▁▂▃▄▅▆▇█")
func sparkline(vals []int64) string {
if len(vals) == 0 {
return ""
}
var max int64
for _, v := range vals {
if v > max {
max = v
}
}
if max == 0 {
return strings.Repeat("·", len(vals))
}
var b strings.Builder
for _, v := range vals {
if v == 0 {
b.WriteRune('·')
continue
}
idx := int(float64(v)/float64(max)*float64(len(sparkGlyphs)-1) + 0.5)
if idx < 0 {
idx = 0
}
if idx >= len(sparkGlyphs) {
idx = len(sparkGlyphs) - 1
}
b.WriteRune(sparkGlyphs[idx])
}
return b.String()
}
func seq(n int) []int {
out := make([]int, n)
for i := range out {
out[i] = i
}
return out
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}

View File

@@ -0,0 +1,121 @@
{{ define "dashboard" }}
{{ template "head" "Dashboard" }}
{{ template "header" true }}
<main class="max-w-6xl mx-auto px-6 py-8">
<!-- Stat tiles -->
<section class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
<div class="rounded border border-slate-800 bg-slate-900/50 px-4 py-3">
<div class="text-xs uppercase tracking-wide text-slate-400">Artifacts</div>
<div class="text-2xl font-semibold mt-1">{{ .Stats.TotalArtifacts }}</div>
</div>
<div class="rounded border border-slate-800 bg-slate-900/50 px-4 py-3">
<div class="text-xs uppercase tracking-wide text-slate-400">Views (7d)</div>
<div class="text-2xl font-semibold mt-1">{{ .Stats.Views7d }}</div>
</div>
<div class="rounded border border-slate-800 bg-slate-900/50 px-4 py-3">
<div class="text-xs uppercase tracking-wide text-slate-400">Views (30d)</div>
<div class="text-2xl font-semibold mt-1">{{ .Stats.Views30d }}</div>
</div>
<div class="rounded border border-slate-800 bg-slate-900/50 px-4 py-3">
<div class="text-xs uppercase tracking-wide text-slate-400">Storage</div>
<div class="text-2xl font-semibold mt-1">{{ humanSize .Stats.StorageBytes }}</div>
</div>
</section>
<!-- Publish form -->
<section class="rounded border border-slate-800 bg-slate-900/50 p-4 mb-8">
<h2 class="text-lg font-semibold mb-3">Publish</h2>
<form method="post" action="/api/publish" enctype="multipart/form-data" class="space-y-3">
<textarea name="html" rows="8" placeholder="Paste HTML here, or use the file picker below"
class="w-full bg-slate-950 border border-slate-800 rounded px-3 py-2 text-sm focus:outline-none focus:border-indigo-500"></textarea>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<label class="block">
<span class="block text-xs text-slate-400 mb-1">Custom slug (optional)</span>
<input type="text" name="slug" pattern="[a-z0-9][a-z0-9-]{2,31}" placeholder="auto"
class="w-full bg-slate-950 border border-slate-800 rounded px-3 py-2 text-sm">
</label>
<label class="block">
<span class="block text-xs text-slate-400 mb-1">Title (optional)</span>
<input type="text" name="title" placeholder="from &lt;title&gt; if blank"
class="w-full bg-slate-950 border border-slate-800 rounded px-3 py-2 text-sm">
</label>
<label class="block">
<span class="block text-xs text-slate-400 mb-1">Password (optional)</span>
<input type="password" name="password" autocomplete="new-password"
class="w-full bg-slate-950 border border-slate-800 rounded px-3 py-2 text-sm">
</label>
<label class="block">
<span class="block text-xs text-slate-400 mb-1">Expires</span>
<select name="expires_in" class="w-full bg-slate-950 border border-slate-800 rounded px-3 py-2 text-sm">
<option value="never">never</option>
<option value="1h">1 hour</option>
<option value="24h">1 day</option>
<option value="7d">7 days</option>
<option value="30d">30 days</option>
</select>
</label>
</div>
<div class="flex items-center justify-between">
<label class="text-xs text-slate-400">
Or upload: <input type="file" name="file" accept=".html,text/html" class="ml-2 text-xs">
</label>
<button class="bg-indigo-600 hover:bg-indigo-500 text-white rounded px-4 py-2 font-medium text-sm">Publish</button>
</div>
</form>
</section>
{{ if .FlashSlug }}
<div class="rounded border border-emerald-800 bg-emerald-900/30 text-emerald-100 px-4 py-3 mb-6 text-sm flex items-center justify-between">
<div>
Published: <code class="bg-slate-900 px-2 py-0.5 rounded">{{ .BaseURL }}/p/{{ .FlashSlug }}</code>
</div>
<button onclick="navigator.clipboard.writeText('{{ .BaseURL }}/p/{{ .FlashSlug }}')"
class="bg-emerald-700 hover:bg-emerald-600 rounded px-3 py-1 text-xs">Copy link</button>
</div>
{{ end }}
<!-- Artifact list -->
<section class="rounded border border-slate-800 bg-slate-900/30 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-900 text-slate-400 text-xs uppercase tracking-wide">
<tr>
<th class="text-left px-4 py-2">Slug</th>
<th class="text-left px-4 py-2">Title</th>
<th class="text-right px-4 py-2">Views</th>
<th class="text-left px-4 py-2">Last viewed</th>
<th class="text-left px-4 py-2">7-day</th>
<th class="text-left px-4 py-2">Size</th>
<th class="text-right px-4 py-2">Actions</th>
</tr>
</thead>
<tbody>
{{ range .Items }}
<tr id="row-{{ .Slug }}" class="border-t border-slate-800 hover:bg-slate-900/50">
<td class="px-4 py-2">
<a class="font-mono text-indigo-300 hover:text-indigo-200" href="/a/{{ .Slug }}">{{ .Slug }}</a>
{{ if .HasPassword }}<span class="ml-1 text-xs text-amber-400" title="Password-protected">🔒</span>{{ end }}
</td>
<td class="px-4 py-2 text-slate-300">{{ if .Title }}{{ .Title }}{{ else }}<span class="text-slate-500"></span>{{ end }}</td>
<td class="px-4 py-2 text-right tabular-nums">{{ .ViewCount }}</td>
<td class="px-4 py-2 text-slate-400">{{ humanTime .LastViewedAt }}</td>
<td class="px-4 py-2 spark text-indigo-300">{{ sparkline .Spark }}</td>
<td class="px-4 py-2 text-slate-400">{{ humanSize .SizeBytes }}</td>
<td class="px-4 py-2 text-right">
<button onclick="navigator.clipboard.writeText('{{ $.BaseURL }}/p/{{ .Slug }}')"
class="text-xs px-2 py-1 rounded bg-slate-800 hover:bg-slate-700">Copy</button>
<a href="/p/{{ .Slug }}" target="_blank" class="text-xs px-2 py-1 rounded bg-slate-800 hover:bg-slate-700">Open</a>
<button hx-delete="/a/{{ .Slug }}" hx-target="#row-{{ .Slug }}" hx-swap="outerHTML"
hx-confirm="Delete {{ .Slug }}? This cannot be undone."
class="text-xs px-2 py-1 rounded bg-red-900/60 hover:bg-red-800">Delete</button>
</td>
</tr>
{{ else }}
<tr><td colspan="7" class="px-4 py-10 text-center text-slate-500">No artifacts yet. Paste some HTML above to publish your first one.</td></tr>
{{ end }}
</tbody>
</table>
</section>
</main>
{{ template "footer" }}
{{ end }}

View File

@@ -0,0 +1,52 @@
{{ define "detail" }}
{{ template "head" (print "Artifact " .Artifact.Slug) }}
{{ template "header" true }}
<main class="max-w-6xl mx-auto px-6 py-8">
<nav class="text-sm text-slate-400 mb-4"><a href="/" class="hover:text-slate-100">← back to dashboard</a></nav>
<div class="flex items-start justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold font-mono">{{ .Artifact.Slug }} {{ if .Artifact.HasPassword }}<span class="text-base text-amber-400 align-middle">🔒</span>{{ end }}</h1>
{{ if .Artifact.Title }}<p class="text-slate-400 mt-1">{{ .Artifact.Title }}</p>{{ end }}
<p class="text-xs text-slate-500 mt-2">
Created {{ humanTime .Artifact.CreatedAt }} ·
{{ humanSize .Artifact.SizeBytes }} ·
{{ .Artifact.ViewCount }} views
{{ if .Artifact.ExpiresAt }} · expires {{ humanTime .Artifact.ExpiresAt }}{{ end }}
</p>
<p class="mt-2 text-sm"><a class="text-indigo-300 hover:text-indigo-200" href="{{ .BaseURL }}/p/{{ .Artifact.Slug }}" target="_blank">{{ .BaseURL }}/p/{{ .Artifact.Slug }}</a></p>
</div>
<div class="flex gap-2">
<button onclick="navigator.clipboard.writeText('{{ .BaseURL }}/p/{{ .Artifact.Slug }}')"
class="text-sm px-3 py-1.5 rounded bg-slate-800 hover:bg-slate-700">Copy link</button>
<button hx-delete="/a/{{ .Artifact.Slug }}" hx-confirm="Delete {{ .Artifact.Slug }}? This cannot be undone."
hx-on::after-request="window.location='/'"
class="text-sm px-3 py-1.5 rounded bg-red-900/60 hover:bg-red-800">Delete</button>
</div>
</div>
<section class="rounded border border-slate-800 bg-slate-900/30 p-4 mb-6">
<h2 class="text-sm uppercase tracking-wide text-slate-400 mb-3">Views — last 30 days</h2>
{{ .ChartSVG }}
</section>
<section class="rounded border border-slate-800 bg-slate-900/30 p-4">
<h2 class="text-sm uppercase tracking-wide text-slate-400 mb-3">Top referrers</h2>
{{ if .Referrers }}
<table class="w-full text-sm">
<tbody>
{{ range .Referrers }}
<tr class="border-t border-slate-800 first:border-t-0">
<td class="py-1.5 text-slate-300">{{ if .Host }}{{ .Host }}{{ else }}<span class="text-slate-500">(direct)</span>{{ end }}</td>
<td class="py-1.5 text-right tabular-nums">{{ .Count }}</td>
</tr>
{{ end }}
</tbody>
</table>
{{ else }}
<p class="text-sm text-slate-500">No referrer data yet.</p>
{{ end }}
</section>
</main>
{{ template "footer" }}
{{ end }}

View File

@@ -0,0 +1,39 @@
{{ define "head" }}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ . }} · Artifacto</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<style>
:root { color-scheme: dark; }
body { font-family: ui-sans-serif, system-ui, sans-serif; }
.spark { font-variant-numeric: tabular-nums; letter-spacing: 2px; }
textarea { font-family: ui-monospace, SF Mono, Menlo, monospace; }
</style>
</head>
<body class="min-h-screen bg-slate-950 text-slate-100">
{{ end }}
{{ define "header" }}
<header class="border-b border-slate-800">
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<a href="/" class="flex items-center gap-2 font-semibold text-lg">
<span class="inline-block w-2 h-2 rounded-full bg-indigo-400"></span>
Artifacto
</a>
{{ if . }}
<form method="post" action="/logout"><button class="text-sm text-slate-400 hover:text-slate-100">Log out</button></form>
{{ end }}
</div>
</header>
{{ end }}
{{ define "footer" }}
<footer class="max-w-6xl mx-auto px-6 py-8 text-xs text-slate-500">
Artifacto · self-hosted · <a class="underline" href="/healthz">/healthz</a>
</footer>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,22 @@
{{ define "login" }}
{{ template "head" "Log in" }}
{{ template "header" false }}
<main class="max-w-6xl mx-auto px-6 py-8">
<div class="max-w-sm mx-auto mt-16">
<h1 class="text-2xl font-semibold mb-4">Log in</h1>
{{ if .Error }}
<div class="rounded bg-red-900/40 border border-red-800 text-red-200 px-3 py-2 mb-3 text-sm">{{ .Error }}</div>
{{ end }}
<form method="post" action="/login" class="space-y-3">
<input type="hidden" name="next" value="{{ .Next }}">
<label class="block">
<span class="block text-sm text-slate-400 mb-1">Password</span>
<input type="password" name="password" required autofocus autocomplete="current-password"
class="w-full bg-slate-900 border border-slate-800 rounded px-3 py-2 focus:outline-none focus:border-indigo-500">
</label>
<button class="w-full bg-indigo-600 hover:bg-indigo-500 text-white rounded px-3 py-2 font-medium">Log in</button>
</form>
</div>
</main>
{{ template "footer" }}
{{ end }}

View File

@@ -0,0 +1,21 @@
{{ define "unlock" }}
{{ template "head" "Protected" }}
<main class="max-w-6xl mx-auto px-6 py-8">
<div class="max-w-sm mx-auto mt-24 text-center">
<div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-slate-900 border border-slate-800 mb-4">
<svg class="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11v2m-4 4h8a2 2 0 002-2v-5a2 2 0 00-2-2H8a2 2 0 00-2 2v5a2 2 0 002 2zM10 9V7a2 2 0 114 0v2"/></svg>
</div>
<h1 class="text-xl font-semibold mb-2">Protected artifact</h1>
<p class="text-sm text-slate-400 mb-6">Enter the password to view.</p>
{{ if .Error }}
<div class="rounded bg-red-900/40 border border-red-800 text-red-200 px-3 py-2 mb-3 text-sm">{{ .Error }}</div>
{{ end }}
<form method="post" action="/p/{{ .Slug }}/unlock" class="space-y-3 text-left">
<input type="password" name="password" required autofocus
class="w-full bg-slate-900 border border-slate-800 rounded px-3 py-2 focus:outline-none focus:border-indigo-500">
<button class="w-full bg-indigo-600 hover:bg-indigo-500 text-white rounded px-3 py-2 font-medium">Unlock</button>
</form>
</div>
</main>
{{ template "footer" }}
{{ end }}