list_shares, share_garden, remove_share and public_link (get / enable / rotate / disable) wrap the sharing service. They change who can see a garden beyond the screen, so they are gated twice: the prompt tells the model to say exactly what it would do and ask, and the tools refuse without confirmed=true, which their descriptions allow only after a yes in the conversation. The refusal names the action, so the question the model asks is precise. share_garden changes the role of an existing share instead of failing on it; remove_share takes the email list_shares reports; an unknown email explains that the person has to sign in once first. public_link returns the address (PANSY_BASE_URL + /g/<token>, via the new Service.PublicShareURL), never a bare token. delete_planting is the hard delete for a plop that was never really planted, as opposed to remove_planting's "it came out"; it is recorded, so undoable. Co-Authored-By: Claude Fable 5 <[email protected]>
110 lines
3.7 KiB
Go
110 lines
3.7 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 != "" {
|
|
return 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)
|
|
}
|