github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/move/import.go (about) 1 package move 2 3 import ( 4 "fmt" 5 "net/http" 6 "net/url" 7 "sort" 8 "strings" 9 10 "github.com/cozy/cozy-stack/model/instance" 11 "github.com/cozy/cozy-stack/model/instance/lifecycle" 12 "github.com/cozy/cozy-stack/model/job" 13 csettings "github.com/cozy/cozy-stack/model/settings" 14 "github.com/cozy/cozy-stack/pkg/consts" 15 "github.com/cozy/cozy-stack/pkg/couchdb" 16 "github.com/cozy/cozy-stack/pkg/jsonapi" 17 "github.com/cozy/cozy-stack/pkg/mail" 18 "github.com/cozy/cozy-stack/pkg/safehttp" 19 multierror "github.com/hashicorp/go-multierror" 20 ) 21 22 // ImportOptions contains the options for launching the import worker. 23 type ImportOptions struct { 24 SettingsURL string `json:"url,omitempty"` 25 ManifestURL string `json:"manifest_url,omitempty"` 26 Vault bool `json:"vault,omitempty"` 27 MoveFrom *FromOptions `json:"move_from,omitempty"` 28 } 29 30 // FromOptions is used when the import finishes to notify the source Cozy. 31 type FromOptions struct { 32 URL string `json:"url"` 33 Token string `json:"token"` 34 } 35 36 // CheckImport returns an error if an exports cannot be found at the given URL, 37 // or if the instance has not enough disk space to import the files. 38 func CheckImport(inst *instance.Instance, settingsURL string) error { 39 manifestURL, err := transformSettingsURLToManifestURL(settingsURL) 40 if err != nil { 41 inst.Logger().WithNamespace("move"). 42 Debugf("Invalid settings URL %s: %s", settingsURL, err) 43 return ErrExportNotFound 44 } 45 manifest, err := fetchManifest(manifestURL) 46 if err != nil { 47 inst.Logger().WithNamespace("move"). 48 Warnf("Cannot fetch manifest: %s", err) 49 return ErrExportNotFound 50 } 51 if inst.BytesDiskQuota > 0 && manifest.TotalSize > inst.BytesDiskQuota { 52 return ErrNotEnoughSpace 53 } 54 return nil 55 } 56 57 // ScheduleImport blocks the instance and adds a job to import the data from 58 // the given URL. 59 func ScheduleImport(inst *instance.Instance, options ImportOptions) error { 60 manifestURL, err := transformSettingsURLToManifestURL(options.SettingsURL) 61 if err != nil { 62 return ErrExportNotFound 63 } 64 options.ManifestURL = manifestURL 65 options.SettingsURL = "" 66 msg, err := job.NewMessage(options) 67 if err != nil { 68 return err 69 } 70 _, err = job.System().PushJob(inst, &job.JobRequest{ 71 WorkerType: "import", 72 Message: msg, 73 }) 74 if err != nil { 75 return err 76 } 77 78 settings, err := inst.SettingsDocument() 79 if err != nil { 80 return nil 81 } 82 settings.M["importing"] = true 83 _ = couchdb.UpdateDoc(inst, settings) 84 return nil 85 } 86 87 func transformSettingsURLToManifestURL(settingsURL string) (string, error) { 88 u, err := url.Parse(strings.TrimSpace(settingsURL)) 89 if err != nil { 90 return "", err 91 } 92 if strings.HasPrefix(u.Host, consts.SettingsSlug+".") { 93 // Nested subdomains 94 u.Host = strings.TrimPrefix(u.Host, consts.SettingsSlug+".") 95 } else { 96 // Flat subdomains 97 parts := strings.Split(u.Host, ".") 98 parts[0] = strings.TrimSuffix(parts[0], "-"+consts.SettingsSlug) 99 u.Host = strings.Join(parts, ".") 100 } 101 if !strings.HasPrefix(u.Fragment, "/exports/") { 102 return "", fmt.Errorf("Fragment is not in the expected format") 103 } 104 mac := strings.TrimPrefix(u.Fragment, "/exports/") 105 u.Fragment = "" 106 u.Path = "/move/exports/" + mac 107 u.RawPath = "" 108 u.RawQuery = "" 109 return u.String(), nil 110 } 111 112 func fetchManifest(manifestURL string) (*ExportDoc, error) { 113 res, err := safehttp.ClientWithKeepAlive.Get(manifestURL) 114 if err != nil { 115 return nil, err 116 } 117 defer res.Body.Close() 118 if res.StatusCode != http.StatusOK { 119 return nil, ErrExportNotFound 120 } 121 doc := &ExportDoc{} 122 if _, err = jsonapi.Bind(res.Body, doc); err != nil { 123 return nil, err 124 } 125 if doc.State != ExportStateDone { 126 return nil, ErrExportNotFound 127 } 128 return doc, nil 129 } 130 131 // Import downloads the documents and files from an export and add them to the 132 // local instance. It returns the list of slugs for apps/konnectors that have 133 // not been installed. 134 func Import(inst *instance.Instance, options ImportOptions) ([]string, error) { 135 defer func() { 136 settings, err := inst.SettingsDocument() 137 if err == nil { 138 delete(settings.M, "importing") 139 _ = couchdb.UpdateDoc(inst, settings) 140 } 141 }() 142 143 doc, err := fetchManifest(options.ManifestURL) 144 if err != nil { 145 return nil, err 146 } 147 148 if err = GetStore().SetAllowDeleteAccounts(inst); err != nil { 149 return nil, err 150 } 151 if err = lifecycle.Reset(inst); err != nil { 152 return nil, err 153 } 154 if err = GetStore().ClearAllowDeleteAccounts(inst); err != nil { 155 return nil, err 156 } 157 158 im := &importer{ 159 inst: inst, 160 fs: inst.VFS(), 161 options: options, 162 doc: doc, 163 servicesInError: make(map[string]bool), 164 } 165 if err = im.importPart(""); err != nil { 166 return nil, err 167 } 168 for _, cursor := range doc.PartsCursors { 169 if erri := im.importPart(cursor); erri != nil { 170 err = multierror.Append(err, erri) 171 } 172 } 173 if err != nil { 174 return nil, err 175 } 176 177 var inError []string 178 for slug := range im.servicesInError { 179 inError = append(inError, slug) 180 } 181 sort.Strings(inError) 182 return inError, nil 183 } 184 185 // ImportIsFinished returns true unless an import is running 186 func ImportIsFinished(inst *instance.Instance) bool { 187 settings, err := inst.SettingsDocument() 188 if err != nil { 189 return false 190 } 191 importing, _ := settings.M["importing"].(bool) 192 return !importing 193 } 194 195 // Status is a type for the status of an import. 196 type Status int 197 198 const ( 199 // StatusMoveSuccess is the status when a move has been successful. 200 StatusMoveSuccess Status = iota + 1 201 // StatusImportSuccess is the status when a import of a tarball has been 202 // successful. 203 StatusImportSuccess 204 // StatusMoveFailure is the status when the move has failed. 205 StatusMoveFailure 206 // StatusImportFailure is the status when the import has failed. 207 StatusImportFailure 208 ) 209 210 // SendImportDoneMail sends an email to the user when the import is done. The 211 // content will depend if the import has been successful or not, and if it was 212 // a move or just a import of a tarball. 213 func SendImportDoneMail(inst *instance.Instance, status Status, notInstalled []string) error { 214 var email mail.Options 215 switch status { 216 case StatusMoveSuccess, StatusImportSuccess: 217 tmpl := "import_success" 218 if status == StatusMoveSuccess { 219 tmpl = "move_success" 220 } 221 publicName, _ := csettings.PublicName(inst) 222 link := inst.SubDomain(consts.HomeSlug) 223 email = mail.Options{ 224 Mode: mail.ModeFromStack, 225 TemplateName: tmpl, 226 TemplateValues: map[string]interface{}{ 227 "AppsNotInstalled": strings.Join(notInstalled, ", "), 228 "CozyLink": link.String(), 229 "PublicName": publicName, 230 }, 231 } 232 case StatusMoveFailure: 233 email = mail.Options{ 234 Mode: mail.ModeFromStack, 235 TemplateName: "move_error", 236 } 237 case StatusImportFailure: 238 email = mail.Options{ 239 Mode: mail.ModeFromStack, 240 TemplateName: "import_error", 241 } 242 default: 243 return fmt.Errorf("Unknown import status: %v", status) 244 } 245 246 msg, err := job.NewMessage(&email) 247 if err != nil { 248 return err 249 } 250 _, err = job.System().PushJob(inst, &job.JobRequest{ 251 WorkerType: "sendmail", 252 Message: msg, 253 }) 254 return err 255 }