commit c7d7ee287c335e5caaa54d0bb09d62693edf40fc Author: dtoro Date: Wed Apr 22 15:45:57 2026 +0200 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) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4d8fd85 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +ADMIN_PASSWORD=change-me +SESSION_SECRET=generate-with-openssl-rand-hex-32 +BASE_URL=https://artifacto.hubris.network diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f2785f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/data/ +/bin/ +*.db +*.db-journal +.env +.env.local +dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b55868b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.23-alpine AS build +WORKDIR /src +ENV CGO_ENABLED=0 GOFLAGS=-buildvcs=false +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build -ldflags="-s -w" -o /out/artifacto ./cmd/artifacto + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata wget && adduser -D -u 10001 app +COPY --from=build /out/artifacto /usr/local/bin/artifacto +VOLUME /data +RUN mkdir -p /data && chown app:app /data +USER app +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://localhost:3000/healthz >/dev/null 2>&1 || exit 1 +ENTRYPOINT ["/usr/local/bin/artifacto"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7af0ac1 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +.PHONY: build run test docker-build docker-run tidy clean + +BIN := bin/artifacto + +build: + CGO_ENABLED=0 go build -ldflags="-s -w" -o $(BIN) ./cmd/artifacto + +run: build + ADMIN_PASSWORD=dev SESSION_SECRET=$$(openssl rand -hex 32) BASE_URL=http://localhost:3000 DATA_DIR=./data $(BIN) + +test: + go test ./... + +tidy: + go mod tidy + +docker-build: + docker build -t artifacto:latest . + +docker-run: docker-build + docker compose up -d + +clean: + rm -rf bin data diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce71df4 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Artifacto + +Self-hosted drop-and-share for HTML artifacts. Paste HTML → get a link → share. +Single-binary Go service, SQLite metadata, HTML bodies on disk, behind a reverse proxy. + +## Features + +- Paste or upload HTML, auto-generated slug (or custom) +- Optional per-artifact password (bcrypt), rate-limited unlock +- Optional expiration (time or view-count) +- Admin dashboard with view counts, sparklines, per-artifact 30-day chart +- Privacy-preserving visitor hash (daily-rotated salt, no cookies on viewers) +- One container, one SQLite file, one data directory + +## Quick start + +```bash +cp .env.example .env # set ADMIN_PASSWORD + SESSION_SECRET +docker compose up -d +open http://localhost:3100 +``` + +Then put a reverse proxy (Caddy, nginx, Traefik) in front for HTTPS. + +## Configuration + +| Env var | Default | Purpose | +|------------------|--------------------------------------|-------------------------------------| +| `ADMIN_PASSWORD` | — (required) | Admin login password | +| `SESSION_SECRET` | — (required, 32+ bytes hex) | Cookie HMAC key | +| `BASE_URL` | `http://localhost:3000` | Used when building share links | +| `DATA_DIR` | `/data` | SQLite DB + artifact files | +| `BIND_ADDR` | `:3000` | Listen address | +| `MAX_UPLOAD_MB` | `5` | Per-artifact upload cap | +| `LOG_LEVEL` | `info` | `info` or `debug` | + +## License + +MIT diff --git a/cmd/artifacto/main.go b/cmd/artifacto/main.go new file mode 100644 index 0000000..4ea7aa4 --- /dev/null +++ b/cmd/artifacto/main.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "git.hubris.network/dtoro/artifacto/internal/auth" + "git.hubris.network/dtoro/artifacto/internal/server" + "git.hubris.network/dtoro/artifacto/internal/store" +) + +func main() { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel()})) + slog.SetDefault(logger) + + dataDir := envOr("DATA_DIR", "/data") + bindAddr := envOr("BIND_ADDR", ":3000") + baseURL := envOr("BASE_URL", "http://localhost:3000") + adminPw := os.Getenv("ADMIN_PASSWORD") + sessionSecret := os.Getenv("SESSION_SECRET") + maxMB, _ := strconv.ParseInt(envOr("MAX_UPLOAD_MB", "5"), 10, 64) + + if sessionSecret == "" { + // For dev convenience: generate an ephemeral secret if unset. + buf := make([]byte, 32) + _, _ = rand.Read(buf) + sessionSecret = hex.EncodeToString(buf) + logger.Warn("SESSION_SECRET not set; using ephemeral (sessions will not persist across restarts)") + } + + s, err := store.Open(dataDir) + if err != nil { + logger.Error("open store", "err", err) + os.Exit(1) + } + defer s.Close() + + secure := strings.HasPrefix(baseURL, "https://") + admin, err := auth.NewAdmin(adminPw, sessionSecret, secure) + if err != nil { + logger.Error("init admin auth", "err", err) + os.Exit(1) + } + artifactAuth := auth.NewArtifact(sessionSecret, secure) + + srv, err := server.New(server.Config{ + BaseURL: strings.TrimRight(baseURL, "/"), + MaxUploadMB: maxMB, + Secure: secure, + Logger: logger, + }, s, admin, artifactAuth) + if err != nil { + logger.Error("init server", "err", err) + os.Exit(1) + } + + httpSrv := &http.Server{ + Addr: bindAddr, + Handler: srv.Handler(), + ReadHeaderTimeout: 10 * time.Second, + } + + stop := make(chan struct{}) + go srv.RollupLoop(stop) + + // Graceful shutdown + go func() { + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + logger.Info("shutting down") + close(stop) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = httpSrv.Shutdown(ctx) + }() + + logger.Info("artifacto starting", "addr", bindAddr, "base_url", baseURL, "data_dir", dataDir) + if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("http server", "err", err) + os.Exit(1) + } +} + +func envOr(k, d string) string { + if v := os.Getenv(k); v != "" { + return v + } + return d +} + +func logLevel() slog.Level { + switch strings.ToLower(os.Getenv("LOG_LEVEL")) { + case "debug": + return slog.LevelDebug + case "warn": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fd66a36 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +name: artifacto +services: + artifacto: + build: . + image: artifacto:latest + container_name: artifacto + restart: unless-stopped + environment: + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set in .env} + SESSION_SECRET: ${SESSION_SECRET:?set in .env} + BASE_URL: ${BASE_URL:-https://artifacto.hubris.network} + MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-5} + volumes: + - ./data:/data + ports: + - "127.0.0.1:3100:3000" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d2b8765 --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module git.hubris.network/dtoro/artifacto + +go 1.23 + +require ( + github.com/go-chi/chi/v5 v5.1.0 + golang.org/x/crypto v0.29.0 + modernc.org/sqlite v1.34.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.27.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c10ba09 --- /dev/null +++ b/go.sum @@ -0,0 +1,53 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= +golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk= +modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/auth/admin.go b/internal/auth/admin.go new file mode 100644 index 0000000..fa42d31 --- /dev/null +++ b/internal/auth/admin.go @@ -0,0 +1,133 @@ +package auth + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +const ( + AdminCookie = "artifacto_admin" + adminLifetime = 30 * 24 * time.Hour +) + +type ctxKey int + +const ctxAdmin ctxKey = 1 + +type Admin struct { + passwordHash []byte + secret []byte + secure bool +} + +func NewAdmin(password, secretHex string, secure bool) (*Admin, error) { + if password == "" { + return nil, errors.New("ADMIN_PASSWORD required") + } + if len(secretHex) < 32 { + return nil, errors.New("SESSION_SECRET must be >= 32 chars") + } + h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + return &Admin{passwordHash: h, secret: []byte(secretHex), secure: secure}, nil +} + +func (a *Admin) VerifyPassword(p string) bool { + return bcrypt.CompareHashAndPassword(a.passwordHash, []byte(p)) == nil +} + +// Issue returns a signed cookie value "issued_at.sig". +func (a *Admin) Issue() string { + issued := strconv.FormatInt(time.Now().Unix(), 10) + return issued + "." + a.sign("admin|"+issued) +} + +func (a *Admin) Verify(raw string) bool { + parts := strings.SplitN(raw, ".", 2) + if len(parts) != 2 { + return false + } + issued, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return false + } + if time.Since(time.Unix(issued, 0)) > adminLifetime { + return false + } + expected := a.sign("admin|" + parts[0]) + return hmac.Equal([]byte(expected), []byte(parts[1])) +} + +func (a *Admin) sign(s string) string { + m := hmac.New(sha256.New, a.secret) + m.Write([]byte(s)) + return base64.RawURLEncoding.EncodeToString(m.Sum(nil)) +} + +func (a *Admin) SetCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: AdminCookie, + Value: a.Issue(), + Path: "/", + HttpOnly: true, + Secure: a.secure, + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(adminLifetime), + }) +} + +func (a *Admin) ClearCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: AdminCookie, + Value: "", + Path: "/", + HttpOnly: true, + Secure: a.secure, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) +} + +// Middleware allows the request through if the admin cookie is valid; otherwise +// redirects to /login (for HTML nav) or returns 401 (for API/HTMX). +func (a *Admin) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(AdminCookie) + if err != nil || !a.Verify(c.Value) { + if isAPI(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + http.Redirect(w, r, "/login?next="+r.URL.RequestURI(), http.StatusSeeOther) + return + } + ctx := context.WithValue(r.Context(), ctxAdmin, true) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func isAPI(r *http.Request) bool { + if strings.HasPrefix(r.URL.Path, "/api/") { + return true + } + if r.Header.Get("HX-Request") == "true" { + return true + } + return false +} + +func IsAdmin(ctx context.Context) bool { + v, _ := ctx.Value(ctxAdmin).(bool) + return v +} diff --git a/internal/auth/artifact.go b/internal/auth/artifact.go new file mode 100644 index 0000000..32533ef --- /dev/null +++ b/internal/auth/artifact.go @@ -0,0 +1,80 @@ +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "net/http" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" +) + +const unlockLifetime = 24 * time.Hour + +type Artifact struct { + secret []byte + secure bool +} + +func NewArtifact(secretHex string, secure bool) *Artifact { + return &Artifact{secret: []byte(secretHex), secure: secure} +} + +func HashPassword(p string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost) + return string(b), err +} + +func VerifyPassword(hash, p string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(p)) == nil +} + +func unlockCookieName(slug string) string { + return "artifacto_unlock_" + slug +} + +func (a *Artifact) Issue(slug string) string { + issued := strconv.FormatInt(time.Now().Unix(), 10) + return issued + "." + a.sign(slug+"|"+issued) +} + +func (a *Artifact) SetCookie(w http.ResponseWriter, slug string) { + http.SetCookie(w, &http.Cookie{ + Name: unlockCookieName(slug), + Value: a.Issue(slug), + Path: "/p/" + slug, + HttpOnly: true, + Secure: a.secure, + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(unlockLifetime), + }) +} + +func (a *Artifact) Verify(r *http.Request, slug string) bool { + c, err := r.Cookie(unlockCookieName(slug)) + if err != nil { + return false + } + parts := strings.SplitN(c.Value, ".", 2) + if len(parts) != 2 { + return false + } + issued, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + return false + } + if time.Since(time.Unix(issued, 0)) > unlockLifetime { + return false + } + expected := a.sign(slug + "|" + parts[0]) + return hmac.Equal([]byte(expected), []byte(parts[1])) +} + +func (a *Artifact) sign(s string) string { + m := hmac.New(sha256.New, a.secret) + m.Write([]byte(s)) + return base64.RawURLEncoding.EncodeToString(m.Sum(nil)) +} diff --git a/internal/server/admin.go b/internal/server/admin.go new file mode 100644 index 0000000..418099e --- /dev/null +++ b/internal/server/admin.go @@ -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) +} diff --git a/internal/server/artifact.go b/internal/server/artifact.go new file mode 100644 index 0000000..15b031b --- /dev/null +++ b/internal/server/artifact.go @@ -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) +} diff --git a/internal/server/metrics.go b/internal/server/metrics.go new file mode 100644 index 0000000..5fe6b4c --- /dev/null +++ b/internal/server/metrics.go @@ -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, ``, w, h) + // axes + fmt.Fprintf(&b, ``, + padLeft, padTop, padLeft, padTop+innerH) + fmt.Fprintf(&b, ``, + padLeft, padTop+innerH, padLeft+innerW, padTop+innerH) + // horizontal gridline at max + fmt.Fprintf(&b, `%d`, + padLeft-4, padTop+6, max) + fmt.Fprintf(&b, `0`, + padLeft-4, padTop+innerH) + + n := len(series) + if n == 0 { + b.WriteString(``) + 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, ``, area.String()) + fmt.Fprintf(&b, ``, 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, `%s`, + x, padTop+innerH+14, shortDay(series[i].Day)) + } + + b.WriteString(``) + return b.String() +} + +func shortDay(iso string) string { + // yyyy-mm-dd → mm-dd + if len(iso) >= 10 { + return iso[5:10] + } + return iso +} diff --git a/internal/server/publish.go b/internal/server/publish.go new file mode 100644 index 0000000..9edef8f --- /dev/null +++ b/internal/server/publish.go @@ -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)(.*?)`) + +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, "") +} + +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") +} diff --git a/internal/server/ratelimit.go b/internal/server/ratelimit.go new file mode 100644 index 0000000..3d5fef6 --- /dev/null +++ b/internal/server/ratelimit.go @@ -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 +} diff --git a/internal/server/render.go b/internal/server/render.go new file mode 100644 index 0000000..04afaf8 --- /dev/null +++ b/internal/server/render.go @@ -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()) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..5d5c4ea --- /dev/null +++ b/internal/server/server.go @@ -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) + }) +} diff --git a/internal/server/static/.keep b/internal/server/static/.keep new file mode 100644 index 0000000..e69de29 diff --git a/internal/server/templates.go b/internal/server/templates.go new file mode 100644 index 0000000..3e145ce --- /dev/null +++ b/internal/server/templates.go @@ -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 +} diff --git a/internal/server/templates/dashboard.html b/internal/server/templates/dashboard.html new file mode 100644 index 0000000..d87dbf8 --- /dev/null +++ b/internal/server/templates/dashboard.html @@ -0,0 +1,121 @@ +{{ define "dashboard" }} +{{ template "head" "Dashboard" }} +{{ template "header" true }} +
+ + +
+
+
Artifacts
+
{{ .Stats.TotalArtifacts }}
+
+
+
Views (7d)
+
{{ .Stats.Views7d }}
+
+
+
Views (30d)
+
{{ .Stats.Views30d }}
+
+
+
Storage
+
{{ humanSize .Stats.StorageBytes }}
+
+
+ + +
+

