355 lines
10 KiB
Go
355 lines
10 KiB
Go
package sharing
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const defaultVisitorLimit int64 = 100
|
|
|
|
type Service struct {
|
|
store Store
|
|
codec *secureCodec
|
|
now func() time.Time
|
|
}
|
|
|
|
func NewService(store Store, encryptionSecret string) (*Service, error) {
|
|
if store == nil {
|
|
return nil, fmt.Errorf("%w: sharing store is required", ErrInvalidInput)
|
|
}
|
|
codec, err := newSecureCodec(encryptionSecret)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Service{store: store, codec: codec, now: time.Now}, nil
|
|
}
|
|
|
|
func (s *Service) Close() error {
|
|
if s == nil || s.store == nil {
|
|
return nil
|
|
}
|
|
return s.store.Close()
|
|
}
|
|
|
|
func ParseLinkPermission(value string) (Permission, error) {
|
|
permission := Permission(strings.ToLower(strings.TrimSpace(value)))
|
|
switch permission {
|
|
case PermissionPrivate, PermissionCanvas, PermissionViewer, PermissionEditor:
|
|
return permission, nil
|
|
default:
|
|
return "", fmt.Errorf("%w: unsupported link permission", ErrInvalidInput)
|
|
}
|
|
}
|
|
|
|
func ParseMemberPermission(value string) (Permission, error) {
|
|
permission := Permission(strings.ToLower(strings.TrimSpace(value)))
|
|
switch permission {
|
|
case PermissionCanvas, PermissionViewer, PermissionEditor:
|
|
return permission, nil
|
|
default:
|
|
return "", fmt.Errorf("%w: unsupported member permission", ErrInvalidInput)
|
|
}
|
|
}
|
|
|
|
func (s *Service) Settings(ctx context.Context, projectID string, ownerID string) (Settings, error) {
|
|
projectID = strings.TrimSpace(projectID)
|
|
ownerID = strings.TrimSpace(ownerID)
|
|
policy, err := s.store.GetPolicyByProject(ctx, projectID)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return Settings{ProjectID: projectID, OwnerID: ownerID, LinkPermission: PermissionPrivate, Members: []Member{}}, nil
|
|
}
|
|
return Settings{}, err
|
|
}
|
|
if policy.OwnerID != ownerID {
|
|
return Settings{}, ErrForbidden
|
|
}
|
|
return s.settingsFromPolicy(ctx, policy)
|
|
}
|
|
|
|
func (s *Service) UpdateLink(ctx context.Context, projectID string, ownerID string, permission Permission) (Settings, error) {
|
|
if permission == PermissionPrivate {
|
|
policy, err := s.store.GetPolicyByProject(ctx, projectID)
|
|
if err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return Settings{ProjectID: projectID, OwnerID: ownerID, LinkPermission: PermissionPrivate, Members: []Member{}}, nil
|
|
}
|
|
return Settings{}, err
|
|
}
|
|
if policy.OwnerID != ownerID {
|
|
return Settings{}, ErrForbidden
|
|
}
|
|
policy, err = s.store.UpdatePolicyPermission(ctx, projectID, ownerID, permission, s.now().UTC())
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
return s.settingsFromPolicy(ctx, policy)
|
|
}
|
|
policy, err := s.ensurePolicy(ctx, projectID, ownerID)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
policy, err = s.store.UpdatePolicyPermission(ctx, projectID, ownerID, permission, s.now().UTC())
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
return s.settingsFromPolicy(ctx, policy)
|
|
}
|
|
|
|
func (s *Service) Invite(ctx context.Context, projectID string, owner Actor, identifier string, permission Permission) (Settings, error) {
|
|
identifierType, normalized, err := normalizeIdentifier(identifier)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
owner.UserID = strings.TrimSpace(owner.UserID)
|
|
if !owner.Authenticated || owner.UserID == "" {
|
|
return Settings{}, ErrForbidden
|
|
}
|
|
if ActorOwnsIdentifier(owner, normalized) {
|
|
return Settings{}, ErrSelfInvite
|
|
}
|
|
policy, err := s.ensurePolicy(ctx, projectID, owner.UserID)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
ciphertext, err := s.codec.encrypt(normalized)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
now := s.now().UTC()
|
|
_, err = s.store.CreateMember(ctx, MemberRecord{
|
|
ID: uuid.NewString(),
|
|
ProjectID: projectID,
|
|
IdentifierType: identifierType,
|
|
IdentifierHash: hashValue(identifierType + ":" + normalized),
|
|
IdentifierCiphertext: ciphertext,
|
|
Permission: permission,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
return s.settingsFromPolicy(ctx, policy)
|
|
}
|
|
|
|
func (s *Service) UpdateMember(ctx context.Context, projectID string, ownerID string, memberID string, permission Permission) (Settings, error) {
|
|
policy, err := s.ownerPolicy(ctx, projectID, ownerID)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
if _, err := s.store.UpdateMember(ctx, projectID, strings.TrimSpace(memberID), permission, s.now().UTC()); err != nil {
|
|
return Settings{}, err
|
|
}
|
|
return s.settingsFromPolicy(ctx, policy)
|
|
}
|
|
|
|
func (s *Service) DeleteMember(ctx context.Context, projectID string, ownerID string, memberID string) (Settings, error) {
|
|
policy, err := s.ownerPolicy(ctx, projectID, ownerID)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
if err := s.store.DeleteMember(ctx, projectID, strings.TrimSpace(memberID)); err != nil {
|
|
return Settings{}, err
|
|
}
|
|
return s.settingsFromPolicy(ctx, policy)
|
|
}
|
|
|
|
func (s *Service) RevokeAll(ctx context.Context, projectID string, ownerID string) (Settings, error) {
|
|
if policy, err := s.store.GetPolicyByProject(ctx, projectID); err == nil && policy.OwnerID != ownerID {
|
|
return Settings{}, ErrForbidden
|
|
} else if err != nil && !errors.Is(err, ErrNotFound) {
|
|
return Settings{}, err
|
|
}
|
|
if err := s.store.DeleteAll(ctx, projectID, ownerID); err != nil {
|
|
return Settings{}, err
|
|
}
|
|
return Settings{ProjectID: projectID, OwnerID: ownerID, LinkPermission: PermissionPrivate, Members: []Member{}}, nil
|
|
}
|
|
|
|
func (s *Service) ResolveAccess(ctx context.Context, shareID string, actor Actor) (Access, error) {
|
|
shareID = strings.TrimSpace(shareID)
|
|
if !validShareID(shareID) {
|
|
return Access{}, ErrNotFound
|
|
}
|
|
policy, err := s.store.GetPolicyByTokenHash(ctx, hashValue(shareID))
|
|
if err != nil {
|
|
return Access{}, err
|
|
}
|
|
permission := policy.LinkPermission
|
|
source := "link"
|
|
isOwner := actor.Authenticated && actor.UserID != "" && actor.UserID == policy.OwnerID
|
|
if isOwner {
|
|
permission = PermissionOwner
|
|
source = "owner"
|
|
} else if actor.Authenticated {
|
|
members, listErr := s.store.ListMembers(ctx, policy.ProjectID)
|
|
if listErr != nil {
|
|
return Access{}, listErr
|
|
}
|
|
if memberPermission, ok := permissionForActor(members, actor); ok {
|
|
permission = memberPermission
|
|
source = "member"
|
|
}
|
|
}
|
|
if permission == PermissionPrivate {
|
|
return Access{}, ErrNotFound
|
|
}
|
|
if permission == PermissionEditor && !actor.Authenticated {
|
|
return Access{}, ErrLoginRequired
|
|
}
|
|
capabilities := permission.Capabilities()
|
|
if !capabilities.CanViewCanvas {
|
|
return Access{}, ErrForbidden
|
|
}
|
|
return Access{
|
|
ShareID: shareID,
|
|
ProjectID: policy.ProjectID,
|
|
OwnerID: policy.OwnerID,
|
|
Permission: permission,
|
|
Source: source,
|
|
Authenticated: actor.Authenticated,
|
|
IsOwner: isOwner,
|
|
Capabilities: capabilities,
|
|
PolicyVersion: policy.Version,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) RecordVisit(ctx context.Context, access Access, actor Actor, visitorID string) error {
|
|
if access.IsOwner {
|
|
return nil
|
|
}
|
|
visitorKey := strings.TrimSpace(visitorID)
|
|
userID := ""
|
|
if actor.Authenticated && actor.UserID != "" {
|
|
visitorKey = "user:" + actor.UserID
|
|
userID = actor.UserID
|
|
}
|
|
if visitorKey == "" || len(visitorKey) > 128 {
|
|
return nil
|
|
}
|
|
visitorHash := hashValue("visit:" + access.ProjectID + ":" + visitorKey)
|
|
now := s.now().UTC()
|
|
return s.store.RecordVisit(ctx, VisitRecord{
|
|
ID: hashID(visitorHash),
|
|
ProjectID: access.ProjectID,
|
|
VisitorHash: visitorHash,
|
|
UserID: userID,
|
|
VisitCount: 1,
|
|
FirstSeenAt: now,
|
|
LastSeenAt: now,
|
|
})
|
|
}
|
|
|
|
func (s *Service) Visitors(ctx context.Context, projectID string, ownerID string) ([]VisitRecord, error) {
|
|
if _, err := s.ownerPolicy(ctx, projectID, ownerID); err != nil {
|
|
if errors.Is(err, ErrNotFound) {
|
|
return []VisitRecord{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return s.store.ListVisitors(ctx, projectID, defaultVisitorLimit)
|
|
}
|
|
|
|
func (s *Service) ensurePolicy(ctx context.Context, projectID string, ownerID string) (PolicyRecord, error) {
|
|
policy, err := s.store.GetPolicyByProject(ctx, projectID)
|
|
if err == nil {
|
|
if policy.OwnerID != ownerID {
|
|
return PolicyRecord{}, ErrForbidden
|
|
}
|
|
return policy, nil
|
|
}
|
|
if !errors.Is(err, ErrNotFound) {
|
|
return PolicyRecord{}, err
|
|
}
|
|
shareID, err := s.codec.newShareID()
|
|
if err != nil {
|
|
return PolicyRecord{}, err
|
|
}
|
|
ciphertext, err := s.codec.encrypt(shareID)
|
|
if err != nil {
|
|
return PolicyRecord{}, err
|
|
}
|
|
now := s.now().UTC()
|
|
return s.store.CreatePolicy(ctx, PolicyRecord{
|
|
ProjectID: projectID,
|
|
OwnerID: ownerID,
|
|
TokenHash: hashValue(shareID),
|
|
TokenCiphertext: ciphertext,
|
|
LinkPermission: PermissionPrivate,
|
|
Version: 1,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
|
|
func (s *Service) ownerPolicy(ctx context.Context, projectID string, ownerID string) (PolicyRecord, error) {
|
|
policy, err := s.store.GetPolicyByProject(ctx, projectID)
|
|
if err != nil {
|
|
return PolicyRecord{}, err
|
|
}
|
|
if policy.OwnerID != ownerID {
|
|
return PolicyRecord{}, ErrForbidden
|
|
}
|
|
return policy, nil
|
|
}
|
|
|
|
func (s *Service) settingsFromPolicy(ctx context.Context, policy PolicyRecord) (Settings, error) {
|
|
shareID, err := s.codec.decrypt(policy.TokenCiphertext)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
if !validShareID(shareID) || !hashesEqual(hashValue(shareID), policy.TokenHash) {
|
|
return Settings{}, fmt.Errorf("%w: share token integrity check failed", ErrInvalidInput)
|
|
}
|
|
records, err := s.store.ListMembers(ctx, policy.ProjectID)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
members := make([]Member, 0, len(records))
|
|
for _, record := range records {
|
|
identifier, decryptErr := s.codec.decrypt(record.IdentifierCiphertext)
|
|
if decryptErr != nil {
|
|
return Settings{}, decryptErr
|
|
}
|
|
members = append(members, Member{
|
|
ID: record.ID,
|
|
IdentifierType: record.IdentifierType,
|
|
Identifier: identifier,
|
|
Permission: record.Permission,
|
|
CreatedAt: record.CreatedAt,
|
|
UpdatedAt: record.UpdatedAt,
|
|
})
|
|
}
|
|
visitorCount, err := s.store.CountVisitors(ctx, policy.ProjectID)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
return Settings{
|
|
ProjectID: policy.ProjectID,
|
|
OwnerID: policy.OwnerID,
|
|
ShareID: shareID,
|
|
LinkPermission: policy.LinkPermission,
|
|
Members: members,
|
|
VisitorCount: visitorCount,
|
|
UpdatedAt: policy.UpdatedAt,
|
|
}, nil
|
|
}
|
|
|
|
func permissionForActor(members []MemberRecord, actor Actor) (Permission, bool) {
|
|
identifiers := actorIdentifiers(actor)
|
|
for _, member := range members {
|
|
for _, identifier := range identifiers {
|
|
if hashesEqual(member.IdentifierHash, hashValue(identifier)) {
|
|
return member.Permission, true
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|