Public read-only share link (#41)
A per-garden public link anyone can open read-only, with no login and no OIDC. Backend: - Migration 0004 adds gardens.public_token (nullable, unique over non-NULL). - Owner-only management: GET/POST/DELETE /gardens/:id/share-link (state / enable-or-rotate / disable). The token is stored raw (it must be shown back to the owner) and never selected into the normal garden payload. - Unauthenticated GET /api/v1/public/gardens/:token returns the read-only /full payload — the token is the capability, so no requireAuth and no redirect. The public view drops MyRole and OwnerID to minimize what an anonymous viewer learns. Unknown/rotated/disabled tokens are masked as 404. - assembleFull is extracted from GardenFull so both reads return one shape. Frontend: - /g/$token route with NO auth guard (SPA fallback already serves it), rendering GardenCanvas read-only via usePublicGarden. - Share dialog gains a Public link section: create, copy, regenerate, turn off. Tests: service lifecycle + ACL (owner-only, non-owner masked, rotate/disable invalidation) and the HTTP surface incl. the cookieless public read. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
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 don't expose the
|
||||
// owner's user id (the page renders fine without it).
|
||||
full.Garden.MyRole = ""
|
||||
full.Garden.OwnerID = 0
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user