Publish

+
+ +
+ + + + +
+
+ + +
+
+
+ + {{ if .FlashSlug }} +
+
+ Published: {{ .BaseURL }}/p/{{ .FlashSlug }} +
+ +
+ {{ end }} + + +
+ + + + + + + + + + + + + + {{ range .Items }} + + + + + + + + + + {{ else }} + + {{ end }} + +
SlugTitleViewsLast viewed7-daySizeActions
+ {{ .Slug }} + {{ if .HasPassword }}🔒{{ end }} + {{ if .Title }}{{ .Title }}{{ else }}{{ end }}{{ .ViewCount }}{{ humanTime .LastViewedAt }}{{ sparkline .Spark }}{{ humanSize .SizeBytes }} + + Open + +
No artifacts yet. Paste some HTML above to publish your first one.
+
+
+{{ template "footer" }} +{{ end }} diff --git a/internal/server/templates/detail.html b/internal/server/templates/detail.html new file mode 100644 index 0000000..bf4ef4e --- /dev/null +++ b/internal/server/templates/detail.html @@ -0,0 +1,52 @@ +{{ define "detail" }} +{{ template "head" (print "Artifact " .Artifact.Slug) }} +{{ template "header" true }} +
+ + +
+
+

{{ .Artifact.Slug }} {{ if .Artifact.HasPassword }}🔒{{ end }}

