Build image / build-and-push (push) Successful in 15s
- 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
98 lines
3.2 KiB
Go
98 lines
3.2 KiB
Go
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)
|
|
}
|