Merge pull request 'Phase 0: backend + frontend scaffold, single-binary build' (#21) from phase-0-scaffold into main
This commit was merged in pull request #21.
This commit is contained in:
+18
@@ -25,3 +25,21 @@ go.work.sum
|
||||
# env file
|
||||
.env
|
||||
|
||||
# pansy binary + SQLite database files
|
||||
/pansy
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Embedded web build: the placeholder index.html is committed so `go build`
|
||||
# works on a fresh checkout; `make web` overwrites it and adds hashed assets,
|
||||
# which are generated artifacts and must not be committed.
|
||||
/internal/webdist/dist/*
|
||||
!/internal/webdist/dist/index.html
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Playwright MCP scratch output
|
||||
.playwright-mcp/
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# pansy — one static binary: Go API + embedded React build.
|
||||
#
|
||||
# make build # production binary ./pansy with the web build embedded
|
||||
# make dev # Go API (:8080) + Vite dev (:5173) together, hot reload
|
||||
# make test # go test + web tests
|
||||
#
|
||||
# pansy is a standalone module; GOWORK=off keeps the parent workspace out of the
|
||||
# way, and CGO_ENABLED=0 keeps the binary static (pure-Go SQLite driver).
|
||||
|
||||
BINARY := pansy
|
||||
WEBDIST := internal/webdist/dist
|
||||
GO_ENV := CGO_ENABLED=0 GOWORK=off
|
||||
|
||||
.PHONY: all build web dev dev-api dev-web test clean
|
||||
|
||||
all: build
|
||||
|
||||
## build: bundle the frontend and compile the single static binary.
|
||||
build: web
|
||||
$(GO_ENV) go build -o $(BINARY) ./cmd/pansy
|
||||
@echo "built ./$(BINARY)"
|
||||
|
||||
## web: produce the production web bundle and sync it into the embed directory.
|
||||
web:
|
||||
cd web && npm ci && npm run build
|
||||
rm -rf $(WEBDIST)
|
||||
cp -r web/dist $(WEBDIST)
|
||||
|
||||
## dev: run the API and Vite dev server together (Ctrl-C stops both).
|
||||
## Prefer two terminals if you want independent restarts:
|
||||
## term 1: make dev-api term 2: make dev-web
|
||||
dev:
|
||||
@echo "API on :8080, Vite on :5173 (proxies /api → :8080). Ctrl-C stops both."
|
||||
@$(MAKE) -j2 dev-api dev-web
|
||||
|
||||
dev-api:
|
||||
$(GO_ENV) go run ./cmd/pansy
|
||||
|
||||
dev-web:
|
||||
cd web && npm run dev
|
||||
|
||||
## test: run backend and (if present) frontend tests.
|
||||
test:
|
||||
$(GO_ENV) go test ./...
|
||||
cd web && npm test --if-present
|
||||
|
||||
## clean: remove the binary and the generated embed contents (restore placeholder).
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
rm -rf $(WEBDIST)
|
||||
git checkout -- $(WEBDIST)/index.html 2>/dev/null || true
|
||||
@@ -4,3 +4,57 @@ Self-hostable garden planner: drag beds, bags, and containers onto a real-scale
|
||||
|
||||
See [DESIGN.md](DESIGN.md) for the architecture. Work is tracked in this repo's issues — start from the tracking epic.
|
||||
|
||||
## Quickstart
|
||||
|
||||
**Prerequisites:** Go 1.26+, Node 20+.
|
||||
|
||||
### Develop
|
||||
|
||||
Run the Go API and the Vite dev server together (Vite proxies `/api` → the API):
|
||||
|
||||
```sh
|
||||
make dev
|
||||
```
|
||||
|
||||
Then open http://localhost:5173. Or run the two halves in separate terminals for independent restarts:
|
||||
|
||||
```sh
|
||||
make dev-api # Go API on :8080
|
||||
make dev-web # Vite dev server on :5173
|
||||
```
|
||||
|
||||
### Build & run
|
||||
|
||||
Produce the single static binary with the web build embedded, then run it:
|
||||
|
||||
```sh
|
||||
make build
|
||||
./pansy
|
||||
```
|
||||
|
||||
Open http://localhost:8080 — one process serves both the JSON API and the app.
|
||||
|
||||
### Test
|
||||
|
||||
```sh
|
||||
make test
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is via environment variables; every value has a default, so `./pansy` runs with none set.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ------------------------- | ------------------ | ------------------------------------------------------------------ |
|
||||
| `PANSY_PORT` | `8080` | TCP port the HTTP server listens on. |
|
||||
| `PANSY_DB` | `./pansy.db` | SQLite database file path (created if absent). |
|
||||
| `PANSY_BASE_URL` | *(empty)* | Externally-visible base URL; used to derive the OIDC redirect URI. |
|
||||
| `PANSY_REGISTRATION` | `open` | `open` or `closed` — gates local self-service signup. |
|
||||
| `PANSY_LOCAL_AUTH` | `true` | Enable local password auth. Set `false` for pure-OIDC. |
|
||||
| `PANSY_OIDC_ISSUER` | *(empty)* | OIDC issuer/discovery URL (Authentik). Enables SSO when set. |
|
||||
| `PANSY_OIDC_CLIENT_ID` | *(empty)* | OIDC client ID. |
|
||||
| `PANSY_OIDC_CLIENT_SECRET`| *(empty)* | OIDC client secret. |
|
||||
| `PANSY_OIDC_BUTTON_LABEL` | `Sign in with SSO` | Label for the OIDC button on the login page. |
|
||||
| `PANSY_TRUSTED_PROXIES` | *(none)* | Comma-separated proxy CIDRs/IPs to trust for client-IP resolution. |
|
||||
|
||||
Auth variables are read at startup now and consumed as the auth features land (issues #4/#5).
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Command pansy is the single static binary: it loads configuration, opens and
|
||||
// migrates the SQLite database, builds the HTTP server (JSON API + embedded SPA),
|
||||
// and serves until interrupted.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/api"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/store"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/webdist"
|
||||
)
|
||||
|
||||
func main() {
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||
|
||||
if err := run(); err != nil {
|
||||
slog.Error("pansy: fatal", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg := config.Load()
|
||||
|
||||
db, err := store.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Migrations run against a short-lived context so a hung DB can't block boot
|
||||
// forever; the schema is tiny so this is generous.
|
||||
migCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := db.Migrate(migCtx); err != nil {
|
||||
return fmt.Errorf("migrate database: %w", err)
|
||||
}
|
||||
slog.Info("pansy: database ready", "path", cfg.DBPath)
|
||||
|
||||
r := api.New(cfg)
|
||||
api.RegisterSPA(r, webdist.Dist())
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
Handler: r,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// Serve in the background; the main goroutine waits for a signal, then drains.
|
||||
serveErr := make(chan error, 1)
|
||||
go func() {
|
||||
slog.Info("pansy: listening", "addr", srv.Addr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
serveErr <- err
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case err := <-serveErr:
|
||||
return fmt.Errorf("http server: %w", err)
|
||||
case <-ctx.Done():
|
||||
slog.Info("pansy: shutting down")
|
||||
stop() // restore default signal handling so a second Ctrl-C force-quits
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("graceful shutdown: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
module gitea.stevedudenhoeffer.com/steve/pansy
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/samber/slog-gin v1.15.0
|
||||
modernc.org/sqlite v1.34.4
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.9 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.4 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.22.0 // indirect
|
||||
github.com/goccy/go-json v0.10.3 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.opentelemetry.io/otel v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // 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
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg=
|
||||
github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
|
||||
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
|
||||
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
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/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
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=
|
||||
github.com/samber/slog-gin v1.15.0 h1:Kqs/ilXd9divtslWjbz5DVptmLlzyntbBiXUAta2SFg=
|
||||
github.com/samber/slog-gin v1.15.0/go.mod h1:mPAEinK/g2jPLauuWO11m3Q0Ca7aG4k9XjXjXY8IhMQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
|
||||
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
|
||||
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=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
@@ -0,0 +1,45 @@
|
||||
// Package api wires pansy's HTTP surface: a gin engine with structured logging
|
||||
// and panic recovery, the versioned JSON API under /api/v1, and (via spa.go) the
|
||||
// embedded single-page-app fallback. Handlers stay thin — decode, call the
|
||||
// service layer, encode — so all business logic and permission checks live in
|
||||
// internal/service (added by later issues).
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
sloggin "github.com/samber/slog-gin"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
)
|
||||
|
||||
// New builds the gin engine with the standard middleware stack and registers the
|
||||
// API routes. The embedded SPA fallback is registered separately by the caller
|
||||
// via RegisterSPA (see spa.go) so the API can be built and tested without a web
|
||||
// build present.
|
||||
func New(cfg *config.Config) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(sloggin.New(slog.Default()), gin.Recovery())
|
||||
|
||||
if err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {
|
||||
// Do not leave gin's trust-everyone default active on a parse failure —
|
||||
// that would let any client spoof X-Forwarded-For. Fall back to trusting
|
||||
// no proxies, which is also the behavior when none are configured.
|
||||
slog.Error("api: invalid trusted proxies, trusting none", "error", err)
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
}
|
||||
|
||||
v1 := r.Group("/api/v1")
|
||||
v1.GET("/healthz", healthz)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// healthz is a liveness probe: always returns {"ok": true} when the server is up.
|
||||
func healthz(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterSPA serves the embedded single-page app for every route gin didn't
|
||||
// otherwise match. A request whose path maps to a real file (e.g. a hashed asset
|
||||
// bundle) is served directly; any other non-API path falls back to index.html so
|
||||
// client-side deep links like /gardens/42 load the app. Unmatched /api paths
|
||||
// return a JSON 404 rather than HTML.
|
||||
func RegisterSPA(r *gin.Engine, dist fs.FS) {
|
||||
fileServer := http.FileServer(http.FS(dist))
|
||||
|
||||
// index.html is served on every client-route deep link; read it once at
|
||||
// startup rather than re-reading the embedded FS per request. A build without
|
||||
// it is a packaging bug, so fail loudly.
|
||||
index, err := fs.ReadFile(dist, "index.html")
|
||||
if err != nil {
|
||||
panic("api: embedded web build missing index.html: " + err.Error())
|
||||
}
|
||||
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
reqPath := c.Request.URL.Path
|
||||
|
||||
// API paths (including a bare "/api") are never the SPA: a miss is a real
|
||||
// JSON 404, not the app shell.
|
||||
if reqPath == "/api" || strings.HasPrefix(reqPath, "/api/") {
|
||||
writeAPIError(c, http.StatusNotFound, "NOT_FOUND", "no such endpoint")
|
||||
return
|
||||
}
|
||||
|
||||
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
|
||||
writeAPIError(c, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
|
||||
name := strings.TrimPrefix(path.Clean(reqPath), "/")
|
||||
if name == "" {
|
||||
serveIndex(c, index)
|
||||
return
|
||||
}
|
||||
|
||||
if info, statErr := fs.Stat(dist, name); statErr == nil {
|
||||
// An embedded directory is neither a client route nor a servable file;
|
||||
// serving it would leak a directory listing, so it is a 404.
|
||||
if info.IsDir() {
|
||||
writeAPIError(c, http.StatusNotFound, "NOT_FOUND", "not found")
|
||||
return
|
||||
}
|
||||
// Vite emits content-hashed filenames under assets/, so those are safe
|
||||
// to cache forever; index.html must always revalidate to pick up new builds.
|
||||
if strings.HasPrefix(name, "assets/") {
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
}
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// A missing file under assets/ is a genuine 404, not a client route:
|
||||
// falling back to index.html would return HTML with a 200 for a .js/.css
|
||||
// request, which the browser rejects with a confusing MIME-type error.
|
||||
if strings.HasPrefix(name, "assets/") {
|
||||
writeAPIError(c, http.StatusNotFound, "NOT_FOUND", "asset not found")
|
||||
return
|
||||
}
|
||||
|
||||
serveIndex(c, index)
|
||||
})
|
||||
}
|
||||
|
||||
// serveIndex writes the cached index.html as the SPA fallback (no-cache so new
|
||||
// builds are picked up). net/http suppresses the body for HEAD requests.
|
||||
func serveIndex(c *gin.Context, index []byte) {
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", index)
|
||||
}
|
||||
|
||||
// writeAPIError writes pansy's standard JSON error envelope.
|
||||
func writeAPIError(c *gin.Context, status int, code, message string) {
|
||||
c.JSON(status, gin.H{"error": gin.H{"code": code, "message": message}})
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func testEngine() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
// A real API route so unmatched /api/* paths reach NoRoute (as in production).
|
||||
r.GET("/api/v1/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
|
||||
|
||||
dist := fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{Data: []byte(`<!doctype html><div id="root"></div>`)},
|
||||
"assets/app-abc123.js": &fstest.MapFile{Data: []byte(`console.log(1)`)},
|
||||
}
|
||||
RegisterSPA(r, dist)
|
||||
return r
|
||||
}
|
||||
|
||||
func do(t *testing.T, r *gin.Engine, method, path string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(method, path, nil))
|
||||
return w
|
||||
}
|
||||
|
||||
func TestSPAServesHashedAssetImmutable(t *testing.T) {
|
||||
w := do(t, testEngine(), http.MethodGet, "/assets/app-abc123.js")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if cc := w.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
|
||||
t.Errorf("Cache-Control = %q, want immutable", cc)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") {
|
||||
t.Errorf("Content-Type = %q, want javascript", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPAServesIndexNoCache(t *testing.T) {
|
||||
w := do(t, testEngine(), http.MethodGet, "/")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if cc := w.Header().Get("Cache-Control"); cc != "no-cache" {
|
||||
t.Errorf("Cache-Control = %q, want no-cache", cc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPAFallbackForClientRoute(t *testing.T) {
|
||||
// A deep link with no matching file returns index.html so client routing works.
|
||||
w := do(t, testEngine(), http.MethodGet, "/gardens/42")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `id="root"`) {
|
||||
t.Errorf("expected index.html body, got %q", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPAMissingAssetIs404(t *testing.T) {
|
||||
// A missing asset must NOT fall back to index.html (which would be a 200 of
|
||||
// HTML for a .js request — a MIME-type error in the browser).
|
||||
w := do(t, testEngine(), http.MethodGet, "/assets/missing-xyz.js")
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "json") {
|
||||
t.Errorf("Content-Type = %q, want json", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPADirectoryIs404NotListing(t *testing.T) {
|
||||
// A request that resolves to an embedded directory must 404, never a listing.
|
||||
for _, p := range []string{"/assets", "/assets/"} {
|
||||
w := do(t, testEngine(), http.MethodGet, p)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("GET %s status = %d, want 404", p, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPAUnmatchedAPIIs404JSON(t *testing.T) {
|
||||
w := do(t, testEngine(), http.MethodGet, "/api/v1/nope")
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", w.Code)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "json") {
|
||||
t.Errorf("Content-Type = %q, want json (not the SPA HTML)", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPARejectsNonGET(t *testing.T) {
|
||||
w := do(t, testEngine(), http.MethodPost, "/gardens")
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("status = %d, want 405", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Package config loads pansy's runtime configuration from the environment.
|
||||
//
|
||||
// Every value has a sensible default so `pansy` runs with zero configuration
|
||||
// for local use; production deployments override via PANSY_* env vars. Auth
|
||||
// values (local + OIDC) are plumbed here now and consumed by the auth issues
|
||||
// (#4 local, #5 OIDC).
|
||||
package config
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Registration gates local self-service signup.
|
||||
const (
|
||||
RegistrationOpen = "open"
|
||||
RegistrationClosed = "closed"
|
||||
)
|
||||
|
||||
// Config is the fully-resolved runtime configuration.
|
||||
type Config struct {
|
||||
// Port is the TCP port the HTTP server listens on (PANSY_PORT, default 8080).
|
||||
Port int
|
||||
// DBPath is the SQLite database file path (PANSY_DB, default ./pansy.db).
|
||||
DBPath string
|
||||
// BaseURL is the externally-visible base URL, used to derive the OIDC
|
||||
// redirect URI and absolute links (PANSY_BASE_URL). Empty in bare local dev.
|
||||
BaseURL string
|
||||
// Registration is "open" or "closed"; gates POST /auth/register for local
|
||||
// accounts (PANSY_REGISTRATION, default open). OIDC JIT provisioning ignores
|
||||
// this — the IdP gates access.
|
||||
Registration string
|
||||
// LocalAuth enables argon2id username/password auth (PANSY_LOCAL_AUTH,
|
||||
// default true). Set false for pure-Authentik deployments.
|
||||
LocalAuth bool
|
||||
// OIDC holds the optional OpenID Connect provider settings.
|
||||
OIDC OIDCConfig
|
||||
// TrustedProxies is the set of proxy CIDRs/IPs gin trusts for client IP
|
||||
// resolution (PANSY_TRUSTED_PROXIES, comma-separated). Empty trusts none.
|
||||
TrustedProxies []string
|
||||
}
|
||||
|
||||
// OIDCConfig holds the OpenID Connect provider settings (Authentik is the
|
||||
// primary IdP). Consumed by #5.
|
||||
type OIDCConfig struct {
|
||||
Issuer string // PANSY_OIDC_ISSUER — discovery base URL
|
||||
ClientID string // PANSY_OIDC_CLIENT_ID
|
||||
ClientSecret string // PANSY_OIDC_CLIENT_SECRET
|
||||
ButtonLabel string // PANSY_OIDC_BUTTON_LABEL — login-page button text
|
||||
}
|
||||
|
||||
// Enabled reports whether enough OIDC config is present to attempt discovery.
|
||||
func (o OIDCConfig) Enabled() bool {
|
||||
return o.Issuer != "" && o.ClientID != ""
|
||||
}
|
||||
|
||||
// RegistrationOpen reports whether local self-service signup is allowed.
|
||||
func (c *Config) RegistrationOpen() bool {
|
||||
return c.Registration == RegistrationOpen
|
||||
}
|
||||
|
||||
// Load reads configuration from the environment, applying defaults. It never
|
||||
// fails: invalid numeric/boolean values fall back to the default and are logged.
|
||||
func Load() *Config {
|
||||
cfg := &Config{
|
||||
Port: envInt("PANSY_PORT", 8080),
|
||||
DBPath: envStr("PANSY_DB", "./pansy.db"),
|
||||
BaseURL: strings.TrimRight(envStr("PANSY_BASE_URL", ""), "/"),
|
||||
Registration: envStr("PANSY_REGISTRATION", RegistrationOpen),
|
||||
LocalAuth: envBool("PANSY_LOCAL_AUTH", true),
|
||||
OIDC: OIDCConfig{
|
||||
Issuer: envStr("PANSY_OIDC_ISSUER", ""),
|
||||
ClientID: envStr("PANSY_OIDC_CLIENT_ID", ""),
|
||||
ClientSecret: envStr("PANSY_OIDC_CLIENT_SECRET", ""),
|
||||
ButtonLabel: envStr("PANSY_OIDC_BUTTON_LABEL", "Sign in with SSO"),
|
||||
},
|
||||
TrustedProxies: envList("PANSY_TRUSTED_PROXIES"),
|
||||
}
|
||||
|
||||
if cfg.Registration != RegistrationOpen && cfg.Registration != RegistrationClosed {
|
||||
slog.Warn("config: invalid PANSY_REGISTRATION, defaulting to open", "value", cfg.Registration)
|
||||
cfg.Registration = RegistrationOpen
|
||||
}
|
||||
|
||||
if cfg.Port < 1 || cfg.Port > 65535 {
|
||||
slog.Warn("config: PANSY_PORT out of range, using default", "value", cfg.Port, "default", 8080)
|
||||
cfg.Port = 8080
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func envStr(key, def string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envInt(key string, def int) int {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok || v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
slog.Warn("config: invalid int env, using default", "key", key, "value", v, "default", def)
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func envBool(key string, def bool) bool {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok || v == "" {
|
||||
return def
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
slog.Warn("config: invalid bool env, using default", "key", key, "value", v, "default", def)
|
||||
return def
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func envList(key string) []string {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok || v == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Package domain holds pansy's core types: plain structs mirroring the database
|
||||
// schema and the sentinel errors the service layer returns. It has no
|
||||
// dependencies on store, api, or any framework — every other package depends on
|
||||
// it, not the other way around.
|
||||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
// Sentinel errors returned by the service layer and mapped to HTTP status codes
|
||||
// by the API layer (404, 403, 409 respectively).
|
||||
var (
|
||||
// ErrNotFound means the requested entity does not exist (or is invisible to
|
||||
// the actor, when we deliberately mask existence).
|
||||
ErrNotFound = errors.New("not found")
|
||||
// ErrForbidden means the actor is known but lacks permission for the action.
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
// ErrVersionConflict means the row's version did not match the one supplied
|
||||
// with a PATCH/DELETE; the caller should refetch and retry.
|
||||
ErrVersionConflict = errors.New("version conflict")
|
||||
)
|
||||
|
||||
// Enumerated string values mirrored from the schema CHECK constraints.
|
||||
const (
|
||||
UnitMetric = "metric"
|
||||
UnitImperial = "imperial"
|
||||
|
||||
RoleViewer = "viewer"
|
||||
RoleEditor = "editor"
|
||||
|
||||
KindBed = "bed"
|
||||
KindGrowBag = "grow_bag"
|
||||
KindContainer = "container"
|
||||
KindInGround = "in_ground"
|
||||
KindTree = "tree"
|
||||
KindPath = "path"
|
||||
KindStructure = "structure"
|
||||
|
||||
ShapeRect = "rect"
|
||||
ShapeCircle = "circle"
|
||||
ShapePolygon = "polygon" // reserved for post-v1
|
||||
|
||||
CategoryVegetable = "vegetable"
|
||||
CategoryHerb = "herb"
|
||||
CategoryFlower = "flower"
|
||||
CategoryFruit = "fruit"
|
||||
CategoryTreeShrub = "tree_shrub"
|
||||
CategoryCover = "cover"
|
||||
)
|
||||
|
||||
// User is a pansy account. It may have a local password, OIDC identity, or both.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
PasswordHash *string `json:"-"` // never serialized
|
||||
OIDCIssuer *string `json:"-"`
|
||||
OIDCSubject *string `json:"-"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Session is a server-side session record. The raw token is never stored; only
|
||||
// its sha256 hash (the primary key) is.
|
||||
type Session struct {
|
||||
TokenHash string `json:"-"`
|
||||
UserID int64 `json:"userId"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Garden is a planning surface owned by one user, at real-world scale (cm).
|
||||
type Garden struct {
|
||||
ID int64 `json:"id"`
|
||||
OwnerID int64 `json:"ownerId"`
|
||||
Name string `json:"name"`
|
||||
WidthCM float64 `json:"widthCm"`
|
||||
HeightCM float64 `json:"heightCm"`
|
||||
UnitPref string `json:"unitPref"`
|
||||
Notes string `json:"notes"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// GardenShare grants a non-owner user viewer or editor access to a garden.
|
||||
type GardenShare struct {
|
||||
ID int64 `json:"id"`
|
||||
GardenID int64 `json:"gardenId"`
|
||||
UserID int64 `json:"userId"`
|
||||
Role string `json:"role"`
|
||||
CreatedBy int64 `json:"createdBy"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// GardenObject is any placeable object in a garden (bed, container, tree, path…).
|
||||
// Positioned by center point + rotation about center, in garden space.
|
||||
type GardenObject struct {
|
||||
ID int64 `json:"id"`
|
||||
GardenID int64 `json:"gardenId"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Shape string `json:"shape"`
|
||||
Points *string `json:"points,omitempty"` // reserved: JSON for polygons
|
||||
XCM float64 `json:"xCm"`
|
||||
YCM float64 `json:"yCm"`
|
||||
WidthCM float64 `json:"widthCm"`
|
||||
HeightCM float64 `json:"heightCm"`
|
||||
RotationDeg float64 `json:"rotationDeg"`
|
||||
ZIndex int `json:"zIndex"`
|
||||
Plantable bool `json:"plantable"`
|
||||
Color *string `json:"color,omitempty"`
|
||||
Props *string `json:"props,omitempty"` // kind-specific JSON
|
||||
Notes string `json:"notes"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Plant is a catalog entry. OwnerID nil means a read-only seeded built-in.
|
||||
type Plant struct {
|
||||
ID int64 `json:"id"`
|
||||
OwnerID *int64 `json:"ownerId,omitempty"` // nil = built-in
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
SpacingCM float64 `json:"spacingCm"`
|
||||
Color string `json:"color"`
|
||||
Icon string `json:"icon"`
|
||||
DaysToMaturity *int `json:"daysToMaturity,omitempty"`
|
||||
Notes string `json:"notes"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Planting ("plop") is a circular patch of one plant, positioned in its parent
|
||||
// object's local frame. Count nil means it is derived from area / spacing².
|
||||
type Planting struct {
|
||||
ID int64 `json:"id"`
|
||||
ObjectID int64 `json:"objectId"`
|
||||
PlantID int64 `json:"plantId"`
|
||||
XCM float64 `json:"xCm"`
|
||||
YCM float64 `json:"yCm"`
|
||||
RadiusCM float64 `json:"radiusCm"`
|
||||
Count *int `json:"count,omitempty"` // nil = derived
|
||||
Label *string `json:"label,omitempty"`
|
||||
PlantedAt *string `json:"plantedAt,omitempty"`
|
||||
RemovedAt *string `json:"removedAt,omitempty"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// migration is one numbered SQL file: version parsed from the filename prefix.
|
||||
type migration struct {
|
||||
version int
|
||||
name string
|
||||
sql string
|
||||
}
|
||||
|
||||
// Migrate applies every embedded migration whose version has not yet been
|
||||
// recorded, in ascending order, each in its own transaction. It is idempotent:
|
||||
// a second run with no new files is a no-op. Migration files are named
|
||||
// NNNN_description.sql (e.g. 0001_init.sql); NNNN is the version.
|
||||
func (d *DB) Migrate(ctx context.Context) error {
|
||||
if _, err := d.sql.ExecContext(ctx,
|
||||
`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY)`,
|
||||
); err != nil {
|
||||
return fmt.Errorf("store: create schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
applied, err := d.appliedVersions(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
migrations, err := loadMigrations()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
if applied[m.version] {
|
||||
continue
|
||||
}
|
||||
if err := d.applyMigration(ctx, m); err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Info("store: applied migration", "version", m.version, "name", m.name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DB) appliedVersions(ctx context.Context) (map[int]bool, error) {
|
||||
rows, err := d.sql.QueryContext(ctx, `SELECT version FROM schema_migrations`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: read schema_migrations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
applied := map[int]bool{}
|
||||
for rows.Next() {
|
||||
var v int
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, fmt.Errorf("store: scan schema_migrations: %w", err)
|
||||
}
|
||||
applied[v] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: iterate schema_migrations: %w", err)
|
||||
}
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
func (d *DB) applyMigration(ctx context.Context, m migration) error {
|
||||
tx, err := d.sql.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: begin migration %d: %w", m.version, err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // no-op after a successful commit
|
||||
|
||||
if _, err := tx.ExecContext(ctx, m.sql); err != nil {
|
||||
return fmt.Errorf("store: apply migration %d (%s): %w", m.version, m.name, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO schema_migrations (version) VALUES (?)`, m.version,
|
||||
); err != nil {
|
||||
return fmt.Errorf("store: record migration %d: %w", m.version, err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// loadMigrations reads and parses every embedded migration file, sorted by
|
||||
// version ascending.
|
||||
func loadMigrations() ([]migration, error) {
|
||||
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: read migrations dir: %w", err)
|
||||
}
|
||||
|
||||
var migrations []migration
|
||||
seen := map[int]string{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
version, err := parseVersion(e.Name())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if prev, dup := seen[version]; dup {
|
||||
return nil, fmt.Errorf("store: duplicate migration version %d: %q and %q", version, prev, e.Name())
|
||||
}
|
||||
seen[version] = e.Name()
|
||||
body, err := fs.ReadFile(migrationsFS, "migrations/"+e.Name())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: read migration %s: %w", e.Name(), err)
|
||||
}
|
||||
migrations = append(migrations, migration{version: version, name: e.Name(), sql: string(body)})
|
||||
}
|
||||
|
||||
sort.Slice(migrations, func(i, j int) bool {
|
||||
return migrations[i].version < migrations[j].version
|
||||
})
|
||||
return migrations, nil
|
||||
}
|
||||
|
||||
// parseVersion extracts the leading integer from a migration filename such as
|
||||
// "0001_init.sql".
|
||||
func parseVersion(name string) (int, error) {
|
||||
prefix, _, ok := strings.Cut(name, "_")
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("store: migration %q missing NNNN_ prefix", name)
|
||||
}
|
||||
v, err := strconv.Atoi(prefix)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: migration %q has non-numeric version: %w", name, err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
-- 0001_init.sql — pansy initial schema.
|
||||
--
|
||||
-- Conventions (see DESIGN.md § Domain model):
|
||||
-- * All measurements are centimeters (REAL); imperial is a display concern.
|
||||
-- * `version INTEGER NOT NULL DEFAULT 1` on every user-mutable row; PATCH/DELETE
|
||||
-- carry it and the service layer increments on write for 409 conflict detection.
|
||||
-- * Enumerations are enforced with CHECK constraints.
|
||||
-- * Timestamps are ISO-8601 TEXT (UTC); dates are 'YYYY-MM-DD' TEXT.
|
||||
-- * Foreign keys cascade on owner/parent deletion; enforcement is on (PRAGMA foreign_keys).
|
||||
|
||||
-- users -----------------------------------------------------------------------
|
||||
-- A user may authenticate locally (password_hash), via OIDC (oidc_issuer+subject),
|
||||
-- or both (linked by email on first OIDC login). First registered user is admin.
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
display_name TEXT NOT NULL,
|
||||
password_hash TEXT, -- argon2id; NULL for OIDC-only users
|
||||
oidc_issuer TEXT, -- NULL for local-only users
|
||||
oidc_subject TEXT,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0, 1)),
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
-- NULLs compare distinct in SQLite, so this only constrains real (issuer, subject) pairs.
|
||||
CREATE UNIQUE INDEX idx_users_oidc ON users (oidc_issuer, oidc_subject);
|
||||
|
||||
-- sessions --------------------------------------------------------------------
|
||||
-- Opaque bearer token stored only as a sha256 hash; the raw token lives in the
|
||||
-- HttpOnly cookie. 30-day sliding expiry (expires_at bumped on use).
|
||||
CREATE TABLE sessions (
|
||||
token_hash TEXT PRIMARY KEY, -- sha256 of the 32-byte random token
|
||||
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
CREATE INDEX idx_sessions_user ON sessions (user_id);
|
||||
|
||||
-- gardens ---------------------------------------------------------------------
|
||||
CREATE TABLE gardens (
|
||||
id INTEGER PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
width_cm REAL NOT NULL,
|
||||
height_cm REAL NOT NULL,
|
||||
unit_pref TEXT NOT NULL DEFAULT 'metric' CHECK (unit_pref IN ('metric', 'imperial')),
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
CREATE INDEX idx_gardens_owner ON gardens (owner_id);
|
||||
|
||||
-- garden_shares ---------------------------------------------------------------
|
||||
-- The owner is implicit via gardens.owner_id and never has a share row.
|
||||
CREATE TABLE garden_shares (
|
||||
id INTEGER PRIMARY KEY,
|
||||
garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('viewer', 'editor')),
|
||||
created_by INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE (garden_id, user_id)
|
||||
);
|
||||
CREATE INDEX idx_garden_shares_garden ON garden_shares (garden_id);
|
||||
CREATE INDEX idx_garden_shares_user ON garden_shares (user_id);
|
||||
|
||||
-- garden_objects --------------------------------------------------------------
|
||||
-- One polymorphic table for every placeable object. Positioned by CENTER point +
|
||||
-- rotation about center, in garden space (origin top-left, x→right, y→down).
|
||||
-- shape='polygon' and the points JSON column are reserved for post-v1; the v1
|
||||
-- editor emits only 'rect' and 'circle' (circle: width_cm = diameter).
|
||||
CREATE TABLE garden_objects (
|
||||
id INTEGER PRIMARY KEY,
|
||||
garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('bed', 'grow_bag', 'container', 'in_ground', 'tree', 'path', 'structure')),
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
shape TEXT NOT NULL DEFAULT 'rect' CHECK (shape IN ('rect', 'circle', 'polygon')),
|
||||
points TEXT, -- reserved: JSON [[x,y],...] for shape='polygon'
|
||||
x_cm REAL NOT NULL, -- center in garden space
|
||||
y_cm REAL NOT NULL,
|
||||
width_cm REAL NOT NULL,
|
||||
height_cm REAL NOT NULL,
|
||||
rotation_deg REAL NOT NULL DEFAULT 0, -- clockwise about center
|
||||
z_index INTEGER NOT NULL DEFAULT 0,
|
||||
plantable INTEGER NOT NULL DEFAULT 0 CHECK (plantable IN (0, 1)),
|
||||
color TEXT, -- optional hex override
|
||||
props TEXT, -- kind-specific extras (JSON)
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
CREATE INDEX idx_garden_objects_garden ON garden_objects (garden_id);
|
||||
|
||||
-- plants ----------------------------------------------------------------------
|
||||
-- Catalog. owner_id NULL = built-in (seeded, read-only; clone to customize).
|
||||
-- Users see built-ins plus their own.
|
||||
CREATE TABLE plants (
|
||||
id INTEGER PRIMARY KEY,
|
||||
owner_id INTEGER REFERENCES users (id) ON DELETE CASCADE, -- NULL = built-in
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL CHECK (category IN ('vegetable', 'herb', 'flower', 'fruit', 'tree_shrub', 'cover')),
|
||||
spacing_cm REAL NOT NULL, -- mature spacing
|
||||
color TEXT NOT NULL, -- hex
|
||||
icon TEXT NOT NULL DEFAULT '', -- emoji (zero image assets in v1)
|
||||
days_to_maturity INTEGER,
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
CREATE INDEX idx_plants_owner ON plants (owner_id);
|
||||
|
||||
-- plantings ("plops") ---------------------------------------------------------
|
||||
-- A circular patch of one plant, positioned in the PARENT OBJECT's local frame
|
||||
-- (origin at object center, unrotated axes) so moving/rotating the bed moves its
|
||||
-- plants for free. count NULL = derived max(1, round(pi*r^2 / spacing_cm^2)).
|
||||
-- "Clear bed" sets removed_at; v1 lists rows where removed_at IS NULL.
|
||||
CREATE TABLE plantings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
object_id INTEGER NOT NULL REFERENCES garden_objects (id) ON DELETE CASCADE,
|
||||
plant_id INTEGER NOT NULL REFERENCES plants (id) ON DELETE RESTRICT,
|
||||
x_cm REAL NOT NULL, -- center in object-local frame
|
||||
y_cm REAL NOT NULL,
|
||||
radius_cm REAL NOT NULL,
|
||||
count INTEGER, -- NULL = derived from area / spacing^2
|
||||
label TEXT,
|
||||
planted_at TEXT, -- 'YYYY-MM-DD'
|
||||
removed_at TEXT, -- 'YYYY-MM-DD'; NULL = currently planted
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
CREATE INDEX idx_plantings_object ON plantings (object_id);
|
||||
CREATE INDEX idx_plantings_plant ON plantings (plant_id);
|
||||
@@ -0,0 +1,106 @@
|
||||
// Package store provides pansy's durable persistence layer: a pure-Go SQLite
|
||||
// database (modernc.org/sqlite — no cgo, so the binary stays static) with a
|
||||
// tiny embedded migration runner. Entity query methods are added per feature
|
||||
// issue; this file only handles opening and the connection pragmas.
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite" // pure-Go driver, registered as "sqlite"
|
||||
)
|
||||
|
||||
// requiredPragmas are applied to every connection (they are per-connection in
|
||||
// SQLite): wait rather than fail on a contended write, use WAL journaling, and
|
||||
// enforce foreign keys. They are attached to the DSN so the whole pool inherits
|
||||
// them.
|
||||
var requiredPragmas = []string{
|
||||
"busy_timeout(5000)",
|
||||
"journal_mode(WAL)",
|
||||
"foreign_keys(1)",
|
||||
}
|
||||
|
||||
// DB is a handle to the SQLite database backing pansy. Safe for concurrent use:
|
||||
// WAL mode allows many readers with one writer, and busy_timeout makes a
|
||||
// contended writer wait rather than fail with SQLITE_BUSY.
|
||||
type DB struct {
|
||||
sql *sql.DB
|
||||
}
|
||||
|
||||
// Open opens (creating if absent) the SQLite database at path and returns a DB.
|
||||
// The pragmas — busy_timeout, WAL journal, and foreign-key enforcement — are
|
||||
// applied per connection via the DSN so every pooled connection inherits them,
|
||||
// regardless of whether path is a bare filename, a path with query parameters,
|
||||
// or a full file: URI. A path of ":memory:" yields an ephemeral in-memory
|
||||
// database (useful in tests); the pool is pinned to a single connection so all
|
||||
// queries see the same DB. The caller owns the returned DB and must Close it.
|
||||
func Open(path string) (*DB, error) {
|
||||
dsn, memory := buildDSN(path)
|
||||
sqldb, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: open %q: %w", path, err)
|
||||
}
|
||||
if memory {
|
||||
// Each new connection to a bare ":memory:" DSN gets its OWN empty database,
|
||||
// so a pooled second connection wouldn't see the migrated schema. Pin the
|
||||
// pool to one connection to keep an in-memory DB coherent.
|
||||
sqldb.SetMaxOpenConns(1)
|
||||
}
|
||||
if err := sqldb.Ping(); err != nil {
|
||||
sqldb.Close()
|
||||
return nil, fmt.Errorf("store: ping %q: %w", path, err)
|
||||
}
|
||||
return &DB{sql: sqldb}, nil
|
||||
}
|
||||
|
||||
// buildDSN converts the configured path into a modernc DSN that always carries
|
||||
// the required pragmas, and reports whether it is an in-memory database. It
|
||||
// accepts a bare filename, a path with existing query parameters, or a full
|
||||
// file: URI, and merges the pragmas without clobbering any the user supplied.
|
||||
func buildDSN(path string) (dsn string, memory bool) {
|
||||
body := strings.TrimPrefix(path, "file:")
|
||||
|
||||
filePart, rawQuery, _ := strings.Cut(body, "?")
|
||||
query := url.Values{}
|
||||
if q, err := url.ParseQuery(rawQuery); err == nil {
|
||||
query = q
|
||||
}
|
||||
|
||||
// Detect in-memory precisely: the exact ":memory:" token, or an explicit
|
||||
// mode=memory parameter. A substring match would misfire on real file paths.
|
||||
memory = filePart == ":memory:" || query.Get("mode") == "memory"
|
||||
|
||||
have := make(map[string]bool, len(query["_pragma"]))
|
||||
for _, p := range query["_pragma"] {
|
||||
have[pragmaName(p)] = true
|
||||
}
|
||||
for _, p := range requiredPragmas {
|
||||
if !have[pragmaName(p)] {
|
||||
query.Add("_pragma", p)
|
||||
}
|
||||
}
|
||||
|
||||
// A '#' in a filesystem path would otherwise start a URI fragment and drop
|
||||
// the query string that carries the pragmas.
|
||||
filePart = strings.ReplaceAll(filePart, "#", "%23")
|
||||
return "file:" + filePart + "?" + query.Encode(), memory
|
||||
}
|
||||
|
||||
// pragmaName extracts the pragma's name from a "_pragma" value such as
|
||||
// "busy_timeout(5000)" or "foreign_keys=1", lowercased for case-insensitive
|
||||
// de-duplication against a user-supplied value.
|
||||
func pragmaName(p string) string {
|
||||
if i := strings.IndexAny(p, "(="); i >= 0 {
|
||||
p = p[:i]
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(p))
|
||||
}
|
||||
|
||||
// Close closes the underlying database.
|
||||
func (d *DB) Close() error { return d.sql.Close() }
|
||||
|
||||
// SQL exposes the underlying *sql.DB for the per-entity query files.
|
||||
func (d *DB) SQL() *sql.DB { return d.sql }
|
||||
@@ -0,0 +1,180 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// openTestDB opens a fresh file-backed DB in a temp dir and migrates it.
|
||||
func openTestDB(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
db, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if err := db.Migrate(context.Background()); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestMigrateCreatesSchema(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
want := []string{
|
||||
"users", "sessions", "gardens", "garden_shares",
|
||||
"garden_objects", "plants", "plantings", "schema_migrations",
|
||||
}
|
||||
for _, table := range want {
|
||||
var name string
|
||||
err := db.SQL().QueryRow(
|
||||
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table,
|
||||
).Scan(&name)
|
||||
if err != nil {
|
||||
t.Errorf("expected table %q to exist: %v", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
var version int
|
||||
if err := db.SQL().QueryRow(`SELECT max(version) FROM schema_migrations`).Scan(&version); err != nil {
|
||||
t.Fatalf("read schema_migrations: %v", err)
|
||||
}
|
||||
if version != 1 {
|
||||
t.Errorf("schema version = %d, want 1", version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateIsIdempotent(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
// A second Migrate must be a no-op and leave exactly one recorded version.
|
||||
if err := db.Migrate(context.Background()); err != nil {
|
||||
t.Fatalf("second Migrate: %v", err)
|
||||
}
|
||||
var count int
|
||||
if err := db.SQL().QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&count); err != nil {
|
||||
t.Fatalf("count schema_migrations: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("schema_migrations rows = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForeignKeysEnforced(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
// The DSN sets foreign_keys(1) per connection; a planting referencing a
|
||||
// nonexistent object must be rejected. This guards the pragma actually
|
||||
// reaching the app's pooled connections, not just the sqlite3 CLI.
|
||||
_, err := db.SQL().Exec(
|
||||
`INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm) VALUES (999, 999, 0, 0, 10)`,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected foreign-key violation, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckConstraintRejectsBadEnum(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
|
||||
// Seed a user + garden so the FK is satisfied and only the CHECK can fail.
|
||||
res, err := db.SQL().Exec(
|
||||
`INSERT INTO users (email, display_name) VALUES ('[email protected]', 'A')`)
|
||||
if err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
uid, _ := res.LastInsertId()
|
||||
res, err = db.SQL().Exec(
|
||||
`INSERT INTO gardens (owner_id, name, width_cm, height_cm) VALUES (?, 'G', 100, 100)`, uid)
|
||||
if err != nil {
|
||||
t.Fatalf("insert garden: %v", err)
|
||||
}
|
||||
gid, _ := res.LastInsertId()
|
||||
|
||||
// kind='invalid' violates the CHECK constraint.
|
||||
_, err = db.SQL().Exec(
|
||||
`INSERT INTO garden_objects (garden_id, kind, x_cm, y_cm, width_cm, height_cm)
|
||||
VALUES (?, 'invalid', 0, 0, 10, 10)`, gid)
|
||||
if err == nil {
|
||||
t.Fatal("expected CHECK constraint violation for bad kind, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForeignKeysEnforcedForFileURIDSN(t *testing.T) {
|
||||
// A file: URI (or any path with query params) must still get foreign_keys(1):
|
||||
// the earlier passthrough silently dropped the pragmas for these DSNs.
|
||||
p := filepath.Join(t.TempDir(), "uri.db")
|
||||
db, err := Open("file:" + p + "?_pragma=synchronous(1)")
|
||||
if err != nil {
|
||||
t.Fatalf("Open file: URI: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if err := db.Migrate(context.Background()); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
_, err = db.SQL().Exec(
|
||||
`INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm) VALUES (999, 999, 0, 0, 10)`,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected foreign-key violation for file: URI DSN, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDSNAlwaysIncludesPragmas(t *testing.T) {
|
||||
for _, in := range []string{
|
||||
"./pansy.db",
|
||||
"/data/pansy.db",
|
||||
"file:/data/pansy.db",
|
||||
"file:/data/pansy.db?cache=shared",
|
||||
"/tmp/weird?name.db",
|
||||
} {
|
||||
dsn, _ := buildDSN(in)
|
||||
for _, want := range []string{"busy_timeout", "journal_mode", "foreign_keys"} {
|
||||
if !strings.Contains(dsn, want) {
|
||||
t.Errorf("buildDSN(%q) = %q, missing %s pragma", in, dsn, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDSNDoesNotDuplicateUserPragma(t *testing.T) {
|
||||
// A user-supplied busy_timeout must not be duplicated by our default.
|
||||
dsn, _ := buildDSN("file:/data/x.db?_pragma=busy_timeout(9000)")
|
||||
if strings.Count(dsn, "busy_timeout") != 1 {
|
||||
t.Errorf("buildDSN kept both user and default busy_timeout: %q", dsn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDSNMemoryDetection(t *testing.T) {
|
||||
if _, mem := buildDSN(":memory:"); !mem {
|
||||
t.Error(":memory: not detected as in-memory")
|
||||
}
|
||||
if _, mem := buildDSN("file:x.db?mode=memory"); !mem {
|
||||
t.Error("mode=memory not detected as in-memory")
|
||||
}
|
||||
if _, mem := buildDSN("/var/lib/pansy/pansy.db"); mem {
|
||||
t.Error("file path wrongly detected as in-memory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInMemoryDBIsCoherentAcrossQueries(t *testing.T) {
|
||||
// A bare ":memory:" DSN gives each connection its own DB; Open must pin the
|
||||
// pool so the migrated schema is visible to subsequent queries.
|
||||
db, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Open(:memory:): %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.Migrate(context.Background()); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
var name string
|
||||
if err := db.SQL().QueryRow(
|
||||
`SELECT name FROM sqlite_master WHERE type='table' AND name='gardens'`,
|
||||
).Scan(&name); err != nil {
|
||||
t.Fatalf("in-memory schema not visible: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Package webdist embeds the production web build so pansy ships as one static
|
||||
// binary. The Makefile syncs web/dist into this package's dist/ directory before
|
||||
// `go build`; a minimal placeholder dist/index.html is committed so the module
|
||||
// builds without a web build present (the Makefile overwrites it).
|
||||
package webdist
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var embedded embed.FS
|
||||
|
||||
// Dist returns the embedded web build rooted at its top level (index.html,
|
||||
// assets/…), ready to hand to api.RegisterSPA.
|
||||
func Dist() fs.FS {
|
||||
sub, err := fs.Sub(embedded, "dist")
|
||||
if err != nil {
|
||||
// Only possible if the embed directive and this path disagree — a build-time bug.
|
||||
panic("webdist: dist subtree missing: " + err.Error())
|
||||
}
|
||||
return sub
|
||||
}
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>pansy</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Placeholder shell. `make web` replaces this directory with the real Vite
|
||||
build (web/dist). Serving this file means the binary was built without a
|
||||
web build; run `make build` for the full app. -->
|
||||
<main style="font-family: system-ui, sans-serif; max-width: 40rem; margin: 4rem auto; padding: 0 1rem;">
|
||||
<h1>pansy</h1>
|
||||
<p>The web build is not embedded in this binary. Run <code>make build</code> to bundle the frontend.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
.vite/
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>pansy</title>
|
||||
<meta name="description" content="Self-hostable garden planner — plan beds, containers, and plops of plants at real scale." />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2701
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "pansy-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Pansy garden planner — web frontend.",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"@tanstack/react-router": "^1.95.0",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** Placeholder page scaffolding used until each feature issue fills these in. */
|
||||
export function PageStub({ title, children }: { title: string; children?: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<p className="mt-2 text-sm text-muted">
|
||||
{children ?? 'Placeholder — this page arrives in a later issue.'}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Link, Outlet } from '@tanstack/react-router'
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/gardens', label: 'Gardens' },
|
||||
{ to: '/plants', label: 'Plants' },
|
||||
] as const
|
||||
|
||||
// TanStack Router concatenates the base className with activeProps/inactiveProps,
|
||||
// so state-specific and conflicting utilities (text-muted vs text-fg) live in the
|
||||
// state props — never in the base — to avoid ambiguous overrides.
|
||||
const navLinkBase = 'rounded-md px-3 py-1.5 text-sm font-medium transition-colors'
|
||||
const navLinkActive = 'bg-border/60 text-fg'
|
||||
const navLinkInactive = 'text-muted hover:bg-border/60 hover:text-fg'
|
||||
|
||||
/** Top-level chrome: a sticky nav bar plus the routed page in an <Outlet>. */
|
||||
export function AppShell() {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-surface/90 backdrop-blur">
|
||||
<nav className="mx-auto flex max-w-5xl items-center gap-4 px-4 py-3">
|
||||
<Link to="/gardens" className="text-lg font-semibold text-accent-strong">
|
||||
🌱 pansy
|
||||
</Link>
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
{navLinks.map((l) => (
|
||||
<Link
|
||||
key={l.to}
|
||||
to={l.to}
|
||||
className={navLinkBase}
|
||||
activeProps={{ className: navLinkActive }}
|
||||
inactiveProps={{ className: navLinkInactive }}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
to="/login"
|
||||
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:text-fg"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Typed fetch wrapper for the pansy JSON API under /api/v1.
|
||||
//
|
||||
// Every non-2xx response throws an ApiError carrying the HTTP status and the
|
||||
// parsed response body. That contract matters beyond this issue: the optimistic
|
||||
// editor (per DESIGN.md § Sync) sends each PATCH/DELETE with a row `version` and,
|
||||
// on a 409, reads the *current* row back out of `ApiError.body` to reconcile.
|
||||
|
||||
const BASE = '/api/v1'
|
||||
|
||||
/** Error thrown for any non-2xx response (or a network failure, status 0). */
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
/** Parsed response body (JSON object, string, or null). On a 409 this is the current server row. */
|
||||
readonly body: unknown
|
||||
|
||||
constructor(message: string, status: number, body: unknown) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
|
||||
/** A version conflict (DESIGN.md § Sync); `body` holds the current server row. */
|
||||
get isConflict(): boolean {
|
||||
return this.status === 409
|
||||
}
|
||||
|
||||
/** No authenticated session; callers typically redirect to /login. */
|
||||
get isUnauthorized(): boolean {
|
||||
return this.status === 401
|
||||
}
|
||||
}
|
||||
|
||||
type ParamValue = string | number | boolean | undefined | null
|
||||
export type Params = Record<string, ParamValue>
|
||||
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||
/** JSON request body; serialized and sent with a JSON content-type. */
|
||||
body?: unknown
|
||||
/** Query-string parameters; undefined/null/'' entries are omitted. */
|
||||
params?: Params
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
function buildUrl(path: string, params?: Params): string {
|
||||
const url = BASE + path
|
||||
if (!params) return url
|
||||
const qs = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null || v === '') continue
|
||||
qs.set(k, String(v))
|
||||
}
|
||||
const s = qs.toString()
|
||||
return s ? `${url}?${s}` : url
|
||||
}
|
||||
|
||||
async function parseBody(res: Response): Promise<unknown> {
|
||||
if (res.status === 204) return null
|
||||
const text = await res.text()
|
||||
if (!text) return null
|
||||
const contentType = res.headers.get('content-type') ?? ''
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function messageFrom(body: unknown, status: number): string {
|
||||
if (body && typeof body === 'object') {
|
||||
const err = (body as { error?: { message?: string } }).error
|
||||
if (err?.message) return err.message
|
||||
const msg = (body as { message?: string }).message
|
||||
if (msg) return msg
|
||||
}
|
||||
return `Request failed (${status})`
|
||||
}
|
||||
|
||||
/** Perform a request against /api/v1 and return the parsed JSON body as T. */
|
||||
export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const { method = 'GET', body, params, signal } = opts
|
||||
|
||||
// Serialize before the try so a JSON.stringify failure (e.g. a circular value)
|
||||
// surfaces as itself, not as a misleading "cannot reach the server" error.
|
||||
const requestBody = body !== undefined ? JSON.stringify(body) : undefined
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(buildUrl(path, params), {
|
||||
method,
|
||||
signal,
|
||||
credentials: 'same-origin', // send the HttpOnly session cookie
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
||||
},
|
||||
body: requestBody,
|
||||
})
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name === 'AbortError') throw err
|
||||
throw new ApiError('Cannot reach the pansy server.', 0, null)
|
||||
}
|
||||
|
||||
const payload = await parseBody(res)
|
||||
if (!res.ok) {
|
||||
throw new ApiError(messageFrom(payload, res.status), res.status, payload)
|
||||
}
|
||||
return payload as T
|
||||
}
|
||||
|
||||
/** Convenience verbs over apiFetch. */
|
||||
export const api = {
|
||||
get: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
apiFetch<T>(path, { ...opts, method: 'GET' }),
|
||||
post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
apiFetch<T>(path, { ...opts, method: 'POST', body }),
|
||||
patch: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
apiFetch<T>(path, { ...opts, method: 'PATCH', body }),
|
||||
delete: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
apiFetch<T>(path, { ...opts, method: 'DELETE' }),
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
/** Merge conditional class names, resolving Tailwind conflicts (last wins). */
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import { router } from './router'
|
||||
import './styles/index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, staleTime: 30_000 },
|
||||
},
|
||||
})
|
||||
|
||||
const rootEl = document.getElementById('root')
|
||||
if (!rootEl) throw new Error('Missing #root element')
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import { PageStub } from '@/components/PageStub'
|
||||
|
||||
const routeApi = getRouteApi('/gardens/$gardenId')
|
||||
|
||||
export function GardenEditorPage() {
|
||||
const { gardenId } = routeApi.useParams()
|
||||
return (
|
||||
<PageStub title={`Garden ${gardenId}`}>
|
||||
The field editor (canvas, palette, plops) arrives across issues #9–#15.
|
||||
</PageStub>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageStub } from '@/components/PageStub'
|
||||
|
||||
export function GardensPage() {
|
||||
return <PageStub title="Gardens">The garden list and create flow arrive in issue #8.</PageStub>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageStub } from '@/components/PageStub'
|
||||
|
||||
export function LoginPage() {
|
||||
return <PageStub title="Sign in">Login form and SSO button arrive in issue #6.</PageStub>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageStub } from '@/components/PageStub'
|
||||
|
||||
export function PlantsPage() {
|
||||
return <PageStub title="Plants">The plant catalog arrives in issue #13.</PageStub>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageStub } from '@/components/PageStub'
|
||||
|
||||
export function RegisterPage() {
|
||||
return <PageStub title="Create account">Registration form arrives in issue #6.</PageStub>
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
redirect,
|
||||
} from '@tanstack/react-router'
|
||||
import { AppShell } from '@/components/layout/AppShell'
|
||||
import { LoginPage } from '@/pages/LoginPage'
|
||||
import { RegisterPage } from '@/pages/RegisterPage'
|
||||
import { GardensPage } from '@/pages/GardensPage'
|
||||
import { GardenEditorPage } from '@/pages/GardenEditorPage'
|
||||
import { PlantsPage } from '@/pages/PlantsPage'
|
||||
|
||||
const rootRoute = createRootRoute({ component: AppShell })
|
||||
|
||||
// The root path has no page of its own yet; send it to the gardens list. A real
|
||||
// auth-aware landing/redirect lands with the route guard in issue #6.
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/gardens' })
|
||||
},
|
||||
})
|
||||
|
||||
const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: 'login', component: LoginPage })
|
||||
const registerRoute = createRoute({ getParentRoute: () => rootRoute, path: 'register', component: RegisterPage })
|
||||
const gardensRoute = createRoute({ getParentRoute: () => rootRoute, path: 'gardens', component: GardensPage })
|
||||
const gardenEditorRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'gardens/$gardenId',
|
||||
component: GardenEditorPage,
|
||||
})
|
||||
const plantsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'plants', component: PlantsPage })
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
loginRoute,
|
||||
registerRoute,
|
||||
gardensRoute,
|
||||
gardenEditorRoute,
|
||||
plantsRoute,
|
||||
])
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: 'intent',
|
||||
})
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Garden-planner theme tokens. Each --color-* becomes a Tailwind utility
|
||||
(bg-*, text-*, border-*). Light by default; dark via prefers-color-scheme. */
|
||||
@theme {
|
||||
--color-bg: #f8faf7;
|
||||
--color-surface: #ffffff;
|
||||
--color-border: #e3e8e0;
|
||||
--color-muted: #61705d;
|
||||
--color-fg: #1c2419;
|
||||
|
||||
--color-accent: #3f8f4f;
|
||||
--color-accent-strong: #2f7a3e;
|
||||
--color-accent-contrast: #ffffff;
|
||||
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-fg);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark theme: override the same tokens so utilities recolor automatically. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@theme {
|
||||
--color-bg: #10140f;
|
||||
--color-surface: #171c15;
|
||||
--color-border: #2a3226;
|
||||
--color-muted: #8a967f;
|
||||
--color-fg: #e7ede2;
|
||||
|
||||
--color-accent: #5bb06a;
|
||||
--color-accent-strong: #6fc07d;
|
||||
--color-accent-contrast: #0c1109;
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// In dev, Vite serves the SPA with HMR and proxies /api to the Go backend. The
|
||||
// backend listens on PANSY_PORT (default 8080); read it so the proxy target
|
||||
// tracks an overridden port. The production build is embedded into the Go binary
|
||||
// (see internal/webdist), so no proxy is involved there.
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const backendPort = env.PANSY_PORT || process.env.PANSY_PORT || '8080'
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: `http://127.0.0.1:${backendPort}`,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user