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 }