// 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" "gitea.stevedudenhoeffer.com/steve/pansy/internal/service" ) // handlers carries the dependencies shared by every HTTP handler. Handlers stay // thin: decode the request, call a service method, encode the result. type handlers struct { cfg *config.Config svc *service.Service oidc *oidcClient // nil unless OIDC is configured (see config.OIDCReady) } // New builds the gin engine with the standard middleware stack and registers the // API routes against the given service. 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, svc *service.Service) *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) } h := &handlers{cfg: cfg, svc: svc} v1 := r.Group("/api/v1") // CSRF defense for every state-changing API call (no-op unless PANSY_BASE_URL // is set; see csrfGuard). v1.Use(h.csrfGuard()) v1.GET("/healthz", healthz) // Auth endpoints are exempt from requireAuth (you can't be logged in yet); // /me is the one that needs a session. Feature routers in later issues attach // h.requireAuth() to their own protected groups. auth := v1.Group("/auth") auth.POST("/register", h.register) auth.POST("/login", h.login) auth.POST("/logout", h.logout) auth.GET("/providers", h.providers) auth.GET("/me", h.requireAuth(), h.me) // OIDC routes exist only when OIDC can actually be offered, so an unconfigured // instance 404s them (matching what /auth/providers advertises). Provider // discovery is lazy (first request), so a briefly-unreachable IdP doesn't stop // the server — or local auth — from starting. switch { case cfg.OIDCReady(): h.oidc = newOIDCClient(cfg) auth.GET("/oidc/login", h.oidcLogin) auth.GET("/oidc/callback", h.oidcCallback) case cfg.OIDC.Enabled(): slog.Warn("api: OIDC is configured but PANSY_BASE_URL is unset; OIDC disabled (an absolute redirect URI is required)") } // Feature resources sit behind requireAuth, which resolves the session cookie // to the actor the service layer's permission checks key off. gardens := v1.Group("/gardens", h.requireAuth()) gardens.GET("", h.listGardens) gardens.POST("", h.createGarden) gardens.GET("/:id", h.getGarden) gardens.PATCH("/:id", h.updateGarden) gardens.DELETE("/:id", h.deleteGarden) gardens.GET("/:id/full", h.getGardenFull) // one-shot editor load gardens.POST("/:id/objects", h.createObject) // Objects are addressed by their own id; the service resolves the owning // garden for the permission check. objects := v1.Group("/objects", h.requireAuth()) objects.PATCH("/:id", h.updateObject) objects.DELETE("/:id", h.deleteObject) // Plant catalog: built-ins (seeded, read-only) plus the actor's own rows. plants := v1.Group("/plants", h.requireAuth()) plants.GET("", h.listPlants) plants.POST("", h.createPlant) plants.PATCH("/:id", h.updatePlant) plants.DELETE("/:id", h.deletePlant) 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}) }