Photograph a seed packet → it fills in the plant and the purchase. This is the backend; the scan UI is a follow-up PR. Vision model config (mirrors the agent model from #79): - Migration 0011 adds instance_settings.vision_model; PANSY_VISION_MODEL is the env default. Precedence Settings → env → empty; the KEY stays in the env. - EffectiveVision resolves it; /capabilities advertises "vision" only when a model + key are configured, so the UI offers the scan button only when it works. Extraction is one-shot, NOT an agent loop (internal/vision): - majordomo.Generate[SeedPacket] derives a JSON schema from the struct tags and hands the image to the vision model; it can't call a tool, so it can't touch the garden — it only reads a picture and returns data. Numeric fields are pointers, so a field the packet doesn't print comes back nil, not a made-up 0. - Hermetic test: majordomo's fake provider returns canned packet JSON and Generate unmarshals it, image + derived schema included. No live model. The image is normalized to JPEG at the upload boundary (imagenorm from #80), which is where an iPhone HEIC becomes readable — majordomo's media path can't decode HEIC. imagenorm now links into the binary (~7 MB, the cost #80 deferred). The hard part is catalog matching, not OCR (internal/service/seed_packet.go): - A wrong auto-match splits a variety's seed-lot history across duplicate rows, so the service NEVER auto-creates. matchPlants surfaces RANKED candidates (exact name → variety-in-name → same species, conservative and name-based), the user confirms, and CreateFromPacket makes the plant (new or existing) + the lot. Exactly one of plantId/newPlant, refused otherwise. - Plants/lots aren't in the undo history (catalog/inventory), so no change set. - The extractor is injectable (service.WithPacketExtractor) so ExtractSeedPacket and the /scan endpoint test end to end against a fake, no live model. Endpoints: POST /seed-lots/scan (multipart image → proposal, reads only; extends the read deadline for a slow phone upload, caps the body, maps too-large/unreadable to clear statuses) and POST /seed-lots/from-packet (confirmed proposal → 201). Docs: README (PANSY_VISION_MODEL), DESIGN (routes + the decision and why the model can't touch the garden). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
225 lines
10 KiB
Go
225 lines
10 KiB
Go
// 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})
|
|
}
|