Files
pansy/internal/api/public.go
T
steveandClaude Opus 4.8 0842618ad1
Build image / build-and-push (push) Successful in 15s
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-19 02:17:15 -04:00

80 lines
2.5 KiB
Go

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})
}