Public read-only share link (no login / no OIDC) (#41) #43
+12
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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", " ", ""} {
|
||||
|
||||
@@ -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<EditorState>((set) => ({
|
||||
@@ -78,4 +84,15 @@ export const useEditorStore = create<EditorState>((set) => ({
|
||||
|
||||
objectDragging: false,
|
||||
setObjectDragging: (v) => set({ objectDragging: v }),
|
||||
|
||||
resetTransient: () =>
|
||||
set({
|
||||
selectedId: null,
|
||||
selectedPlantingId: null,
|
||||
focusedObjectId: null,
|
||||
armedPlant: null,
|
||||
armedKind: null,
|
||||
liveObject: null,
|
||||
livePlanting: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -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(() => {
|
||||
|
gitea-actions
commented
🟠 Manual store reset omits objectDragging and is fragile to future store additions maintainability · flagged by 4 models
🪰 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()
|
||||
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])
|
||||
|
||||
Reference in New Issue
Block a user
🟡 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:19returns viac.JSONwith no cache headers;assembleFull(objects.go:174-186) runs 3 indexed queries plus theGetGardenByPublicTokenlookup = 4 DB queries per load; onlyspa.gosetsCache-Controlin the API package;web/src/lib/publicGarden.ts:16hasstaleTime: 30_000. The finding survives.🪰 Gadfly · advisory