Public read-only share link (no login / no OIDC) (#41) #43

Merged
steve merged 2 commits from feat/public-share-link into main 2026-07-19 06:18:31 +00:00
5 changed files with 43 additions and 11 deletions
Showing only changes of commit 0842618ad1 - Show all commits
+12 -1
View File
@@ -1,6 +1,8 @@
package api package api
import ( import (
"errors"
"io"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
1
@@ -16,6 +18,10 @@ func (h *handlers) getPublicGarden(c *gin.Context) {
writeServiceError(c, err) writeServiceError(c, err)
return return
Review

🟡 Public garden read has no HTTP cache headers despite being the many-viewer hot path (4 DB queries per load)

performance · flagged by 1 model

Confirmed. internal/api/public.go:19 returns via c.JSON with no cache headers; assembleFull (objects.go:174-186) runs 3 indexed queries plus the GetGardenByPublicToken lookup = 4 DB queries per load; only spa.go sets Cache-Control in the API package; web/src/lib/publicGarden.ts:16 has staleTime: 30_000. The finding survives.

🪰 Gadfly · advisory

🟡 **Public garden read has no HTTP cache headers despite being the many-viewer hot path (4 DB queries per load)** _performance · flagged by 1 model_ Confirmed. `internal/api/public.go:19` returns via `c.JSON` with no cache headers; `assembleFull` (objects.go:174-186) runs 3 indexed queries plus the `GetGardenByPublicToken` lookup = 4 DB queries per load; only `spa.go` sets `Cache-Control` in the API package; `web/src/lib/publicGarden.ts:16` has `staleTime: 30_000`. The finding survives. <sub>🪰 Gadfly · advisory</sub>
} }
// 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) c.JSON(http.StatusOK, full)
} }
1
@@ -45,7 +51,12 @@ func (h *handlers) createShareLink(c *gin.Context) {
var req struct { var req struct {
Rotate bool `json:"rotate"` 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) link, err := h.svc.EnablePublicShareLink(c.Request.Context(), mustActor(c).ID, id, req.Rotate)
if err != nil { if err != nil {
writeServiceError(c, err) writeServiceError(c, err)
1
+6 -2
View File
1
@@ -38,10 +38,14 @@ func (s *Service) PublicGarden(ctx context.Context, token string) (*FullGarden,
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Minimize what an anonymous viewer learns: no role, and don't expose the // Minimize what an anonymous viewer learns: no role, and no owner user id —
// owner's user id (the page renders fine without it). // 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.MyRole = ""
full.Garden.OwnerID = 0 full.Garden.OwnerID = 0
for i := range full.Plants {
full.Plants[i].OwnerID = nil
}
return full, nil return full, nil
} }
+7
View File
@@ -84,6 +84,13 @@ func TestPublicShareLink(t *testing.T) {
t.Errorf("public full = %d objs / %d plops / %d plants, want 1/1/1", t.Errorf("public full = %d objs / %d plops / %d plants, want 1/1/1",
len(full.Objects), len(full.Plantings), len(full.Plants)) 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. // Unknown / blank tokens are masked, never a distinct error.
for _, bad := range []string{"does-not-exist", " ", ""} { for _, bad := range []string{"does-not-exist", " ", ""} {
+17
View File
@@ -49,6 +49,12 @@ interface EditorState {
// pan gesture stands down (both listen on the same svg). // pan gesture stands down (both listen on the same svg).
objectDragging: boolean objectDragging: boolean
setObjectDragging: (v: boolean) => void 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<EditorState>((set) => ({ export const useEditorStore = create<EditorState>((set) => ({
@@ -78,4 +84,15 @@ export const useEditorStore = create<EditorState>((set) => ({
objectDragging: false, objectDragging: false,
setObjectDragging: (v) => set({ objectDragging: v }), setObjectDragging: (v) => set({ objectDragging: v }),
resetTransient: () =>
set({
selectedId: null,
selectedPlantingId: null,
focusedObjectId: null,
armedPlant: null,
armedKind: null,
liveObject: null,
livePlanting: null,
}),
})) }))
+1 -8
View File
@@ -25,14 +25,7 @@ export function PublicGardenPage() {
// Start from a clean canvas: if a logged-in user reaches here from their own // 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. // editor, stale focus/selection state would otherwise dim or highlight things.
useEffect(() => { useEffect(() => {
Review

🟠 Manual store reset omits objectDragging and is fragile to future store additions

maintainability · flagged by 4 models

  • web/src/pages/PublicGardenPage.tsx:27-36 — The mount effect manually enumerates seven individual useEditorStore setters to reset transient editor state. It omits objectDragging, so a logged-in user arriving from an active editor gesture (where objectDragging may be true) could find pan/zoom broken on the public canvas because the viewport stands down while that flag is set. More broadly, manually listing fields is fragile: any new transient field added to the editor store will be…

🪰 Gadfly · advisory

🟠 **Manual store reset omits objectDragging and is fragile to future store additions** _maintainability · flagged by 4 models_ - **`web/src/pages/PublicGardenPage.tsx:27-36`** — The mount effect manually enumerates seven individual `useEditorStore` setters to reset transient editor state. It omits `objectDragging`, so a logged-in user arriving from an active editor gesture (where `objectDragging` may be `true`) could find pan/zoom broken on the public canvas because the viewport stands down while that flag is set. More broadly, manually listing fields is fragile: any new transient field added to the editor store will be… <sub>🪰 Gadfly · advisory</sub>
const s = useEditorStore.getState() useEditorStore.getState().resetTransient()
s.setFocusedObject(null)
s.select(null)
s.selectPlanting(null)
s.setArmedPlant(null)
s.setArmedKind(null)
s.setLiveObject(null)
s.setLivePlanting(null)
}, [token]) }, [token])
const objects = useMemo(() => full.data?.objects.map(toEditorObject) ?? [], [full.data?.objects]) const objects = useMemo(() => full.data?.objects.map(toEditorObject) ?? [], [full.data?.objects])
1