github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/settings/service.go (about)

     1  package settings
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"net/url"
     7  	"strings"
     8  	"time"
     9  
    10  	"github.com/cozy/cozy-stack/model/cloudery"
    11  	"github.com/cozy/cozy-stack/model/instance"
    12  	"github.com/cozy/cozy-stack/model/token"
    13  	"github.com/cozy/cozy-stack/pkg/couchdb"
    14  	"github.com/cozy/cozy-stack/pkg/emailer"
    15  	"github.com/cozy/cozy-stack/pkg/prefixer"
    16  )
    17  
    18  const TokenExpiration = 7 * 24 * time.Hour
    19  
    20  var (
    21  	ErrInvalidType    = errors.New("invalid type")
    22  	ErrInvalidID      = errors.New("invalid id")
    23  	ErrNoPendingEmail = errors.New("no pending email")
    24  )
    25  
    26  type ExternalTies struct {
    27  	HasBlockingSubscription bool                           `json:"has_blocking_subscription"`
    28  	BlockingSubscription    *cloudery.BlockingSubscription `json:"blocking_subscription,omitempty"`
    29  }
    30  
    31  // Storage used to persiste and fetch settings data.
    32  type Storage interface {
    33  	setInstanceSettings(db prefixer.Prefixer, doc *couchdb.JSONDoc) error
    34  	getInstanceSettings(db prefixer.Prefixer) (*couchdb.JSONDoc, error)
    35  }
    36  
    37  // SettingsService handle the business logic around "settings".
    38  //
    39  // This service handle 2 structured documents present in [consts.Settings]
    40  // - The "instance settings" ([consts.InstanceSettingsID])
    41  // - The "bitwarden settings" ([consts.BitwardenSettingsID]) (#TODO)
    42  type SettingsService struct {
    43  	emailer  emailer.Emailer
    44  	instance instance.Service
    45  	token    token.Service
    46  	cloudery cloudery.Service
    47  	storage  Storage
    48  }
    49  
    50  // NewService instantiates a new [SettingsService].
    51  func NewService(
    52  	emailer emailer.Emailer,
    53  	instance instance.Service,
    54  	token token.Service,
    55  	cloudery cloudery.Service,
    56  	storage Storage,
    57  ) *SettingsService {
    58  	return &SettingsService{emailer, instance, token, cloudery, storage}
    59  }
    60  
    61  // PublicName returns the settings' public name or a default one if missing
    62  func (s *SettingsService) PublicName(db prefixer.Prefixer) (string, error) {
    63  	doc, err := s.storage.getInstanceSettings(db)
    64  	if err != nil {
    65  		return "", err
    66  	}
    67  	publicName, _ := doc.M["public_name"].(string)
    68  	// if the public name is not defined, use the instance's domain
    69  	if publicName == "" {
    70  		split := strings.Split(db.DomainName(), ".")
    71  		publicName = split[0]
    72  	}
    73  	return publicName, nil
    74  }
    75  
    76  // GetInstanceSettings allows for fetch directly the [consts.InstanceSettingsID] couchdb document.
    77  func (s *SettingsService) GetInstanceSettings(db prefixer.Prefixer) (*couchdb.JSONDoc, error) {
    78  	return s.storage.getInstanceSettings(db)
    79  }
    80  
    81  // SetInstanceSettings allows a set directly the [consts.InstanceSettingsID] couchdb document.
    82  func (s *SettingsService) SetInstanceSettings(db prefixer.Prefixer, doc *couchdb.JSONDoc) error {
    83  	return s.storage.setInstanceSettings(db, doc)
    84  }
    85  
    86  type UpdateEmailCmd struct {
    87  	Passphrase []byte
    88  	Email      string
    89  }
    90  
    91  // StartEmailUpdate will start the email updating process.
    92  //
    93  // This process consists of validating the user with a password and sending
    94  // a validation email to the new address with a validation link. This link
    95  // will allow the user to confirm its email.
    96  func (s *SettingsService) StartEmailUpdate(inst *instance.Instance, cmd *UpdateEmailCmd) error {
    97  	err := s.instance.CheckPassphrase(inst, cmd.Passphrase)
    98  	if err != nil {
    99  		return fmt.Errorf("failed to check passphrase: %w", err)
   100  	}
   101  
   102  	settings, err := s.storage.getInstanceSettings(inst)
   103  	if err != nil {
   104  		return fmt.Errorf("failed to fetch the settings: %w", err)
   105  	}
   106  
   107  	publicName, err := s.PublicName(inst)
   108  	if err != nil {
   109  		return fmt.Errorf("failed to retrieve the instance settings: %w", err)
   110  	}
   111  
   112  	settings.M["pending_email"] = cmd.Email
   113  
   114  	token, err := s.token.GenerateAndSave(inst, token.EmailUpdate, cmd.Email, TokenExpiration)
   115  	if err != nil {
   116  		return fmt.Errorf("failed to generate and save the confirmation token: %w", err)
   117  	}
   118  
   119  	err = s.storage.setInstanceSettings(inst, settings)
   120  	if err != nil {
   121  		return fmt.Errorf("failed to save the settings changes: %w", err)
   122  	}
   123  
   124  	link := inst.PageURL("/settings/email/confirm", url.Values{
   125  		"token": []string{token},
   126  	})
   127  
   128  	err = s.emailer.SendPendingEmail(inst, &emailer.TransactionalEmailCmd{
   129  		TemplateName: "update_email",
   130  		TemplateValues: map[string]interface{}{
   131  			"PublicName":      publicName,
   132  			"EmailUpdateLink": link,
   133  		},
   134  	})
   135  	if err != nil {
   136  		return fmt.Errorf("failed to send the email: %w", err)
   137  	}
   138  
   139  	return nil
   140  }
   141  
   142  // ResendEmailUpdate will resend the validation email.
   143  func (s *SettingsService) ResendEmailUpdate(inst *instance.Instance) error {
   144  	settings, err := s.storage.getInstanceSettings(inst)
   145  	if err != nil {
   146  		return fmt.Errorf("failed to fetch the settings: %w", err)
   147  	}
   148  
   149  	publicName, err := s.PublicName(inst)
   150  	if err != nil {
   151  		return fmt.Errorf("failed to retrieve the instance settings: %w", err)
   152  	}
   153  
   154  	pendingEmail, ok := settings.M["pending_email"].(string)
   155  	if !ok {
   156  		return ErrNoPendingEmail
   157  	}
   158  
   159  	token, err := s.token.GenerateAndSave(inst, token.EmailUpdate, pendingEmail, TokenExpiration)
   160  	if err != nil {
   161  		return fmt.Errorf("failed to generate and save the confirmation token: %w", err)
   162  	}
   163  
   164  	link := inst.PageURL("/settings/email/confirm", url.Values{
   165  		"token": []string{token},
   166  	})
   167  
   168  	err = s.emailer.SendPendingEmail(inst, &emailer.TransactionalEmailCmd{
   169  		TemplateName: "update_email",
   170  		TemplateValues: map[string]interface{}{
   171  			"PublicName":      publicName,
   172  			"EmailUpdateLink": link,
   173  		},
   174  	})
   175  	if err != nil {
   176  		return fmt.Errorf("failed to send the email: %w", err)
   177  	}
   178  
   179  	return nil
   180  }
   181  
   182  // ConfirmEmailUpdate is the second step to the email update process.
   183  //
   184  // This step consiste to make the email change effectif and relay the change
   185  // into the cloudery.
   186  func (s *SettingsService) ConfirmEmailUpdate(inst *instance.Instance, tok string) error {
   187  	settings, err := s.storage.getInstanceSettings(inst)
   188  	if err != nil {
   189  		return fmt.Errorf("failed to fetch the settings: %w", err)
   190  	}
   191  
   192  	pendingEmail, ok := settings.M["pending_email"].(string)
   193  	if !ok {
   194  		return ErrNoPendingEmail
   195  	}
   196  
   197  	err = s.token.Validate(inst, token.EmailUpdate, pendingEmail, tok)
   198  	if err != nil {
   199  		return fmt.Errorf("failed to validate the token: %w", err)
   200  	}
   201  
   202  	settings.M["email"] = pendingEmail
   203  	settings.M["pending_email"] = nil
   204  
   205  	err = s.storage.setInstanceSettings(inst, settings)
   206  	if err != nil {
   207  		return fmt.Errorf("failed to save the settings changes: %w", err)
   208  	}
   209  
   210  	publicName, _ := settings.M["public_name"].(string)
   211  	// if the public name is not defined, use the instance's domain
   212  	if publicName == "" {
   213  		split := strings.Split(inst.DomainName(), ".")
   214  		publicName = split[0]
   215  	}
   216  
   217  	err = s.cloudery.SaveInstance(inst, &cloudery.SaveCmd{
   218  		Locale:     inst.Locale,
   219  		Email:      settings.M["email"].(string),
   220  		PublicName: publicName,
   221  	})
   222  	if err != nil {
   223  		return fmt.Errorf("failed to update the cloudery: %w", err)
   224  	}
   225  
   226  	return nil
   227  }
   228  
   229  // CancelEmailUpdate cancel any ongoing email update process
   230  //
   231  // If no process is ongoin it's a no-op.
   232  func (s *SettingsService) CancelEmailUpdate(inst *instance.Instance) error {
   233  	settings, err := s.storage.getInstanceSettings(inst)
   234  	if err != nil {
   235  		return fmt.Errorf("failed to fetch the settings: %w", err)
   236  	}
   237  
   238  	_, ok := settings.M["pending_email"].(string)
   239  	if !ok {
   240  		return nil
   241  	}
   242  
   243  	settings.M["pending_email"] = nil
   244  
   245  	err = s.storage.setInstanceSettings(inst, settings)
   246  	if err != nil {
   247  		return fmt.Errorf("failed to save the settings changes: %w", err)
   248  	}
   249  
   250  	return nil
   251  }
   252  
   253  func (s *SettingsService) GetExternalTies(inst *instance.Instance) (*ExternalTies, error) {
   254  	blockingSubscription, err := s.cloudery.BlockingSubscription(inst)
   255  	if err != nil {
   256  		return nil, err
   257  	}
   258  
   259  	var ties ExternalTies
   260  	if blockingSubscription != nil {
   261  		ties = ExternalTies{
   262  			HasBlockingSubscription: true,
   263  			BlockingSubscription:    blockingSubscription,
   264  		}
   265  	}
   266  
   267  	return &ties, nil
   268  }