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  }