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 }