From 9968c062430d48e369fb15b2d2d976d85d13150b Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 19 Jul 2026 06:18:31 +0000 Subject: [PATCH] Public read-only share link (no login / no OIDC) (#41) (#43) Per-garden public read-only link: unauthenticated GET /api/v1/public/gardens/:token (no requireAuth, no OIDC), owner-only enable/rotate/disable, and a /g/$token page rendering GardenCanvas read-only. Review fixes: redact plant owner ids, Cache-Control: no-store, 400 on malformed body, shared resetTransient store action. Closes #41. Co-authored-by: Steve Dudenhoeffer --- internal/api/api.go | 14 ++ internal/api/public.go | 79 +++++++++++ internal/api/public_test.go | 105 ++++++++++++++ internal/service/objects.go | 14 +- internal/service/public.go | 97 +++++++++++++ internal/service/public_test.go | 131 ++++++++++++++++++ internal/service/service.go | 19 ++- internal/store/gardens.go | 60 ++++++++ .../migrations/0004_garden_public_token.sql | 7 + .../components/gardens/ShareGardenModal.tsx | 97 ++++++++++++- web/src/editor/store.ts | 17 +++ web/src/lib/publicGarden.ts | 22 +++ web/src/lib/shares.ts | 46 ++++++ web/src/pages/PublicGardenPage.tsx | 72 ++++++++++ web/src/router.tsx | 11 ++ 15 files changed, 782 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..6fb16ac --- /dev/null +++ b/internal/api/public.go @@ -0,0 +1,79 @@ +package api + +import ( + "errors" + "io" + "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 + } + // 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) +} + +// 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"` + } + // 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) + 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..393f326 --- /dev/null +++ b/internal/service/public.go @@ -0,0 +1,97 @@ +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 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 +} + +// 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..5463081 --- /dev/null +++ b/internal/service/public_test.go @@ -0,0 +1,131 @@ +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)) + } + // 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", " ", ""} { + 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/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/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..5723c87 --- /dev/null +++ b/web/src/pages/PublicGardenPage.tsx @@ -0,0 +1,72 @@ +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(() => { + useEditorStore.getState().resetTransient() + }, [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({