Files
moteva/server/internal/modules/sharing/service.go
T

355 lines
10 KiB
Go
Raw Normal View History

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
}