From f3b0ca572d76db7281ba9a2be0767a58c65ccb40 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 19 Jul 2026 02:04:11 -0400 Subject: [PATCH 1/2] Public read-only share link (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A per-garden public link anyone can open read-only, with no login and no OIDC. Backend: - Migration 0004 adds gardens.public_token (nullable, unique over non-NULL). - Owner-only management: GET/POST/DELETE /gardens/:id/share-link (state / enable-or-rotate / disable). The token is stored raw (it must be shown back to the owner) and never selected into the normal garden payload. - Unauthenticated GET /api/v1/public/gardens/:token returns the read-only /full payload — the token is the capability, so no requireAuth and no redirect. The public view drops MyRole and OwnerID to minimize what an anonymous viewer learns. Unknown/rotated/disabled tokens are masked as 404. - assembleFull is extracted from GardenFull so both reads return one shape. Frontend: - /g/$token route with NO auth guard (SPA fallback already serves it), rendering GardenCanvas read-only via usePublicGarden. - Share dialog gains a Public link section: create, copy, regenerate, turn off. Tests: service lifecycle + ACL (owner-only, non-owner masked, rotate/disable invalidation) and the HTTP surface incl. the cookieless public read. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi --- internal/api/api.go | 14 ++ internal/api/public.go | 68 ++++++++++ internal/api/public_test.go | 105 +++++++++++++++ internal/service/objects.go | 14 +- internal/service/public.go | 93 +++++++++++++ internal/service/public_test.go | 124 ++++++++++++++++++ internal/service/service.go | 19 ++- internal/store/gardens.go | 60 +++++++++ .../migrations/0004_garden_public_token.sql | 7 + .../components/gardens/ShareGardenModal.tsx | 97 +++++++++++++- web/src/lib/publicGarden.ts | 22 ++++ web/src/lib/shares.ts | 46 +++++++ web/src/pages/PublicGardenPage.tsx | 79 +++++++++++ web/src/router.tsx | 11 ++ 14 files changed, 750 insertions(+), 9 deletions(-) create mode 100644 internal/api/public.go create mode 100644 internal/api/public_test.go create mode 100644 internal/service/public.go create mode 100644 internal/service/public_test.go create mode 100644 internal/store/migrations/0004_garden_public_token.sql create mode 100644 web/src/lib/publicGarden.ts create mode 100644 web/src/pages/PublicGardenPage.tsx diff --git a/internal/api/api.go b/internal/api/api.go index 7df98d8..0385f57 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -90,6 +90,13 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine { 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()) @@ -110,6 +117,13 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine { plants.PATCH("/:id", h.updatePlant) plants.DELETE("/:id", h.deletePlant) + // 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 } diff --git a/internal/api/public.go b/internal/api/public.go new file mode 100644 index 0000000..77f5ce6 --- /dev/null +++ b/internal/api/public.go @@ -0,0 +1,68 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// getPublicGarden serves the read-only /full payload for a garden addressed by a +// public share token. It sits OUTSIDE requireAuth — the token itself is the +// capability — so a logged-out visitor can open a shared link without ever being +// bounced to /login or the OIDC flow. An unknown or disabled token is a 404. +func (h *handlers) getPublicGarden(c *gin.Context) { + full, err := h.svc.PublicGarden(c.Request.Context(), c.Param("token")) + if err != nil { + writeServiceError(c, err) + return + } + c.JSON(http.StatusOK, full) +} + +// getShareLink reports the garden's public-link state (and, to the owner, the +// current token) so the share dialog can show/copy the URL. Owner only. +func (h *handlers) getShareLink(c *gin.Context) { + id, ok := parseIDParam(c, "id") + if !ok { + return + } + link, err := h.svc.GetPublicShareLink(c.Request.Context(), mustActor(c).ID, id) + if err != nil { + writeServiceError(c, err) + return + } + c.JSON(http.StatusOK, link) +} + +// createShareLink enables the public link (idempotent) or, with {"rotate":true}, +// issues a fresh token that invalidates the previous URL. Owner only. The body is +// optional — a missing/malformed one just means enable-without-rotate. +func (h *handlers) createShareLink(c *gin.Context) { + id, ok := parseIDParam(c, "id") + if !ok { + return + } + var req struct { + Rotate bool `json:"rotate"` + } + _ = c.ShouldBindJSON(&req) + link, err := h.svc.EnablePublicShareLink(c.Request.Context(), mustActor(c).ID, id, req.Rotate) + if err != nil { + writeServiceError(c, err) + return + } + c.JSON(http.StatusOK, link) +} + +// deleteShareLink disables the public link. Owner only; idempotent. +func (h *handlers) deleteShareLink(c *gin.Context) { + id, ok := parseIDParam(c, "id") + if !ok { + return + } + if err := h.svc.DisablePublicShareLink(c.Request.Context(), mustActor(c).ID, id); err != nil { + writeServiceError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"enabled": false}) +} diff --git a/internal/api/public_test.go b/internal/api/public_test.go new file mode 100644 index 0000000..b984219 --- /dev/null +++ b/internal/api/public_test.go @@ -0,0 +1,105 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" +) + +func shareLinkPath(gid int64) string { + return "/api/v1/gardens/" + strconv.FormatInt(gid, 10) + "/share-link" +} +func publicGardenPath(token string) string { return "/api/v1/public/gardens/" + token } + +// TestPublicShareLinkAPI checks the HTTP surface: owner-only link management, and +// an unauthenticated public read that returns 200 (never a redirect) for a live +// token and 404 for an unknown/rotated/disabled one — without a session cookie. +func TestPublicShareLinkAPI(t *testing.T) { + r := authEngine(t, localCfg()) + owner := registerAndCookie(t, r, "owner@example.com") + other := registerAndCookie(t, r, "other@example.com") + gid := createGardenAPI(t, r, owner, "Owner's yard") + + // Seed a bed so the public payload has visible content. + if w := doJSON(t, r, http.MethodPost, objectsPath(gid), + map[string]any{"kind": "bed", "xCm": 500, "yCm": 500, "widthCm": 200, "heightCm": 200}, owner); w.Code != http.StatusCreated { + t.Fatalf("seed bed = %d, body %s", w.Code, w.Body.String()) + } + + // Disabled by default. + w := doJSON(t, r, http.MethodGet, shareLinkPath(gid), nil, owner) + if w.Code != http.StatusOK { + t.Fatalf("get link = %d, body %s", w.Code, w.Body.String()) + } + if decodeMap(t, w.Body.Bytes())["enabled"] != false { + t.Fatalf("new garden link should be disabled: %s", w.Body.String()) + } + + // A non-owner can't manage the link (garden invisible to them → 404 masked). + if w := doJSON(t, r, http.MethodPost, shareLinkPath(gid), nil, other); w.Code != http.StatusNotFound { + t.Errorf("non-owner enable = %d, want 404", w.Code) + } + + // Owner enables → a token. + w = doJSON(t, r, http.MethodPost, shareLinkPath(gid), nil, owner) + if w.Code != http.StatusOK { + t.Fatalf("enable = %d, body %s", w.Code, w.Body.String()) + } + body := decodeMap(t, w.Body.Bytes()) + if body["enabled"] != true { + t.Fatalf("enable body = %s, want enabled", w.Body.String()) + } + tok, _ := body["token"].(string) + if tok == "" { + t.Fatalf("no token in enable body: %s", w.Body.String()) + } + + // Public read works with NO cookie — the whole point is no login/redirect. + w = doJSON(t, r, http.MethodGet, publicGardenPath(tok), nil, nil) + if w.Code != http.StatusOK { + t.Fatalf("public read = %d, body %s", w.Code, w.Body.String()) + } + var full struct { + Garden map[string]any `json:"garden"` + Objects []map[string]any `json:"objects"` + } + if err := json.Unmarshal(w.Body.Bytes(), &full); err != nil { + t.Fatalf("decode public: %v", err) + } + if int64(full.Garden["id"].(float64)) != gid { + t.Errorf("public garden id mismatch: %s", w.Body.String()) + } + if len(full.Objects) != 1 { + t.Errorf("public objects = %d, want 1", len(full.Objects)) + } + if _, leaked := full.Garden["publicToken"]; leaked { + t.Errorf("public_token leaked into the garden payload: %s", w.Body.String()) + } + + // Rotate → a new token; the old one stops resolving. + w = doJSON(t, r, http.MethodPost, shareLinkPath(gid), map[string]any{"rotate": true}, owner) + newTok, _ := decodeMap(t, w.Body.Bytes())["token"].(string) + if newTok == "" || newTok == tok { + t.Fatalf("rotate token = %q (old %q), want a fresh token", newTok, tok) + } + if w := doJSON(t, r, http.MethodGet, publicGardenPath(tok), nil, nil); w.Code != http.StatusNotFound { + t.Errorf("old token after rotate = %d, want 404", w.Code) + } + if w := doJSON(t, r, http.MethodGet, publicGardenPath(newTok), nil, nil); w.Code != http.StatusOK { + t.Errorf("new token = %d, want 200", w.Code) + } + + // Disable → the link is off and the token 404s. + if w := doJSON(t, r, http.MethodDelete, shareLinkPath(gid), nil, owner); w.Code != http.StatusOK { + t.Errorf("disable = %d, body %s", w.Code, w.Body.String()) + } + if w := doJSON(t, r, http.MethodGet, publicGardenPath(newTok), nil, nil); w.Code != http.StatusNotFound { + t.Errorf("token after disable = %d, want 404", w.Code) + } + + // An unknown token is a plain 404, never a redirect to login. + if w := doJSON(t, r, http.MethodGet, publicGardenPath("nope"), nil, nil); w.Code != http.StatusNotFound { + t.Errorf("unknown token = %d, want 404", w.Code) + } +} diff --git a/internal/service/objects.go b/internal/service/objects.go index 2e27bb0..a6eedce 100644 --- a/internal/service/objects.go +++ b/internal/service/objects.go @@ -164,15 +164,23 @@ func (s *Service) GardenFull(ctx context.Context, actorID, gardenID int64) (*Ful if err != nil { return nil, err } - objects, err := s.store.ListObjectsForGarden(ctx, gardenID) + return s.assembleFull(ctx, g) +} + +// assembleFull builds the read-only /full payload for an already-authorized +// garden: its objects, active plops (with DerivedCount filled in), and the +// plants those plops reference. Shared by the authenticated GardenFull and the +// unauthenticated PublicGarden read, so both return the identical shape. +func (s *Service) assembleFull(ctx context.Context, g *domain.Garden) (*FullGarden, error) { + objects, err := s.store.ListObjectsForGarden(ctx, g.ID) if err != nil { return nil, err } - plantings, err := s.store.ListActivePlantingsForGarden(ctx, gardenID) + plantings, err := s.store.ListActivePlantingsForGarden(ctx, g.ID) if err != nil { return nil, err } - plants, err := s.store.ListReferencedPlants(ctx, gardenID) + plants, err := s.store.ListReferencedPlants(ctx, g.ID) if err != nil { return nil, err } diff --git a/internal/service/public.go b/internal/service/public.go new file mode 100644 index 0000000..b4c8c6d --- /dev/null +++ b/internal/service/public.go @@ -0,0 +1,93 @@ +package service + +import ( + "context" + "strings" + + "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" +) + +// PublicShareLink is the owner-visible state of a garden's public read-only link. +// Token is omitted (and empty) when the link is disabled. +type PublicShareLink struct { + Enabled bool `json:"enabled"` + Token string `json:"token,omitempty"` +} + +func linkState(token *string) *PublicShareLink { + if token == nil { + return &PublicShareLink{Enabled: false} + } + return &PublicShareLink{Enabled: true, Token: *token} +} + +// PublicGarden returns the read-only /full payload for the garden addressed by a +// public share token. The token is the capability — anyone holding a valid one +// may read — so there is no actor or ACL check. An unknown, empty, or disabled +// token yields domain.ErrNotFound (existence is masked, same as a private row). +func (s *Service) PublicGarden(ctx context.Context, token string) (*FullGarden, error) { + token = strings.TrimSpace(token) + if token == "" { + return nil, domain.ErrNotFound + } + g, err := s.store.GetGardenByPublicToken(ctx, token) + if err != nil { + return nil, err + } + full, err := s.assembleFull(ctx, g) + if err != nil { + return nil, err + } + // Minimize what an anonymous viewer learns: no role, and don't expose the + // owner's user id (the page renders fine without it). + full.Garden.MyRole = "" + full.Garden.OwnerID = 0 + return full, nil +} + +// GetPublicShareLink reports whether the garden's public link is on, and (to the +// owner) the current token. Owner only. +func (s *Service) GetPublicShareLink(ctx context.Context, actorID, gardenID int64) (*PublicShareLink, error) { + if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleOwner); err != nil { + return nil, err + } + token, err := s.store.GetGardenPublicToken(ctx, gardenID) + if err != nil { + return nil, err + } + return linkState(token), nil +} + +// EnablePublicShareLink turns the public link on and returns the token. It is +// idempotent when rotate is false (an existing link keeps its token); when rotate +// is true it always issues a fresh token, invalidating the previous URL. Owner +// only. +func (s *Service) EnablePublicShareLink(ctx context.Context, actorID, gardenID int64, rotate bool) (*PublicShareLink, error) { + if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleOwner); err != nil { + return nil, err + } + token, err := s.store.GetGardenPublicToken(ctx, gardenID) + if err != nil { + return nil, err + } + if token == nil || rotate { + fresh, err := newPublicToken() + if err != nil { + return nil, err + } + if err := s.store.SetGardenPublicToken(ctx, gardenID, &fresh); err != nil { + return nil, err + } + token = &fresh + } + return linkState(token), nil +} + +// DisablePublicShareLink turns the public link off (clears the token). Owner +// only; idempotent (disabling an already-disabled link is a no-op success). +func (s *Service) DisablePublicShareLink(ctx context.Context, actorID, gardenID int64) error { + if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleOwner); err != nil { + return err + } + return s.store.SetGardenPublicToken(ctx, gardenID, nil) +} diff --git a/internal/service/public_test.go b/internal/service/public_test.go new file mode 100644 index 0000000..ee29376 --- /dev/null +++ b/internal/service/public_test.go @@ -0,0 +1,124 @@ +package service + +import ( + "context" + "errors" + "testing" + + "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" +) + +// TestPublicShareLink covers the owner-only link lifecycle (enable / idempotent +// re-enable / rotate / disable) and the unauthenticated read it gates: a valid +// token returns the read-only /full payload with no role, an unknown/blank/ +// rotated/disabled token is masked as ErrNotFound, and non-owners can't manage +// the link. +func TestPublicShareLink(t *testing.T) { + ctx := context.Background() + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "owner@example.com") + editor := seedUser(t, s, "editor@example.com") + stranger := seedUser(t, s, "stranger@example.com") + + g := seedGarden(t, s, owner) + bed := seedBed(t, s, owner, g.ID) + plant := seedOwnPlant(t, s, owner, 10) + if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 10}); err != nil { + t.Fatalf("seed plop: %v", err) + } + if _, err := s.AddShare(ctx, owner, g.ID, "editor@example.com", domain.RoleEditor); err != nil { + t.Fatalf("share editor: %v", err) + } + + // Disabled by default. + link, err := s.GetPublicShareLink(ctx, owner, g.ID) + if err != nil { + t.Fatalf("get link: %v", err) + } + if link.Enabled { + t.Fatalf("a new garden should have no public link") + } + + // Non-owners can neither read nor manage the link. A shared editor sees the + // garden (ErrForbidden); a stranger's view is masked (ErrNotFound). + if _, err := s.GetPublicShareLink(ctx, editor, g.ID); !errors.Is(err, domain.ErrForbidden) { + t.Errorf("editor get link = %v, want ErrForbidden", err) + } + if _, err := s.EnablePublicShareLink(ctx, editor, g.ID, false); !errors.Is(err, domain.ErrForbidden) { + t.Errorf("editor enable = %v, want ErrForbidden", err) + } + if _, err := s.EnablePublicShareLink(ctx, stranger, g.ID, false); !errors.Is(err, domain.ErrNotFound) { + t.Errorf("stranger enable = %v, want ErrNotFound", err) + } + + // Owner enables → a token; the public read works with no actor. + link, err = s.EnablePublicShareLink(ctx, owner, g.ID, false) + if err != nil { + t.Fatalf("enable: %v", err) + } + if !link.Enabled || link.Token == "" { + t.Fatalf("enable returned %+v, want enabled with a token", link) + } + tok := link.Token + + // Re-enabling without rotate keeps the same token (idempotent). + again, err := s.EnablePublicShareLink(ctx, owner, g.ID, false) + if err != nil { + t.Fatalf("re-enable: %v", err) + } + if again.Token != tok { + t.Errorf("re-enable changed the token: %q -> %q", tok, again.Token) + } + + full, err := s.PublicGarden(ctx, tok) + if err != nil { + t.Fatalf("public read: %v", err) + } + if full.Garden.ID != g.ID { + t.Errorf("public garden id = %d, want %d", full.Garden.ID, g.ID) + } + if full.Garden.MyRole != "" { + t.Errorf("public garden MyRole = %q, want empty (no role for a public viewer)", full.Garden.MyRole) + } + if len(full.Objects) != 1 || len(full.Plantings) != 1 || len(full.Plants) != 1 { + t.Errorf("public full = %d objs / %d plops / %d plants, want 1/1/1", + len(full.Objects), len(full.Plantings), len(full.Plants)) + } + + // Unknown / blank tokens are masked, never a distinct error. + for _, bad := range []string{"does-not-exist", " ", ""} { + if _, err := s.PublicGarden(ctx, bad); !errors.Is(err, domain.ErrNotFound) { + t.Errorf("PublicGarden(%q) = %v, want ErrNotFound", bad, err) + } + } + + // Rotate → fresh token; the old URL stops working. + rotated, err := s.EnablePublicShareLink(ctx, owner, g.ID, true) + if err != nil { + t.Fatalf("rotate: %v", err) + } + if rotated.Token == "" || rotated.Token == tok { + t.Fatalf("rotate token = %q (old %q), want a fresh non-empty token", rotated.Token, tok) + } + if _, err := s.PublicGarden(ctx, tok); !errors.Is(err, domain.ErrNotFound) { + t.Errorf("old token after rotate = %v, want ErrNotFound", err) + } + if _, err := s.PublicGarden(ctx, rotated.Token); err != nil { + t.Errorf("new token = %v, want success", err) + } + + // Disable → link off, token no longer resolves. + if err := s.DisablePublicShareLink(ctx, owner, g.ID); err != nil { + t.Fatalf("disable: %v", err) + } + if _, err := s.PublicGarden(ctx, rotated.Token); !errors.Is(err, domain.ErrNotFound) { + t.Errorf("token after disable = %v, want ErrNotFound", err) + } + link, err = s.GetPublicShareLink(ctx, owner, g.ID) + if err != nil { + t.Fatalf("get link after disable: %v", err) + } + if link.Enabled { + t.Errorf("link still enabled after disable") + } +} diff --git a/internal/service/service.go b/internal/service/service.go index 2c7a0d6..de028b7 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -59,16 +59,25 @@ func formatTime(t time.Time) string { return t.UTC().Format(timeLayout) } // parseTime parses a canonical pansy timestamp. func parseTime(s string) (time.Time, error) { return time.Parse(timeLayout, s) } -// newSessionToken returns a fresh URL-safe random bearer token (32 bytes of -// entropy). The raw token goes in the cookie; only its hash is persisted. -func newSessionToken() (string, error) { - b := make([]byte, 32) +// randToken returns nBytes of cryptographic randomness as a URL-safe string. +func randToken(nBytes int) (string, error) { + b := make([]byte, nBytes) if _, err := rand.Read(b); err != nil { - return "", fmt.Errorf("service: generate session token: %w", err) + return "", fmt.Errorf("service: generate token: %w", err) } return base64.RawURLEncoding.EncodeToString(b), nil } +// newSessionToken returns a fresh URL-safe random bearer token (32 bytes of +// entropy). The raw token goes in the cookie; only its hash is persisted. +func newSessionToken() (string, error) { return randToken(32) } + +// newPublicToken returns a fresh unguessable token for a garden's public share +// link (18 bytes → 144 bits; a 24-char URL segment). Unlike a session token it's +// stored raw (it must be shown back to the owner to copy) and grants only +// read-only access to one garden. +func newPublicToken() (string, error) { return randToken(18) } + // hashToken maps a raw bearer token to the hex sha256 stored as the session's // primary key. func hashToken(raw string) string { diff --git a/internal/store/gardens.go b/internal/store/gardens.go index 53af9b5..8f157ee 100644 --- a/internal/store/gardens.go +++ b/internal/store/gardens.go @@ -153,3 +153,63 @@ func (d *DB) DeleteGarden(ctx context.Context, id int64) error { } return nil } + +// GetGardenByPublicToken returns the garden whose public_token matches, or +// domain.ErrNotFound. Possession of the token is the capability, so there is no +// owner/actor check here — the service exposes this only for the unauthenticated +// public-read path. public_token itself is never selected into the row (it stays +// out of gardenColumns), so it can't leak through the returned garden. +func (d *DB) GetGardenByPublicToken(ctx context.Context, token string) (*domain.Garden, error) { + g, err := scanGarden(d.sql.QueryRowContext(ctx, + `SELECT `+gardenColumns+` FROM gardens WHERE public_token = ?`, token)) + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("store: get garden by public token: %w", err) + } + return g, nil +} + +// GetGardenPublicToken returns the garden's current public token (nil when the +// public link is disabled), or domain.ErrNotFound if the garden is gone. +func (d *DB) GetGardenPublicToken(ctx context.Context, gardenID int64) (*string, error) { + var tok sql.NullString + err := d.sql.QueryRowContext(ctx, `SELECT public_token FROM gardens WHERE id = ?`, gardenID).Scan(&tok) + if errors.Is(err, sql.ErrNoRows) { + return nil, domain.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("store: get garden public token: %w", err) + } + if !tok.Valid { + return nil, nil + } + return &tok.String, nil +} + +// SetGardenPublicToken sets the garden's public token (enabling/rotating the +// link) or clears it with a nil token (disabling the link). It does not bump the +// garden version — the public link is out-of-band from garden edits, so toggling +// it must not spuriously conflict a concurrent editor. Returns domain.ErrNotFound +// if the garden is gone. +func (d *DB) SetGardenPublicToken(ctx context.Context, gardenID int64, token *string) error { + var val any + if token != nil { + val = *token + } + res, err := d.sql.ExecContext(ctx, + `UPDATE gardens SET public_token = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?`, + val, gardenID) + if err != nil { + return fmt.Errorf("store: set garden public token: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("store: set garden public token rows: %w", err) + } + if n == 0 { + return domain.ErrNotFound + } + return nil +} diff --git a/internal/store/migrations/0004_garden_public_token.sql b/internal/store/migrations/0004_garden_public_token.sql new file mode 100644 index 0000000..3d3b267 --- /dev/null +++ b/internal/store/migrations/0004_garden_public_token.sql @@ -0,0 +1,7 @@ +-- Per-garden public share token. When set, anyone holding the token may read the +-- garden read-only via /api/v1/public/gardens/:token, with no login and no OIDC. +-- NULL disables the public link. Unique (over non-NULL values) so a token maps to +-- at most one garden; rotating the token invalidates the previous URL. +ALTER TABLE gardens ADD COLUMN public_token TEXT; + +CREATE UNIQUE INDEX idx_gardens_public_token ON gardens (public_token) WHERE public_token IS NOT NULL; diff --git a/web/src/components/gardens/ShareGardenModal.tsx b/web/src/components/gardens/ShareGardenModal.tsx index a4a706d..137e381 100644 --- a/web/src/components/gardens/ShareGardenModal.tsx +++ b/web/src/components/gardens/ShareGardenModal.tsx @@ -8,7 +8,16 @@ import { cn } from '@/lib/cn' import { fieldControlClass } from '@/components/ui/field' import { errorMessage } from '@/lib/api' import type { Garden } from '@/lib/gardens' -import { useAddShare, useRemoveShare, useShares, useUpdateShareRole, type ShareRole } from '@/lib/shares' +import { + useAddShare, + useDisableShareLink, + useEnableShareLink, + useRemoveShare, + useShareLink, + useShares, + useUpdateShareRole, + type ShareRole, +} from '@/lib/shares' const roleOptions = [ { value: 'viewer', label: 'Viewer (read-only)' }, @@ -121,6 +130,8 @@ export function ShareGardenModal({ garden, onClose }: { garden: Garden; onClose: + +
+ )} + + {link.isSuccess && link.data.enabled && ( +
+
+ e.currentTarget.select()} + aria-label="Public link URL" + className={cn(fieldControlClass, 'min-w-0 flex-1 text-sm')} + /> + +
+
+ + +
+
+ )} +
+ ) +} diff --git a/web/src/lib/publicGarden.ts b/web/src/lib/publicGarden.ts new file mode 100644 index 0000000..9ee2549 --- /dev/null +++ b/web/src/lib/publicGarden.ts @@ -0,0 +1,22 @@ +// Public read-only garden data layer: the unauthenticated /full payload behind a +// share token. Shares the FullGarden shape with the editor's own load, so the +// public page can reuse toEditorObject / toEditorPlanting and GardenCanvas. + +import { queryOptions, useQuery } from '@tanstack/react-query' +import { api } from './api' +import { fullGardenSchema, type FullGarden } from './objects' + +export function publicGardenQueryOptions(token: string) { + return queryOptions({ + queryKey: ['public-garden', token] as const, + queryFn: async (): Promise => + fullGardenSchema.parse(await api.get(`/public/gardens/${encodeURIComponent(token)}`)), + // A disabled/rotated token is a 404, not a transient failure — don't retry. + retry: false, + staleTime: 30_000, + }) +} + +export function usePublicGarden(token: string) { + return useQuery(publicGardenQueryOptions(token)) +} diff --git a/web/src/lib/shares.ts b/web/src/lib/shares.ts index dcbe3d3..ff7b3c6 100644 --- a/web/src/lib/shares.ts +++ b/web/src/lib/shares.ts @@ -68,3 +68,49 @@ export function useRemoveShare(gardenId: number) { }, }) } + +// --- public share link (owner-only management) ----------------------------- +// The read-only public link that anyone (no account) can open. token is present +// only when enabled; the page builds the URL as `${origin}/g/${token}`. + +export const shareLinkSchema = z.object({ + enabled: z.boolean(), + token: z.string().optional(), +}) +export type ShareLinkState = z.infer + +const shareLinkKey = (gardenId: number) => ['share-link', gardenId] as const + +export function shareLinkQueryOptions(gardenId: number) { + return queryOptions({ + queryKey: shareLinkKey(gardenId), + queryFn: async (): Promise => + shareLinkSchema.parse(await api.get(`/gardens/${gardenId}/share-link`)), + }) +} + +/** The garden's public-link state (owner-only endpoint). */ +export function useShareLink(gardenId: number) { + return useQuery(shareLinkQueryOptions(gardenId)) +} + +/** Enable the public link, or with { rotate: true } issue a fresh token that + * invalidates the old URL. Writes the returned state straight into the cache. */ +export function useEnableShareLink(gardenId: number) { + const qc = useQueryClient() + return useMutation({ + mutationFn: async ({ rotate = false }: { rotate?: boolean }): Promise => + shareLinkSchema.parse(await api.post(`/gardens/${gardenId}/share-link`, { rotate })), + onSuccess: (state) => qc.setQueryData(shareLinkKey(gardenId), state), + }) +} + +/** Disable the public link. */ +export function useDisableShareLink(gardenId: number) { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (): Promise => + shareLinkSchema.parse(await api.delete(`/gardens/${gardenId}/share-link`)), + onSuccess: (state) => qc.setQueryData(shareLinkKey(gardenId), state), + }) +} diff --git a/web/src/pages/PublicGardenPage.tsx b/web/src/pages/PublicGardenPage.tsx new file mode 100644 index 0000000..7371f2a --- /dev/null +++ b/web/src/pages/PublicGardenPage.tsx @@ -0,0 +1,79 @@ +import { useEffect, useMemo } from 'react' +import { getRouteApi } from '@tanstack/react-router' +import { Alert } from '@/components/ui/Alert' +import { GardenCanvas } from '@/editor/GardenCanvas' +import { useEditorStore } from '@/editor/store' +import type { EditorGarden } from '@/editor/types' +import { toEditorObject } from '@/lib/objects' +import { toEditorPlanting } from '@/lib/plantings' +import { usePublicGarden } from '@/lib/publicGarden' +import { usePageTitle } from '@/lib/usePageTitle' + +const routeApi = getRouteApi('/g/$token') + +/** + * The public, read-only garden view behind a share token. Rendered on the + * unauthenticated `/g/$token` route (no auth guard), so a logged-out visitor + * never hits /login or OIDC. Reuses GardenCanvas with canEdit=false — pan/zoom + * to explore, but no editing chrome. + */ +export function PublicGardenPage() { + const { token } = routeApi.useParams() + const full = usePublicGarden(token) + usePageTitle(full.data?.garden.name ?? 'Shared garden') + + // Start from a clean canvas: if a logged-in user reaches here from their own + // editor, stale focus/selection state would otherwise dim or highlight things. + useEffect(() => { + const s = useEditorStore.getState() + s.setFocusedObject(null) + s.select(null) + s.selectPlanting(null) + s.setArmedPlant(null) + s.setArmedKind(null) + s.setLiveObject(null) + s.setLivePlanting(null) + }, [token]) + + const objects = useMemo(() => full.data?.objects.map(toEditorObject) ?? [], [full.data?.objects]) + const plantings = useMemo(() => full.data?.plantings.map(toEditorPlanting) ?? [], [full.data?.plantings]) + const plants = useMemo(() => full.data?.plants ?? [], [full.data?.plants]) + const plantsById = useMemo(() => new Map(plants.map((p) => [p.id, p])), [plants]) + + if (full.isPending) return

