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