github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/move/doc.go (about)

     1  package move
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/base64"
     6  	"encoding/json"
     7  	"fmt"
     8  	"math"
     9  	"net/http"
    10  	"time"
    11  
    12  	"github.com/cozy/cozy-stack/model/bitwarden/settings"
    13  	"github.com/cozy/cozy-stack/model/instance"
    14  	"github.com/cozy/cozy-stack/model/job"
    15  	csettings "github.com/cozy/cozy-stack/model/settings"
    16  	"github.com/cozy/cozy-stack/pkg/consts"
    17  	"github.com/cozy/cozy-stack/pkg/couchdb"
    18  	"github.com/cozy/cozy-stack/pkg/couchdb/mango"
    19  	"github.com/cozy/cozy-stack/pkg/crypto"
    20  	"github.com/cozy/cozy-stack/pkg/jsonapi"
    21  	"github.com/cozy/cozy-stack/pkg/mail"
    22  	"github.com/cozy/cozy-stack/pkg/prefixer"
    23  	"github.com/cozy/cozy-stack/pkg/realtime"
    24  	"github.com/cozy/cozy-stack/pkg/safehttp"
    25  	"github.com/labstack/echo/v4"
    26  )
    27  
    28  const (
    29  	// ExportStateExporting is the state used when the export document is being
    30  	// created.
    31  	ExportStateExporting = "exporting"
    32  	// ExportStateDone is used when the export document is finished, without
    33  	// error.
    34  	ExportStateDone = "done"
    35  	// ExportStateError is used when the export document is finshed with error.
    36  	ExportStateError = "error"
    37  )
    38  
    39  // ExportDoc is a documents storing the metadata of an export.
    40  type ExportDoc struct {
    41  	DocID     string `json:"_id,omitempty"`
    42  	DocRev    string `json:"_rev,omitempty"`
    43  	Domain    string `json:"domain"`
    44  	PartsSize int64  `json:"parts_size,omitempty"`
    45  
    46  	PartsCursors     []string      `json:"parts_cursors"`
    47  	WithDoctypes     []string      `json:"with_doctypes,omitempty"`
    48  	State            string        `json:"state"`
    49  	CreatedAt        time.Time     `json:"created_at"`
    50  	ExpiresAt        time.Time     `json:"expires_at"`
    51  	TotalSize        int64         `json:"total_size,omitempty"`
    52  	CreationDuration time.Duration `json:"creation_duration,omitempty"`
    53  	Error            string        `json:"error,omitempty"`
    54  }
    55  
    56  // DocType implements the couchdb.Doc interface
    57  func (e *ExportDoc) DocType() string { return consts.Exports }
    58  
    59  // ID implements the couchdb.Doc interface
    60  func (e *ExportDoc) ID() string { return e.DocID }
    61  
    62  // Rev implements the couchdb.Doc interface
    63  func (e *ExportDoc) Rev() string { return e.DocRev }
    64  
    65  // SetID implements the couchdb.Doc interface
    66  func (e *ExportDoc) SetID(id string) { e.DocID = id }
    67  
    68  // SetRev implements the couchdb.Doc interface
    69  func (e *ExportDoc) SetRev(rev string) { e.DocRev = rev }
    70  
    71  // Clone implements the couchdb.Doc interface
    72  func (e *ExportDoc) Clone() couchdb.Doc {
    73  	clone := *e
    74  
    75  	clone.PartsCursors = make([]string, len(e.PartsCursors))
    76  	copy(clone.PartsCursors, e.PartsCursors)
    77  
    78  	clone.WithDoctypes = make([]string, len(e.WithDoctypes))
    79  	copy(clone.WithDoctypes, e.WithDoctypes)
    80  
    81  	return &clone
    82  }
    83  
    84  // Links implements the jsonapi.Object interface
    85  func (e *ExportDoc) Links() *jsonapi.LinksList { return nil }
    86  
    87  // Relationships implements the jsonapi.Object interface
    88  func (e *ExportDoc) Relationships() jsonapi.RelationshipMap { return nil }
    89  
    90  // Included implements the jsonapi.Object interface
    91  func (e *ExportDoc) Included() []jsonapi.Object { return nil }
    92  
    93  // HasExpired returns whether or not the export document has expired.
    94  func (e *ExportDoc) HasExpired() bool {
    95  	return time.Until(e.ExpiresAt) <= 0
    96  }
    97  
    98  var _ jsonapi.Object = &ExportDoc{}
    99  
   100  // AcceptDoctype returns true if the documents of the given doctype must be
   101  // exported.
   102  func (e *ExportDoc) AcceptDoctype(doctype string) bool {
   103  	if len(e.WithDoctypes) == 0 {
   104  		return true
   105  	}
   106  	for _, typ := range e.WithDoctypes {
   107  		if typ == doctype {
   108  			return true
   109  		}
   110  	}
   111  	return false
   112  }
   113  
   114  // MarksAsFinished saves the document when the export is done.
   115  func (e *ExportDoc) MarksAsFinished(i *instance.Instance, size int64, err error) error {
   116  	e.CreationDuration = time.Since(e.CreatedAt)
   117  	if err == nil {
   118  		e.State = ExportStateDone
   119  		e.TotalSize = size
   120  	} else {
   121  		e.State = ExportStateError
   122  		e.Error = err.Error()
   123  	}
   124  	return couchdb.UpdateDoc(prefixer.GlobalPrefixer, e)
   125  }
   126  
   127  // SendExportMail sends a mail to the user with a link where they can download
   128  // the export tarballs.
   129  func (e *ExportDoc) SendExportMail(inst *instance.Instance) error {
   130  	link := e.GenerateLink(inst)
   131  	publicName, _ := csettings.PublicName(inst)
   132  	mail := mail.Options{
   133  		Mode:         mail.ModeFromStack,
   134  		TemplateName: "archiver",
   135  		TemplateValues: map[string]interface{}{
   136  			"ArchiveLink": link,
   137  			"PublicName":  publicName,
   138  		},
   139  	}
   140  
   141  	msg, err := job.NewMessage(&mail)
   142  	if err != nil {
   143  		return err
   144  	}
   145  
   146  	_, err = job.System().PushJob(inst, &job.JobRequest{
   147  		WorkerType: "sendmail",
   148  		Message:    msg,
   149  	})
   150  	return err
   151  }
   152  
   153  // NotifyTarget sends an HTTP request to the target so that it can start
   154  // importing the tarballs.
   155  func (e *ExportDoc) NotifyTarget(inst *instance.Instance, to *MoveToOptions, token string, ignoreVault bool) error {
   156  	link := e.GenerateLink(inst)
   157  	u := to.ImportsURL()
   158  	vault := false
   159  	if !ignoreVault {
   160  		vault = settings.HasVault(inst)
   161  	}
   162  	payload, err := json.Marshal(map[string]interface{}{
   163  		"data": map[string]interface{}{
   164  			"attributes": map[string]interface{}{
   165  				"url":   link,
   166  				"vault": vault,
   167  				"move_from": map[string]interface{}{
   168  					"url":   inst.PageURL("/", nil),
   169  					"token": token,
   170  				},
   171  			},
   172  		},
   173  	})
   174  	if err != nil {
   175  		return err
   176  	}
   177  	req, err := http.NewRequest("POST", u, bytes.NewReader(payload))
   178  	if err != nil {
   179  		return err
   180  	}
   181  	req.Header.Add(echo.HeaderContentType, jsonapi.ContentType)
   182  	req.Header.Add(echo.HeaderAuthorization, "Bearer "+to.Token)
   183  	res, err := safehttp.ClientWithKeepAlive.Do(req)
   184  	if err != nil {
   185  		return err
   186  	}
   187  	defer res.Body.Close()
   188  	if res.StatusCode != 200 {
   189  		return fmt.Errorf("Cannot notify target: %d", res.StatusCode)
   190  	}
   191  	return nil
   192  }
   193  
   194  func (e *ExportDoc) NotifyRealtime() {
   195  	realtime.GetHub().Publish(prefixer.GlobalPrefixer, realtime.EventCreate, e.Clone(), nil)
   196  }
   197  
   198  // GenerateLink generates a link to download the export with a MAC.
   199  func (e *ExportDoc) GenerateLink(i *instance.Instance) string {
   200  	mac, err := crypto.EncodeAuthMessage(archiveMACConfig, i.SessionSecret(), []byte(e.ID()), nil)
   201  	if err != nil {
   202  		panic(fmt.Errorf("could not generate archive auth message: %s", err))
   203  	}
   204  	encoded := base64.URLEncoding.EncodeToString(mac)
   205  	link := i.SubDomain(consts.SettingsSlug)
   206  	link.Fragment = fmt.Sprintf("/exports/%s", encoded)
   207  	return link.String()
   208  }
   209  
   210  // CleanPreviousExports ensures that we have no old exports (or clean them).
   211  func (e *ExportDoc) CleanPreviousExports(archiver Archiver) error {
   212  	exportedDocs, err := GetExports(e.Domain)
   213  	if err != nil {
   214  		return err
   215  	}
   216  	notRemovedDocs := exportedDocs[:0]
   217  	for _, e := range exportedDocs {
   218  		if e.State == ExportStateExporting && time.Since(e.CreatedAt) < 24*time.Hour {
   219  			return ErrExportConflict
   220  		}
   221  		notRemovedDocs = append(notRemovedDocs, e)
   222  	}
   223  	if len(notRemovedDocs) > 0 {
   224  		_ = archiver.RemoveArchives(notRemovedDocs)
   225  	}
   226  	return nil
   227  }
   228  
   229  func prepareExportDoc(i *instance.Instance, opts ExportOptions) *ExportDoc {
   230  	createdAt := time.Now()
   231  
   232  	// The size of the buckets can be specified by the options. If it is not
   233  	// the case, it is computed from the disk usage. An instance with 4x more
   234  	// bytes than another instance will have 2x more buckets and the buckets
   235  	// will be 2x larger.
   236  	bucketSize := opts.PartsSize
   237  	if bucketSize < minimalPartsSize {
   238  		bucketSize = minimalPartsSize
   239  		if usage, err := i.VFS().DiskUsage(); err == nil && usage > bucketSize {
   240  			factor := math.Sqrt(float64(usage) / float64(minimalPartsSize))
   241  			bucketSize = int64(factor * float64(bucketSize))
   242  		}
   243  	}
   244  
   245  	maxAge := opts.MaxAge
   246  	if maxAge == 0 || maxAge > archiveMaxAge {
   247  		maxAge = archiveMaxAge
   248  	}
   249  
   250  	return &ExportDoc{
   251  		Domain:       i.Domain,
   252  		State:        ExportStateExporting,
   253  		CreatedAt:    createdAt,
   254  		ExpiresAt:    createdAt.Add(maxAge),
   255  		WithDoctypes: opts.WithDoctypes,
   256  		TotalSize:    -1,
   257  		PartsSize:    bucketSize,
   258  	}
   259  }
   260  
   261  // verifyAuthMessage verifies the given MAC to authenticate and grant the
   262  // access to the export data.
   263  func verifyAuthMessage(i *instance.Instance, mac []byte) (string, bool) {
   264  	exportID, err := crypto.DecodeAuthMessage(archiveMACConfig, i.SessionSecret(), mac, nil)
   265  	return string(exportID), err == nil
   266  }
   267  
   268  // GetExport returns an Export document associated with the given instance and
   269  // with the given MAC message.
   270  func GetExport(inst *instance.Instance, mac []byte) (*ExportDoc, error) {
   271  	exportID, ok := verifyAuthMessage(inst, mac)
   272  	if !ok {
   273  		return nil, ErrMACInvalid
   274  	}
   275  	var exportDoc ExportDoc
   276  	if err := couchdb.GetDoc(prefixer.GlobalPrefixer, consts.Exports, exportID, &exportDoc); err != nil {
   277  		if couchdb.IsNotFoundError(err) || couchdb.IsNoDatabaseError(err) {
   278  			return nil, ErrExportNotFound
   279  		}
   280  		return nil, err
   281  	}
   282  	if exportDoc.HasExpired() {
   283  		return nil, ErrExportExpired
   284  	}
   285  	return &exportDoc, nil
   286  }
   287  
   288  // GetExports returns the list of exported documents.
   289  func GetExports(domain string) ([]*ExportDoc, error) {
   290  	var docs []*ExportDoc
   291  	req := &couchdb.FindRequest{
   292  		UseIndex: "by-domain",
   293  		Selector: mango.Equal("domain", domain),
   294  		Sort: mango.SortBy{
   295  			{Field: "domain", Direction: mango.Desc},
   296  			{Field: "created_at", Direction: mango.Desc},
   297  		},
   298  		Limit: 256,
   299  	}
   300  	err := couchdb.FindDocs(prefixer.GlobalPrefixer, consts.Exports, req, &docs)
   301  	if err != nil && !couchdb.IsNoDatabaseError(err) {
   302  		return nil, err
   303  	}
   304  	return docs, nil
   305  }