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

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
ADMIN_PASSWORD=change-me
SESSION_SECRET=generate-with-openssl-rand-hex-32
BASE_URL=https://artifacto.hubris.network

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
/data/
/bin/
*.db
*.db-journal
.env
.env.local
dist/

18
Dockerfile Normal file
View File

@@ -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"]

24
Makefile Normal file
View File

@@ -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

39
README.md Normal file
View File

@@ -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

113
cmd/artifacto/main.go Normal file
View File

@@ -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
}
}

16
docker-compose.yml Normal file
View File

@@ -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"

25
go.mod Normal file
View File

@@ -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
)

53
go.sum Normal file
View File

@@ -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=

133
internal/auth/admin.go Normal file
View File

@@ -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
}

80
internal/auth/artifact.go Normal file
View File

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

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

194
internal/store/artifacts.go Normal file
View File

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

View File

@@ -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
);

80
internal/store/store.go Normal file
View File

@@ -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
}

221
internal/store/views.go Normal file
View File

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

85
internal/visitor/hash.go Normal file
View File

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