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:
3
.env.example
Normal file
3
.env.example
Normal 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
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/data/
|
||||||
|
/bin/
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
dist/
|
||||||
18
Dockerfile
Normal file
18
Dockerfile
Normal 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
24
Makefile
Normal 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
39
README.md
Normal 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
113
cmd/artifacto/main.go
Normal 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
16
docker-compose.yml
Normal 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
25
go.mod
Normal 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
53
go.sum
Normal 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
133
internal/auth/admin.go
Normal 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
80
internal/auth/artifact.go
Normal 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
86
internal/server/admin.go
Normal 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)
|
||||||
|
}
|
||||||
97
internal/server/artifact.go
Normal file
97
internal/server/artifact.go
Normal 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
136
internal/server/metrics.go
Normal 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
160
internal/server/publish.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.hubris.network/dtoro/artifacto/internal/auth"
|
||||||
|
"git.hubris.network/dtoro/artifacto/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
var customSlugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{2,31}$`)
|
||||||
|
var titleRe = regexp.MustCompile(`(?is)<title>(.*?)</title>`)
|
||||||
|
|
||||||
|
func (s *Server) postPublish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
maxBytes := s.cfg.MaxUploadMB * 1024 * 1024
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
||||||
|
|
||||||
|
contentType := r.Header.Get("Content-Type")
|
||||||
|
var body []byte
|
||||||
|
var slug, customTitle, password, expiresIn string
|
||||||
|
|
||||||
|
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||||
|
if err := r.ParseMultipartForm(maxBytes); err != nil {
|
||||||
|
http.Error(w, "upload too large or malformed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slug = r.FormValue("slug")
|
||||||
|
customTitle = r.FormValue("title")
|
||||||
|
password = r.FormValue("password")
|
||||||
|
expiresIn = r.FormValue("expires_in")
|
||||||
|
body = []byte(r.FormValue("html"))
|
||||||
|
if f, fh, err := r.FormFile("file"); err == nil {
|
||||||
|
defer f.Close()
|
||||||
|
if fh.Size > maxBytes {
|
||||||
|
http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "read file", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body = b
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slug = r.FormValue("slug")
|
||||||
|
customTitle = r.FormValue("title")
|
||||||
|
password = r.FormValue("password")
|
||||||
|
expiresIn = r.FormValue("expires_in")
|
||||||
|
body = []byte(r.FormValue("html"))
|
||||||
|
}
|
||||||
|
|
||||||
|
body = []byte(strings.TrimSpace(string(body)))
|
||||||
|
if len(body) == 0 {
|
||||||
|
http.Error(w, "empty HTML body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !looksLikeHTML(body) {
|
||||||
|
http.Error(w, "body does not look like HTML", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||||
|
if slug != "" && !customSlugRe.MatchString(slug) {
|
||||||
|
http.Error(w, "slug must match [a-z0-9][a-z0-9-]{2,31}", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
title := strings.TrimSpace(customTitle)
|
||||||
|
if title == "" {
|
||||||
|
title = extractTitle(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pwHash string
|
||||||
|
if password != "" {
|
||||||
|
h, err := auth.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "hash password: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pwHash = h
|
||||||
|
}
|
||||||
|
|
||||||
|
var expiresAt *time.Time
|
||||||
|
if expiresIn != "" && expiresIn != "never" {
|
||||||
|
if d, ok := parseDuration(expiresIn); ok {
|
||||||
|
t := time.Now().Add(d)
|
||||||
|
expiresAt = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
art, err := s.store.CreateArtifact(store.CreateOptions{
|
||||||
|
Slug: slug,
|
||||||
|
Title: title,
|
||||||
|
Body: body,
|
||||||
|
PasswordHash: pwHash,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect browsers back to dashboard; return JSON-ish link to API clients.
|
||||||
|
if wantsJSON(r) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"slug":"` + art.Slug + `","url":"` + s.cfg.BaseURL + "/p/" + art.Slug + `"}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/?new="+art.Slug, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeHTML(b []byte) bool {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(string(b)))
|
||||||
|
if strings.HasPrefix(lower, "<!doctype") || strings.HasPrefix(lower, "<html") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Allow fragments that contain at least one tag.
|
||||||
|
return strings.Contains(lower, "<") && strings.Contains(lower, ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractTitle(b []byte) string {
|
||||||
|
m := titleRe.FindSubmatch(b)
|
||||||
|
if len(m) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
t := strings.TrimSpace(string(m[1]))
|
||||||
|
if len(t) > 120 {
|
||||||
|
t = t[:120]
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDuration(s string) (time.Duration, bool) {
|
||||||
|
switch s {
|
||||||
|
case "1h":
|
||||||
|
return time.Hour, true
|
||||||
|
case "24h":
|
||||||
|
return 24 * time.Hour, true
|
||||||
|
case "7d":
|
||||||
|
return 7 * 24 * time.Hour, true
|
||||||
|
case "30d":
|
||||||
|
return 30 * 24 * time.Hour, true
|
||||||
|
}
|
||||||
|
if d, err := time.ParseDuration(s); err == nil {
|
||||||
|
return d, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func wantsJSON(r *http.Request) bool {
|
||||||
|
return strings.Contains(r.Header.Get("Accept"), "application/json")
|
||||||
|
}
|
||||||
55
internal/server/ratelimit.go
Normal file
55
internal/server/ratelimit.go
Normal 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
16
internal/server/render.go
Normal 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
143
internal/server/server.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
0
internal/server/static/.keep
Normal file
0
internal/server/static/.keep
Normal file
134
internal/server/templates.go
Normal file
134
internal/server/templates.go
Normal 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
|
||||||
|
}
|
||||||
121
internal/server/templates/dashboard.html
Normal file
121
internal/server/templates/dashboard.html
Normal 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 <title> 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 }}
|
||||||
52
internal/server/templates/detail.html
Normal file
52
internal/server/templates/detail.html
Normal 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 }}
|
||||||
39
internal/server/templates/layout.html
Normal file
39
internal/server/templates/layout.html
Normal 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 }}
|
||||||
22
internal/server/templates/login.html
Normal file
22
internal/server/templates/login.html
Normal 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 }}
|
||||||
21
internal/server/templates/unlock.html
Normal file
21
internal/server/templates/unlock.html
Normal 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
194
internal/store/artifacts.go
Normal 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")
|
||||||
|
}
|
||||||
50
internal/store/migrations/001_init.sql
Normal file
50
internal/store/migrations/001_init.sql
Normal 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
80
internal/store/store.go
Normal 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
221
internal/store/views.go
Normal 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
85
internal/visitor/hash.go
Normal 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")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user