+ {{ if .Artifact.Title }}

{{ .Artifact.Title }}

{{ end }} +

+ Created {{ humanTime .Artifact.CreatedAt }} · + {{ humanSize .Artifact.SizeBytes }} · + {{ .Artifact.ViewCount }} views + {{ if .Artifact.ExpiresAt }} · expires {{ humanTime .Artifact.ExpiresAt }}{{ end }} +

+

{{ .BaseURL }}/p/{{ .Artifact.Slug }}

+
+
+ + +
+
+ +
+

Views — last 30 days

+ {{ .ChartSVG }} +
+ +
+

Top referrers

+ {{ if .Referrers }} + + + {{ range .Referrers }} + + + + + {{ end }} + +
{{ if .Host }}{{ .Host }}{{ else }}(direct){{ end }}{{ .Count }}
+ {{ else }} +

No referrer data yet.

+ {{ end }} +
+
+{{ template "footer" }} +{{ end }} diff --git a/internal/server/templates/layout.html b/internal/server/templates/layout.html new file mode 100644 index 0000000..1c68bb9 --- /dev/null +++ b/internal/server/templates/layout.html @@ -0,0 +1,39 @@ +{{ define "head" }} + + + + +{{ . }} · Artifacto + + + + + +{{ end }} + +{{ define "header" }} +
+
+ + + Artifacto + + {{ if . }} +
+ {{ end }} +
+
+{{ end }} + +{{ define "footer" }} +
+ Artifacto · self-hosted · /healthz +
+ + +{{ end }} diff --git a/internal/server/templates/login.html b/internal/server/templates/login.html new file mode 100644 index 0000000..557ac25 --- /dev/null +++ b/internal/server/templates/login.html @@ -0,0 +1,22 @@ +{{ define "login" }} +{{ template "head" "Log in" }} +{{ template "header" false }} +
+
+

