Phase 0: backend + frontend scaffold, single-binary build
Implements the Pansy v1 scaffold (epic #20, phase 0). #1 Backend: Go module (pure-Go, builds with CGO_ENABLED=0), env config, gin server with slog + recovery + /api/v1/healthz, modernc SQLite store with WAL/busy_timeout/foreign_keys pragmas, embedded numbered-migration runner, full initial schema (users, sessions, gardens, garden_shares, garden_objects, plants, plantings), domain structs + sentinel errors, graceful shutdown. #2 Frontend: Vite + React 19 + TS (strict) + Tailwind 4 + TanStack Router/Query, typed /api/v1 fetch wrapper (ApiError carries status + body so later issues can read 409 conflict rows), dev proxy /api -> :8080, responsive app shell with stub pages for all five routes. #3 Single binary: //go:embed of the web build with a committed placeholder, SPA fallback (deep links, immutable asset caching, JSON 404 for unmatched /api and missing assets), Makefile (web/build/dev/test), README quickstart + env var table. Verified: go build/vet/test clean (CGO off); binary migrates idempotently and serves healthz; web tsc + build clean; integrated binary serves the SPA, deep links, and correct cache headers; app mounts and navigates all five routes at desktop and 375px widths. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
+18
@@ -25,3 +25,21 @@ go.work.sum
|
|||||||
# env file
|
# env file
|
||||||
.env
|
.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.
|
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,44 @@
|
|||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Quiet gin's default debug output; we log via slog-gin instead.
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(sloggin.New(slog.Default()), gin.Recovery())
|
||||||
|
|
||||||
|
if err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {
|
||||||
|
slog.Warn("api: failed to set trusted proxies", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,78 @@
|
|||||||
|
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))
|
||||||
|
|
||||||
|
r.NoRoute(func(c *gin.Context) {
|
||||||
|
reqPath := c.Request.URL.Path
|
||||||
|
|
||||||
|
if strings.HasPrefix(reqPath, "/api/") {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{
|
||||||
|
"code": "NOT_FOUND", "message": "no such endpoint",
|
||||||
|
}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
|
||||||
|
c.JSON(http.StatusMethodNotAllowed, gin.H{"error": gin.H{
|
||||||
|
"code": "METHOD_NOT_ALLOWED", "message": "method not allowed",
|
||||||
|
}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimPrefix(path.Clean(reqPath), "/")
|
||||||
|
if name == "" {
|
||||||
|
name = "index.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
if f, err := dist.Open(name); err == nil {
|
||||||
|
f.Close()
|
||||||
|
// 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/") {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{
|
||||||
|
"code": "NOT_FOUND", "message": "asset not found",
|
||||||
|
}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serveIndex(c, dist)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveIndex writes index.html as the SPA fallback for an unmatched client route.
|
||||||
|
func serveIndex(c *gin.Context, dist fs.FS) {
|
||||||
|
data, err := fs.ReadFile(dist, "index.html")
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "index.html missing from build")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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 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,136 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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,135 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
return applied, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
version, err := parseVersion(e.Name())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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,68 @@
|
|||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// 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 turns a filesystem path (or ":memory:") into a modernc DSN carrying
|
||||||
|
// the pragmas we want on every connection, and reports whether it is in-memory.
|
||||||
|
// A path that already looks like a DSN (has a scheme or query) is passed through
|
||||||
|
// untouched.
|
||||||
|
func buildDSN(path string) (dsn string, memory bool) {
|
||||||
|
memory = path == ":memory:" || strings.Contains(path, ":memory:") || strings.Contains(path, "mode=memory")
|
||||||
|
if strings.HasPrefix(path, "file:") || strings.Contains(path, "?") {
|
||||||
|
return path, memory
|
||||||
|
}
|
||||||
|
pragmas := url.Values{}
|
||||||
|
pragmas.Add("_pragma", "busy_timeout(5000)")
|
||||||
|
pragmas.Add("_pragma", "journal_mode(WAL)")
|
||||||
|
pragmas.Add("_pragma", "foreign_keys(1)")
|
||||||
|
return "file:" + path + "?" + pragmas.Encode(), memory
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,122 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"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 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,46 @@
|
|||||||
|
import { Link, Outlet } from '@tanstack/react-router'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
|
const navLinks = [
|
||||||
|
{ to: '/gardens', label: 'Gardens' },
|
||||||
|
{ to: '/plants', label: 'Plants' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** 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={cn(
|
||||||
|
'rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:bg-border/60 hover:text-fg',
|
||||||
|
)}
|
||||||
|
activeProps={{ className: 'bg-border/60 text-fg' }}
|
||||||
|
>
|
||||||
|
{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,121 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
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: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
} 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