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