Log in

+ {{ if .Error }} +
{{ .Error }}
+ {{ end }} +
+ + + +
+
+
+{{ template "footer" }} +{{ end }} diff --git a/internal/server/templates/unlock.html b/internal/server/templates/unlock.html new file mode 100644 index 0000000..e19e43b --- /dev/null +++ b/internal/server/templates/unlock.html @@ -0,0 +1,21 @@ +{{ define "unlock" }} +{{ template "head" "Protected" }} +
+
+
+ +
+

Protected artifact

+

Enter the password to view.

+ {{ if .Error }} +
{{ .Error }}
+ {{ end }} +
+ + +
+
+
+{{ template "footer" }} +{{ end }} diff --git a/internal/store/artifacts.go b/internal/store/artifacts.go new file mode 100644 index 0000000..96035f1 --- /dev/null +++ b/internal/store/artifacts.go @@ -0,0 +1,194 @@ +package store + +import ( + "crypto/rand" + "database/sql" + "errors" + "fmt" + "os" + "strings" + "time" +) + +// Crockford base32 without I, L, O, U. +const slugAlphabet = "0123456789abcdefghjkmnpqrstvwxyz" + +var ErrNotFound = errors.New("artifact not found") + +type Artifact struct { + Slug string + Title string + SizeBytes int64 + HasPassword bool + PasswordHash string + CreatedAt time.Time + ExpiresAt *time.Time + ViewCount int64 + LastViewedAt *time.Time +} + +type CreateOptions struct { + Slug string // may be empty + Title string + Body []byte + PasswordHash string + ExpiresAt *time.Time +} + +func (s *Store) CreateArtifact(opts CreateOptions) (*Artifact, error) { + slug := strings.ToLower(strings.TrimSpace(opts.Slug)) + if slug == "" { + g, err := s.generateSlug(5) + if err != nil { + return nil, err + } + slug = g + } else { + if existing, _ := s.GetArtifact(slug); existing != nil { + return nil, fmt.Errorf("slug already in use: %s", slug) + } + } + + path := s.ArtifactPath(slug) + if err := os.WriteFile(path, opts.Body, 0o644); err != nil { + return nil, fmt.Errorf("write artifact: %w", err) + } + + now := time.Now().Unix() + var expires sql.NullInt64 + if opts.ExpiresAt != nil { + expires = sql.NullInt64{Int64: opts.ExpiresAt.Unix(), Valid: true} + } + var pwh sql.NullString + if opts.PasswordHash != "" { + pwh = sql.NullString{String: opts.PasswordHash, Valid: true} + } + + _, err := s.DB.Exec(` + INSERT INTO artifacts (slug, title, size_bytes, password_hash, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?) + `, slug, opts.Title, int64(len(opts.Body)), pwh, now, expires) + if err != nil { + _ = os.Remove(path) + return nil, fmt.Errorf("insert artifact: %w", err) + } + + return s.GetArtifact(slug) +} + +func (s *Store) GetArtifact(slug string) (*Artifact, error) { + row := s.DB.QueryRow(` + SELECT slug, title, size_bytes, password_hash, created_at, expires_at, view_count, last_viewed_at + FROM artifacts WHERE slug = ? + `, slug) + return scanArtifact(row) +} + +func (s *Store) DeleteArtifact(slug string) error { + _, err := s.DB.Exec(`DELETE FROM artifacts WHERE slug = ?`, slug) + if err != nil { + return err + } + _ = os.Remove(s.ArtifactPath(slug)) + return nil +} + +type ListItem struct { + Artifact + Spark []int64 // 7-day recent views (oldest → newest) +} + +func (s *Store) ListArtifacts() ([]ListItem, error) { + rows, err := s.DB.Query(` + SELECT slug, title, size_bytes, password_hash, created_at, expires_at, view_count, last_viewed_at + FROM artifacts + ORDER BY created_at DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []ListItem + for rows.Next() { + a, err := scanArtifact(rows) + if err != nil { + return nil, err + } + out = append(out, ListItem{Artifact: *a}) + } + if err := rows.Err(); err != nil { + return nil, err + } + + // Fill sparklines in one query scan. + for i := range out { + spark, err := s.sparkline(out[i].Slug, 7) + if err != nil { + return nil, err + } + out[i].Spark = spark + } + return out, nil +} + +func (s *Store) TouchView(slug string, at time.Time) error { + _, err := s.DB.Exec(` + UPDATE artifacts + SET view_count = view_count + 1, last_viewed_at = ? + WHERE slug = ? + `, at.Unix(), slug) + return err +} + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanArtifact(r rowScanner) (*Artifact, error) { + var a Artifact + var pwh sql.NullString + var expires sql.NullInt64 + var lastView sql.NullInt64 + var created int64 + err := r.Scan(&a.Slug, &a.Title, &a.SizeBytes, &pwh, &created, &expires, &a.ViewCount, &lastView) + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + a.CreatedAt = time.Unix(created, 0) + if pwh.Valid { + a.PasswordHash = pwh.String + a.HasPassword = true + } + if expires.Valid { + t := time.Unix(expires.Int64, 0) + a.ExpiresAt = &t + } + if lastView.Valid { + t := time.Unix(lastView.Int64, 0) + a.LastViewedAt = &t + } + return &a, nil +} + +func (s *Store) generateSlug(n int) (string, error) { + buf := make([]byte, n) + for attempt := 0; attempt < 10; attempt++ { + if _, err := rand.Read(buf); err != nil { + return "", err + } + slug := make([]byte, n) + for i, b := range buf { + slug[i] = slugAlphabet[int(b)%len(slugAlphabet)] + } + candidate := string(slug) + existing, _ := s.GetArtifact(candidate) + if existing == nil { + return candidate, nil + } + } + return "", errors.New("could not generate unique slug") +} diff --git a/internal/store/migrations/001_init.sql b/internal/store/migrations/001_init.sql new file mode 100644 index 0000000..7a93243 --- /dev/null +++ b/internal/store/migrations/001_init.sql @@ -0,0 +1,50 @@ +CREATE TABLE IF NOT EXISTS artifacts ( + slug TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + size_bytes INTEGER NOT NULL, + password_hash TEXT, + created_at INTEGER NOT NULL, + expires_at INTEGER, + view_count INTEGER NOT NULL DEFAULT 0, + last_viewed_at INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_artifacts_created_at ON artifacts(created_at DESC); + +CREATE TABLE IF NOT EXISTS views ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL, + viewed_at INTEGER NOT NULL, + visitor TEXT NOT NULL, + referrer TEXT, + FOREIGN KEY (slug) REFERENCES artifacts(slug) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_views_slug_time ON views(slug, viewed_at); +CREATE INDEX IF NOT EXISTS idx_views_time ON views(viewed_at); + +CREATE TABLE IF NOT EXISTS daily_stats ( + slug TEXT NOT NULL, + day TEXT NOT NULL, + views INTEGER NOT NULL DEFAULT 0, + uniques INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (slug, day), + FOREIGN KEY (slug) REFERENCES artifacts(slug) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_daily_stats_day ON daily_stats(day); + +CREATE TABLE IF NOT EXISTS unlock_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL, + at INTEGER NOT NULL, + visitor TEXT NOT NULL, + success INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_unlock_attempts_slug_time ON unlock_attempts(slug, at); + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..2fc20e1 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,80 @@ +package store + +import ( + "database/sql" + "embed" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +type Store struct { + DB *sql.DB + DataDir string +} + +func Open(dataDir string) (*Store, error) { + if err := os.MkdirAll(filepath.Join(dataDir, "artifacts"), 0o755); err != nil { + return nil, fmt.Errorf("create data dir: %w", err) + } + dbPath := filepath.Join(dataDir, "artifacto.db") + dsn := "file:" + dbPath + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)" + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open db: %w", err) + } + db.SetMaxOpenConns(1) + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("ping db: %w", err) + } + s := &Store{DB: db, DataDir: dataDir} + if err := s.migrate(); err != nil { + return nil, fmt.Errorf("migrate: %w", err) + } + return s, nil +} + +func (s *Store) Close() error { return s.DB.Close() } + +func (s *Store) migrate() error { + entries, err := migrationsFS.ReadDir("migrations") + if err != nil { + return err + } + for _, e := range entries { + if e.IsDir() { + continue + } + b, err := migrationsFS.ReadFile("migrations/" + e.Name()) + if err != nil { + return err + } + if _, err := s.DB.Exec(string(b)); err != nil { + return fmt.Errorf("apply %s: %w", e.Name(), err) + } + } + return nil +} + +func (s *Store) ArtifactPath(slug string) string { + return filepath.Join(s.DataDir, "artifacts", slug+".html") +} + +func (s *Store) MetaGet(key string) (string, error) { + var v string + err := s.DB.QueryRow(`SELECT value FROM meta WHERE key = ?`, key).Scan(&v) + if err == sql.ErrNoRows { + return "", nil + } + return v, err +} + +func (s *Store) MetaSet(key, value string) error { + _, err := s.DB.Exec(`INSERT INTO meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, key, value) + return err +} diff --git a/internal/store/views.go b/internal/store/views.go new file mode 100644 index 0000000..f683086 --- /dev/null +++ b/internal/store/views.go @@ -0,0 +1,221 @@ +package store + +import ( + "fmt" + "time" +) + +func (s *Store) LogView(slug, visitor, referrer string, at time.Time) error { + var ref any + if referrer != "" { + ref = referrer + } + _, err := s.DB.Exec(` + INSERT INTO views (slug, viewed_at, visitor, referrer) + VALUES (?, ?, ?, ?) + `, slug, at.Unix(), visitor, ref) + return err +} + +func (s *Store) LogUnlockAttempt(slug, visitor string, success bool, at time.Time) error { + v := 0 + if success { + v = 1 + } + _, err := s.DB.Exec(` + INSERT INTO unlock_attempts (slug, at, visitor, success) + VALUES (?, ?, ?, ?) + `, slug, at.Unix(), visitor, v) + return err +} + +// RecentFailedUnlocks counts failed unlock attempts for (slug, visitor) within the window. +func (s *Store) RecentFailedUnlocks(slug, visitor string, since time.Time) (int, error) { + var n int + err := s.DB.QueryRow(` + SELECT COUNT(*) FROM unlock_attempts + WHERE slug = ? AND visitor = ? AND success = 0 AND at >= ? + `, slug, visitor, since.Unix()).Scan(&n) + return n, err +} + +// Rollup merges "views" rows from a given day into daily_stats. Idempotent: replaces existing rows for that day. +func (s *Store) Rollup(day time.Time) error { + dayStr := day.Format("2006-01-02") + start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, day.Location()).Unix() + end := start + 86400 + + tx, err := s.DB.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.Exec(`DELETE FROM daily_stats WHERE day = ?`, dayStr); err != nil { + return err + } + _, err = tx.Exec(` + INSERT INTO daily_stats (slug, day, views, uniques) + SELECT slug, ?, COUNT(*), COUNT(DISTINCT visitor) + FROM views + WHERE viewed_at >= ? AND viewed_at < ? + GROUP BY slug + `, dayStr, start, end) + if err != nil { + return err + } + return tx.Commit() +} + +// PruneRawViews removes view rows older than `keep` days. +func (s *Store) PruneRawViews(keep int) error { + cutoff := time.Now().AddDate(0, 0, -keep).Unix() + _, err := s.DB.Exec(`DELETE FROM views WHERE viewed_at < ?`, cutoff) + return err +} + +type DashboardStats struct { + TotalArtifacts int64 + Views7d int64 + Views30d int64 + StorageBytes int64 +} + +func (s *Store) Dashboard() (DashboardStats, error) { + var st DashboardStats + if err := s.DB.QueryRow(`SELECT COUNT(*), COALESCE(SUM(size_bytes),0) FROM artifacts`). + Scan(&st.TotalArtifacts, &st.StorageBytes); err != nil { + return st, err + } + since7 := time.Now().AddDate(0, 0, -7).Unix() + since30 := time.Now().AddDate(0, 0, -30).Unix() + + // Combine recent "views" table + daily_stats for dates covered by either. + if err := s.DB.QueryRow(`SELECT COUNT(*) FROM views WHERE viewed_at >= ?`, since7).Scan(&st.Views7d); err != nil { + return st, err + } + var fromRollup int64 + if err := s.DB.QueryRow(` + SELECT COALESCE(SUM(views),0) FROM daily_stats + WHERE day >= ? + `, time.Now().AddDate(0, 0, -30).Format("2006-01-02")).Scan(&fromRollup); err != nil { + return st, err + } + var fromRaw int64 + if err := s.DB.QueryRow(`SELECT COUNT(*) FROM views WHERE viewed_at >= ?`, since30).Scan(&fromRaw); err != nil { + return st, err + } + // For 30d, prefer raw if rollup is empty (fresh install); they should mostly match since Rollup prunes raw after 30d. + if fromRaw > fromRollup { + st.Views30d = fromRaw + } else { + st.Views30d = fromRollup + } + return st, nil +} + +// sparkline returns last N days of view counts (oldest → newest). Uses daily_stats then patches today from raw views. +func (s *Store) sparkline(slug string, days int) ([]int64, error) { + out := make([]int64, days) + today := time.Now() + for i := 0; i < days; i++ { + day := today.AddDate(0, 0, -(days - 1 - i)) + dayStr := day.Format("2006-01-02") + var v int64 + err := s.DB.QueryRow(`SELECT COALESCE(views,0) FROM daily_stats WHERE slug = ? AND day = ?`, slug, dayStr).Scan(&v) + if err != nil && err.Error() != "sql: no rows in result set" { + return nil, err + } + // Patch with raw views if rollup hasn't happened yet for this day (mainly: today). + start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, day.Location()).Unix() + end := start + 86400 + var raw int64 + if err := s.DB.QueryRow(`SELECT COUNT(*) FROM views WHERE slug = ? AND viewed_at >= ? AND viewed_at < ?`, slug, start, end).Scan(&raw); err != nil { + return nil, err + } + if raw > v { + v = raw + } + out[i] = v + } + return out, nil +} + +type DayPoint struct { + Day string + Views int64 + Unique int64 +} + +// SeriesFor returns a 30-day series for a single artifact, oldest → newest. +func (s *Store) SeriesFor(slug string, days int) ([]DayPoint, error) { + out := make([]DayPoint, days) + today := time.Now() + for i := 0; i < days; i++ { + day := today.AddDate(0, 0, -(days - 1 - i)) + dayStr := day.Format("2006-01-02") + var dp DayPoint + dp.Day = dayStr + + var v, u int64 + if err := s.DB.QueryRow(` + SELECT COALESCE(views,0), COALESCE(uniques,0) + FROM daily_stats WHERE slug = ? AND day = ? + `, slug, dayStr).Scan(&v, &u); err != nil && err.Error() != "sql: no rows in result set" { + return nil, err + } + + start := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, day.Location()).Unix() + end := start + 86400 + var raw, rawU int64 + if err := s.DB.QueryRow(` + SELECT COUNT(*), COUNT(DISTINCT visitor) + FROM views WHERE slug = ? AND viewed_at >= ? AND viewed_at < ? + `, slug, start, end).Scan(&raw, &rawU); err != nil { + return nil, err + } + if raw > v { + v = raw + } + if rawU > u { + u = rawU + } + dp.Views = v + dp.Unique = u + out[i] = dp + } + return out, nil +} + +type ReferrerCount struct { + Host string + Count int64 +} + +func (s *Store) TopReferrers(slug string, limit int) ([]ReferrerCount, error) { + rows, err := s.DB.Query(` + SELECT COALESCE(referrer, ''), COUNT(*) as n + FROM views WHERE slug = ? + GROUP BY referrer + ORDER BY n DESC + LIMIT ? + `, slug, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ReferrerCount + for rows.Next() { + var r ReferrerCount + if err := rows.Scan(&r.Host, &r.Count); err != nil { + return nil, err + } + out = append(out, r) + } + return out, nil +} + +// Debug helper used in smoke tests. +func (s *Store) Stringer() string { + return fmt.Sprintf("store(%s)", s.DataDir) +} diff --git a/internal/visitor/hash.go b/internal/visitor/hash.go new file mode 100644 index 0000000..565ba89 --- /dev/null +++ b/internal/visitor/hash.go @@ -0,0 +1,85 @@ +package visitor + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "net" + "net/http" + "strings" + "sync" + "time" +) + +type Salter interface { + SaltFor(day string) (string, error) +} + +// Salter that persists the day's salt in a key/value store. The store interface +// is minimal so tests can pass a fake. +type storeSalter interface { + MetaGet(key string) (string, error) + MetaSet(key, value string) error +} + +type DailySalter struct { + mu sync.Mutex + store storeSalter + cache map[string]string +} + +func NewDailySalter(s storeSalter) *DailySalter { + return &DailySalter{store: s, cache: map[string]string{}} +} + +func (d *DailySalter) SaltFor(day string) (string, error) { + d.mu.Lock() + defer d.mu.Unlock() + if v, ok := d.cache[day]; ok { + return v, nil + } + key := "salt:" + day + existing, err := d.store.MetaGet(key) + if err != nil { + return "", err + } + if existing != "" { + d.cache[day] = existing + return existing, nil + } + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "", err + } + salt := hex.EncodeToString(buf) + if err := d.store.MetaSet(key, salt); err != nil { + return "", err + } + d.cache[day] = salt + return salt, nil +} + +// Hash produces a 16-char hex fingerprint stable within a day. +func Hash(ip, ua, salt string) string { + h := sha256.Sum256([]byte(ip + "|" + ua + "|" + salt)) + return hex.EncodeToString(h[:])[:16] +} + +func ClientIP(r *http.Request) string { + if xf := r.Header.Get("X-Forwarded-For"); xf != "" { + parts := strings.Split(xf, ",") + return strings.TrimSpace(parts[0]) + } + if xr := r.Header.Get("X-Real-IP"); xr != "" { + return strings.TrimSpace(xr) + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} + +func Today() string { + return time.Now().Format("2006-01-02") +}