Files
pansy/internal/service/public.go
T
steveandClaude Fable 5 608ef7c58e
Build image / build-and-push (push) Successful in 13s
Address #131 review: unknown link action is unknown, typed views, one share shape
- public_link checks the action before the confirmation gate, so an unknown
  action is told so instead of being asked to confirm nothing in particular
  (the 4/4 finding).
- linkView is a struct like shareView; toShareView builds the five share
  results, and a fresh share is read back so it carries the person's name
  like every other path.
- PublicShareURL trims a trailing slash off a hand-built base URL.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-23 02:21:04 -04:00

112 lines
3.8 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
}
// PublicShareURL is the address a public link opens at: absolute when the
// instance knows its base URL (PANSY_BASE_URL), else the site-relative path the
// editor uses, which a person can complete with the host they are looking at.
// Exists so the assistant can hand the gardener a link rather than a token.
func (s *Service) PublicShareURL(token string) string {
path := "/g/" + token
if s.cfg != nil && s.cfg.BaseURL != "" {
// config trims the trailing slash already; a Config built by hand
// (tests, an embedder) may not have.
return strings.TrimRight(s.cfg.BaseURL, "/") + path
}
return path
}
// 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)
}