// 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 ( "context" "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) // agent holds the live Runner behind an atomic pointer. Unlike oidc it is // never nil — the holder is always present and its Runner may be nil when the // assistant is off. The chat routes are registered unconditionally and // nil-check agent.get(), so a settings change can turn the assistant on or off // at runtime (#79) rather than only at boot. agent *agentHolder } // 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) // What this instance can actually do, so the UI offers only what works. // Registered after the agent below, because it reports whether the runner // actually built — not merely whether it was configured to. v1.GET("/capabilities", h.capabilities) // 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.POST("/:id/copy", h.copyGarden) // duplicate a garden the actor owns gardens.GET("/:id/full", h.getGardenFull) // one-shot editor load gardens.GET("/:id/history", h.getGardenHistory) // change sets, newest first gardens.GET("/:id/years", h.getGardenYears) // years with planting data gardens.POST("/:id/objects", h.createObject) // The grow journal hangs off a garden even for entries about one bed or one // plop, so it inherits the ordinary garden-role check. gardens.GET("/:id/journal", h.listJournal) gardens.POST("/:id/journal", h.createJournalEntry) gardens.GET("/:id/journal/counts", h.getJournalCounts) // Sharing (owner-managed; a recipient may remove their own share). gardens.GET("/:id/shares", h.listShares) gardens.POST("/:id/shares", h.addShare) gardens.PATCH("/:id/shares/:userId", h.updateShare) gardens.DELETE("/:id/shares/:userId", h.removeShare) // Public read-only share link (owner-managed): GET reports state, POST // enables/rotates, DELETE disables. The link itself is served unauthenticated // below. gardens.GET("/:id/share-link", h.getShareLink) gardens.POST("/:id/share-link", h.createShareLink) gardens.DELETE("/:id/share-link", h.deleteShareLink) // 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) objects.POST("/:id/plantings", h.createPlanting) // place a plop in this object // Bulk ops. These wrap the same service methods the agent tools call, so an // instance with no model configured still gets the most valuable operation in // the app — and so "clear bed" is ONE change set rather than one per plop. objects.POST("/:id/fill", h.fillObject) objects.POST("/:id/clear", h.clearObject) // Plantings ("plops") are addressed by their own id; the service resolves the // owning object/garden for the permission check. plantings := v1.Group("/plantings", h.requireAuth()) plantings.PATCH("/:id", h.updatePlanting) plantings.DELETE("/:id", h.deletePlanting) // The garden assistant. Its routes are registered UNCONDITIONALLY and the live // Runner sits behind an atomic pointer in the holder, so a settings change can // turn the assistant on or off at runtime (#79). Each handler nil-checks // agent.get(); a chat request while the assistant is off gets a clean 503 // (AGENT_DISABLED), not a panic and not a missing route. // // The holder resolves its initial Runner from settings + environment at // construction. If the key never reaches the container, the assistant is off // and the reason is logged below — the same operability need #72 added. h.agent = newAgentHolder(context.Background(), svc) if cfg.Agent.OllamaCloudAPIKey == "" { slog.Info("api: garden assistant has no API key", "hint", "set OLLAMA_CLOUD_API_KEY in the container's environment (not just the stack's); the model can be chosen in Settings") } agentGroup := v1.Group("/agent", h.requireAuth()) agentGroup.POST("/chat", h.agentChat) gardens.GET("/:id/agent/history", h.getAgentHistory) gardens.DELETE("/:id/agent/history", h.deleteAgentHistory) // Instance settings: admin-only, and the first thing to enforce is_admin. // requireAdmin runs after requireAuth (it reads the actor requireAuth stored). settings := v1.Group("/settings", h.requireAuth(), h.requireAdmin()) settings.GET("", h.getSettings) settings.PATCH("", h.updateSettings) // Undo. A change set is addressed by its own id; the service resolves the // owning garden for the permission check, same as objects and plantings. changeSets := v1.Group("/change-sets", h.requireAuth()) changeSets.POST("/:id/revert", h.revertChangeSet) // Journal entries are addressed by their own id; the service resolves the // owning garden for the permission check, same as objects and plantings. journal := v1.Group("/journal", h.requireAuth()) journal.PATCH("/:id", h.updateJournalEntry) journal.DELETE("/:id", h.deleteJournalEntry) // 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) // Seed lots: what the actor bought, and what's left. Private to the buyer, // so these hang off the session actor rather than off a garden. seedLots := v1.Group("/seed-lots", h.requireAuth()) seedLots.GET("", h.listSeedLots) seedLots.POST("", h.createSeedLot) seedLots.GET("/:id", h.getSeedLot) seedLots.PATCH("/:id", h.updateSeedLot) seedLots.DELETE("/:id", h.deleteSeedLot) // Seed-packet capture (#81): scan a photo into a proposal, then create the // plant + lot from the confirmed proposal. scan reads only. seedLots.POST("/scan", h.scanSeedPacket) seedLots.POST("/from-packet", h.createFromPacket) // Public, unauthenticated read of a garden by its share token. Deliberately // NOT behind requireAuth: the token is the capability, so a logged-out visitor // opens a shared link without being redirected to /login or OIDC. Only GET, // only the read-only /full payload — never a mutation. public := v1.Group("/public") public.GET("/gardens/:token", h.getPublicGarden) return r } // capabilities reports what this instance can actually do, so the UI offers only // what works. // // It reports whether the assistant is live RIGHT NOW, not merely configured: // the chat routes always exist, but a request while the Runner is nil is refused, // so offering the tab must track the live Runner. Reading agent.get() (an atomic // load) means this reflects a settings-driven swap on the very next poll. func (h *handlers) capabilities(c *gin.Context) { // vision advertises whether seed-packet scanning (#81) can be offered — a // configured, resolvable vision model + a key. Read per-request so a settings // change is reflected on the next poll, same as agent. vision := false if vis, err := h.svc.EffectiveVision(c.Request.Context()); err == nil { vision = vis.Ready() } c.JSON(http.StatusOK, gin.H{"agent": h.agent.get() != nil, "vision": vision}) } // 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}) }