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 }