Loading garden…

+ if (full.isError) + return ( +
+ This shared link isn’t available. The owner may have turned it off or changed it. +
+ ) + + const g = full.data.garden + const garden: EditorGarden = { + id: g.id, + name: g.name, + widthCm: g.widthCm, + heightCm: g.heightCm, + unitPref: g.unitPref, + } + + return ( +
+
+

+ {garden.name} +

+ 👁 Shared · read-only +
+
+ +
+
+ ) +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 951df6a..b8cebf6 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -12,6 +12,7 @@ import { LoginPage } from '@/pages/LoginPage' import { RegisterPage } from '@/pages/RegisterPage' import { GardensPage } from '@/pages/GardensPage' import { GardenEditorPage } from '@/pages/GardenEditorPage' +import { PublicGardenPage } from '@/pages/PublicGardenPage' import { PlantsPage } from '@/pages/PlantsPage' import { meQueryOptions } from '@/lib/auth' import { queryClient } from '@/lib/queryClient' @@ -108,6 +109,15 @@ const plantsRoute = createRoute({ component: PlantsPage, }) +// Public read-only garden by share token. Deliberately has NO beforeLoad auth +// guard, so a logged-out visitor viewing a shared link is never redirected to +// /login or OIDC. +const publicGardenRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'g/$token', + component: PublicGardenPage, +}) + const routeTree = rootRoute.addChildren([ indexRoute, loginRoute, @@ -115,6 +125,7 @@ const routeTree = rootRoute.addChildren([ gardensRoute, gardenEditorRoute, plantsRoute, + publicGardenRoute, ]) export const router = createRouter({ -- 2.54.0 From 0842618ad1bce289a3ae59350404b731a8c8623e Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 19 Jul 2026 02:17:15 -0400 Subject: [PATCH 2/2] Address review: redact plant owner ids, cache-control, 400, DRY reset - Security (2-model, security+correctness): the public payload zeroed Garden.OwnerID but referenced custom plants still carried Plant.OwnerID, leaking the owner's user id to anonymous viewers. Redact plant owner ids too, and assert it in the service test. - Set Cache-Control: no-store on the public read so a capability-URL response isn't held in a shared proxy cache and always reflects the live garden. - createShareLink now 400s on a present-but-malformed JSON body instead of silently enabling (an empty body still means enable-without-rotate). - Extract the transient-editor-state reset into a shared resetTransient store action (3-model finding) and use it from PublicGardenPage. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi --- internal/api/public.go | 13 ++++++++++++- internal/service/public.go | 8 ++++++-- internal/service/public_test.go | 7 +++++++ web/src/editor/store.ts | 17 +++++++++++++++++ web/src/pages/PublicGardenPage.tsx | 9 +-------- 5 files changed, 43 insertions(+), 11 deletions(-) diff --git a/internal/api/public.go b/internal/api/public.go index 77f5ce6..6fb16ac 100644 --- a/internal/api/public.go +++ b/internal/api/public.go @@ -1,6 +1,8 @@ package api import ( + "errors" + "io" "net/http" "github.com/gin-gonic/gin" @@ -16,6 +18,10 @@ func (h *handlers) getPublicGarden(c *gin.Context) { writeServiceError(c, err) return } + // The token is a capability in the URL: keep the response out of any shared + // proxy cache, and always serve the live garden (the owner may have edited or + // revoked it since the last view). + c.Header("Cache-Control", "no-store") c.JSON(http.StatusOK, full) } @@ -45,7 +51,12 @@ func (h *handlers) createShareLink(c *gin.Context) { var req struct { Rotate bool `json:"rotate"` } - _ = c.ShouldBindJSON(&req) + // The body is optional (an empty body means enable-without-rotate), but a + // present-yet-malformed body is a client error, not a silent enable. + if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { + writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid request body") + return + } link, err := h.svc.EnablePublicShareLink(c.Request.Context(), mustActor(c).ID, id, req.Rotate) if err != nil { writeServiceError(c, err) diff --git a/internal/service/public.go b/internal/service/public.go index b4c8c6d..393f326 100644 --- a/internal/service/public.go +++ b/internal/service/public.go @@ -38,10 +38,14 @@ func (s *Service) PublicGarden(ctx context.Context, token string) (*FullGarden, if err != nil { return nil, err } - // Minimize what an anonymous viewer learns: no role, and don't expose the - // owner's user id (the page renders fine without it). + // Minimize what an anonymous viewer learns: no role, and no owner user id — + // neither the garden's owner nor the owner of any referenced custom plant + // (built-ins already have a nil OwnerID). The page renders fine without them. full.Garden.MyRole = "" full.Garden.OwnerID = 0 + for i := range full.Plants { + full.Plants[i].OwnerID = nil + } return full, nil } diff --git a/internal/service/public_test.go b/internal/service/public_test.go index ee29376..5463081 100644 --- a/internal/service/public_test.go +++ b/internal/service/public_test.go @@ -84,6 +84,13 @@ func TestPublicShareLink(t *testing.T) { t.Errorf("public full = %d objs / %d plops / %d plants, want 1/1/1", len(full.Objects), len(full.Plantings), len(full.Plants)) } + // The referenced plant is the owner's custom plant; its OwnerID must be + // redacted so an anonymous viewer can't learn the owner's user id. + for _, pl := range full.Plants { + if pl.OwnerID != nil { + t.Errorf("public plant %d leaks OwnerID %d, want nil", pl.ID, *pl.OwnerID) + } + } // Unknown / blank tokens are masked, never a distinct error. for _, bad := range []string{"does-not-exist", " ", ""} { diff --git a/web/src/editor/store.ts b/web/src/editor/store.ts index 3ed3819..dfe194c 100644 --- a/web/src/editor/store.ts +++ b/web/src/editor/store.ts @@ -49,6 +49,12 @@ interface EditorState { // pan gesture stands down (both listen on the same svg). objectDragging: boolean setObjectDragging: (v: boolean) => void + + // Clear all transient (non-persisted) editor state at once — selection, focus, + // armed plant/kind, and any in-flight live geometry — when entering a garden + // view fresh (e.g. the public read-only page on mount). The viewport is left + // alone; the canvas re-fits it from the loaded garden. + resetTransient: () => void } export const useEditorStore = create((set) => ({ @@ -78,4 +84,15 @@ export const useEditorStore = create((set) => ({ objectDragging: false, setObjectDragging: (v) => set({ objectDragging: v }), + + resetTransient: () => + set({ + selectedId: null, + selectedPlantingId: null, + focusedObjectId: null, + armedPlant: null, + armedKind: null, + liveObject: null, + livePlanting: null, + }), })) diff --git a/web/src/pages/PublicGardenPage.tsx b/web/src/pages/PublicGardenPage.tsx index 7371f2a..5723c87 100644 --- a/web/src/pages/PublicGardenPage.tsx +++ b/web/src/pages/PublicGardenPage.tsx @@ -25,14 +25,7 @@ export function PublicGardenPage() { // Start from a clean canvas: if a logged-in user reaches here from their own // editor, stale focus/selection state would otherwise dim or highlight things. useEffect(() => { - const s = useEditorStore.getState() - s.setFocusedObject(null) - s.select(null) - s.selectPlanting(null) - s.setArmedPlant(null) - s.setArmedKind(null) - s.setLiveObject(null) - s.setLivePlanting(null) + useEditorStore.getState().resetTransient() }, [token]) const objects = useMemo(() => full.data?.objects.map(toEditorObject) ?? [], [full.data?.objects]) -- 2.54.0