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

     1  package move
     2  
     3  import (
     4  	"encoding/base64"
     5  	"fmt"
     6  	"net/http"
     7  	"net/url"
     8  	"strconv"
     9  	"time"
    10  
    11  	"github.com/cozy/cozy-stack/model/bitwarden/settings"
    12  	"github.com/cozy/cozy-stack/model/instance"
    13  	"github.com/cozy/cozy-stack/model/instance/lifecycle"
    14  	"github.com/cozy/cozy-stack/model/job"
    15  	"github.com/cozy/cozy-stack/model/move"
    16  	"github.com/cozy/cozy-stack/model/oauth"
    17  	"github.com/cozy/cozy-stack/model/permission"
    18  	"github.com/cozy/cozy-stack/model/session"
    19  	csettings "github.com/cozy/cozy-stack/model/settings"
    20  	"github.com/cozy/cozy-stack/pkg/config/config"
    21  	"github.com/cozy/cozy-stack/pkg/consts"
    22  	"github.com/cozy/cozy-stack/pkg/couchdb"
    23  	"github.com/cozy/cozy-stack/pkg/jsonapi"
    24  	"github.com/cozy/cozy-stack/pkg/limits"
    25  	"github.com/cozy/cozy-stack/pkg/mail"
    26  	"github.com/cozy/cozy-stack/pkg/realtime"
    27  	"github.com/cozy/cozy-stack/web/auth"
    28  	"github.com/cozy/cozy-stack/web/middlewares"
    29  	"github.com/gorilla/websocket"
    30  	"github.com/labstack/echo/v4"
    31  )
    32  
    33  func createExport(c echo.Context) error {
    34  	inst := middlewares.GetInstance(c)
    35  	err := config.GetRateLimiter().CheckRateLimit(inst, limits.ExportType)
    36  	if limits.IsLimitReachedOrExceeded(err) {
    37  		return echo.NewHTTPError(http.StatusNotFound, "Not found")
    38  	}
    39  	if err := middlewares.AllowWholeType(c, permission.POST, consts.Exports); err != nil {
    40  		return err
    41  	}
    42  
    43  	var exportOptions move.ExportOptions
    44  	if _, err := jsonapi.Bind(c.Request().Body, &exportOptions); err != nil {
    45  		return err
    46  	}
    47  	// The contextual domain is used to send a link on the correct domain when
    48  	// the user is accessing their cozy from a backup URL.
    49  	exportOptions.ContextualDomain = inst.ContextualDomain()
    50  	exportOptions.MoveTo = nil
    51  	exportOptions.TokenSource = ""
    52  
    53  	msg, err := job.NewMessage(exportOptions)
    54  	if err != nil {
    55  		return err
    56  	}
    57  	_, err = job.System().PushJob(inst, &job.JobRequest{
    58  		WorkerType: "export",
    59  		Message:    msg,
    60  	})
    61  	if err != nil {
    62  		return err
    63  	}
    64  	return c.NoContent(http.StatusCreated)
    65  }
    66  
    67  func exportHandler(c echo.Context) error {
    68  	mac, err := base64.URLEncoding.DecodeString(c.Param("export-mac"))
    69  	if err != nil {
    70  		return echo.NewHTTPError(http.StatusBadRequest, err)
    71  	}
    72  	inst := middlewares.GetInstance(c)
    73  	exportDoc, err := move.GetExport(inst, mac)
    74  	if err != nil {
    75  		return err
    76  	}
    77  
    78  	return jsonapi.Data(c, http.StatusOK, exportDoc, nil)
    79  }
    80  
    81  func exportDataHandler(c echo.Context) error {
    82  	mac, err := base64.URLEncoding.DecodeString(c.Param("export-mac"))
    83  	if err != nil {
    84  		return echo.NewHTTPError(http.StatusBadRequest, err)
    85  	}
    86  	inst := middlewares.GetInstance(c)
    87  	exportDoc, err := move.GetExport(inst, mac)
    88  	if err != nil {
    89  		return err
    90  	}
    91  
    92  	cursor, err := move.ParseCursor(exportDoc, c.QueryParam("cursor"))
    93  	if err != nil {
    94  		return err
    95  	}
    96  
    97  	if !config.GetConfig().CSPDisabled {
    98  		from := inst.SubDomain(consts.SettingsSlug).String()
    99  		middlewares.AppendCSPRule(c, "frame-ancestors", from)
   100  	}
   101  
   102  	w := c.Response()
   103  	w.Header().Set(echo.HeaderContentType, "application/zip")
   104  	filename := "My Cozy.zip"
   105  	if len(exportDoc.PartsCursors) > 0 {
   106  		filename = fmt.Sprintf("My Cozy - part%03d.zip", cursor.Number)
   107  	}
   108  	w.Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s", filename))
   109  	w.WriteHeader(http.StatusOK)
   110  
   111  	archiver := move.SystemArchiver()
   112  	return move.ExportCopyData(w, inst, exportDoc, archiver, cursor)
   113  }
   114  
   115  func precheckImport(c echo.Context) error {
   116  	if err := middlewares.AllowWholeType(c, permission.POST, consts.Imports); err != nil {
   117  		return err
   118  	}
   119  
   120  	var options move.ImportOptions
   121  	if _, err := jsonapi.Bind(c.Request().Body, &options); err != nil {
   122  		return err
   123  	}
   124  
   125  	inst := middlewares.GetInstance(c)
   126  	if err := move.CheckImport(inst, options.SettingsURL); err != nil {
   127  		return wrapError(err)
   128  	}
   129  
   130  	return c.NoContent(http.StatusNoContent)
   131  }
   132  
   133  func createImport(c echo.Context) error {
   134  	if err := middlewares.AllowWholeType(c, permission.POST, consts.Imports); err != nil {
   135  		return err
   136  	}
   137  
   138  	var options move.ImportOptions
   139  	if _, err := jsonapi.Bind(c.Request().Body, &options); err != nil {
   140  		return err
   141  	}
   142  
   143  	inst := middlewares.GetInstance(c)
   144  	if err := move.ScheduleImport(inst, options); err != nil {
   145  		return c.Render(http.StatusInternalServerError, "error.html", echo.Map{
   146  			"Domain":       inst.ContextualDomain(),
   147  			"ContextName":  inst.ContextName,
   148  			"Locale":       inst.Locale,
   149  			"Title":        inst.TemplateTitle(),
   150  			"Favicon":      middlewares.Favicon(inst),
   151  			"Illustration": "/images/generic-error.svg",
   152  			"Error":        err.Error(),
   153  			"SupportEmail": inst.SupportEmailAddress(),
   154  		})
   155  	}
   156  
   157  	to := inst.PageURL("/move/importing", nil)
   158  	return c.Redirect(http.StatusSeeOther, to)
   159  }
   160  
   161  func blockForImport(c echo.Context) error {
   162  	if err := middlewares.AllowWholeType(c, permission.POST, consts.Imports); err != nil {
   163  		return err
   164  	}
   165  
   166  	// Force the logout for all sessions before blocking the instance
   167  	inst := middlewares.GetInstance(c)
   168  	_ = session.DeleteOthers(inst, "")
   169  	time.Sleep(100 * time.Millisecond)
   170  
   171  	if source := c.QueryParam("source"); source != "" {
   172  		doc, err := inst.SettingsDocument()
   173  		if err != nil {
   174  			return err
   175  		}
   176  		doc.SetID(consts.InstanceSettingsID)
   177  		doc.M["moved_from"] = source
   178  		if err := couchdb.UpdateDoc(inst, doc); err != nil {
   179  			return err
   180  		}
   181  	}
   182  
   183  	if err := lifecycle.Block(inst, instance.BlockedMoving.Code); err != nil {
   184  		return err
   185  	}
   186  	return c.NoContent(http.StatusNoContent)
   187  }
   188  
   189  func waitImportHasFinished(c echo.Context) error {
   190  	inst := middlewares.GetInstance(c)
   191  	template := "import.html"
   192  	title := "Import Title"
   193  	source := "?"
   194  	if inst.BlockingReason == instance.BlockedMoving.Code {
   195  		template = "move_in_progress.html"
   196  		title = "Move in progress Title"
   197  		doc, err := inst.SettingsDocument()
   198  		if err == nil {
   199  			if from, ok := doc.M["moved_from"].(string); ok {
   200  				source = from
   201  			}
   202  		}
   203  	}
   204  	return c.Render(http.StatusOK, template, echo.Map{
   205  		"Domain":      inst.ContextualDomain(),
   206  		"ContextName": inst.ContextName,
   207  		"Locale":      inst.Locale,
   208  		"Title":       inst.Translate(title),
   209  		"Favicon":     middlewares.Favicon(inst),
   210  		"Source":      source,
   211  	})
   212  }
   213  
   214  const (
   215  	// Time allowed to write a message to the peer
   216  	writeWait = 10 * time.Second
   217  
   218  	// Time allowed to read the next pong message from the peer
   219  	pongWait = 60 * time.Second
   220  
   221  	// Send pings to peer with this period (must be less than pongWait)
   222  	pingPeriod = (pongWait * 9) / 10
   223  )
   224  
   225  var upgrader = websocket.Upgrader{
   226  	// Don't check the origin of the connexion
   227  	CheckOrigin:     func(r *http.Request) bool { return true },
   228  	Subprotocols:    []string{"io.cozy.websocket"},
   229  	ReadBufferSize:  1024,
   230  	WriteBufferSize: 1024,
   231  }
   232  
   233  func wsDone(ws *websocket.Conn, inst *instance.Instance) {
   234  	redirect := inst.PageURL("/auth/login", nil)
   235  	_ = ws.SetWriteDeadline(time.Now().Add(writeWait))
   236  	_ = ws.WriteJSON(echo.Map{"redirect": redirect})
   237  }
   238  
   239  func wsImporting(c echo.Context) error {
   240  	inst := middlewares.GetInstance(c)
   241  	ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
   242  	if err != nil {
   243  		return err
   244  	}
   245  	defer ws.Close()
   246  
   247  	if move.ImportIsFinished(inst) {
   248  		wsDone(ws, inst)
   249  		return nil
   250  	}
   251  
   252  	if err = ws.SetReadDeadline(time.Now().Add(pongWait)); err != nil {
   253  		return err
   254  	}
   255  	ws.SetPongHandler(func(string) error {
   256  		return ws.SetReadDeadline(time.Now().Add(pongWait))
   257  	})
   258  
   259  	ticker := time.NewTicker(pingPeriod)
   260  	defer ticker.Stop()
   261  	ds := realtime.GetHub().Subscriber(inst)
   262  	defer ds.Close()
   263  	ds.Subscribe(consts.Jobs)
   264  
   265  	for {
   266  		select {
   267  		case e := <-ds.Channel:
   268  			doc, ok := e.Doc.(permission.Fetcher)
   269  			if !ok {
   270  				continue
   271  			}
   272  			worker := doc.Fetch("worker")
   273  			state := doc.Fetch("state")
   274  			if len(worker) != 1 || worker[0] != "import" || len(state) != 1 {
   275  				continue
   276  			}
   277  			if s := job.State(state[0]); s != job.Done && s != job.Errored {
   278  				continue
   279  			}
   280  			wsDone(ws, inst)
   281  			return nil
   282  		case <-ticker.C:
   283  			if err := ws.SetWriteDeadline(time.Now().Add(writeWait)); err != nil {
   284  				return err
   285  			}
   286  			if err := ws.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
   287  				return err
   288  			}
   289  		}
   290  	}
   291  }
   292  
   293  func getAuthorizeCode(c echo.Context) error {
   294  	inst := middlewares.GetInstance(c)
   295  	if !middlewares.IsLoggedIn(c) {
   296  		u := inst.PageURL("/auth/login", url.Values{
   297  			"redirect": {inst.FromURL(c.Request().URL)},
   298  		})
   299  		return c.Redirect(http.StatusSeeOther, u)
   300  	}
   301  
   302  	err := config.GetRateLimiter().CheckRateLimit(inst, limits.ExportType)
   303  	if limits.IsLimitReachedOrExceeded(err) {
   304  		return echo.NewHTTPError(http.StatusNotFound, "Not found")
   305  	}
   306  
   307  	u, err := url.Parse(c.QueryParam("redirect_uri"))
   308  	if err != nil {
   309  		return echo.NewHTTPError(http.StatusBadRequest, "bad url: could not parse")
   310  	}
   311  
   312  	if u.Scheme != "http" && u.Scheme != "https" {
   313  		return echo.NewHTTPError(http.StatusBadRequest, "bad url: bad scheme")
   314  	}
   315  
   316  	client := &oauth.Client{ClientID: move.SourceClientID}
   317  	access, err := oauth.CreateAccessCode(inst, client, consts.ExportsRequests, "")
   318  	if err != nil {
   319  		return err
   320  	}
   321  
   322  	vault := settings.HasVault(inst)
   323  	used, quota, err := auth.DiskInfo(inst.VFS())
   324  	if err != nil {
   325  		return err
   326  	}
   327  
   328  	q := u.Query()
   329  	q.Set("state", c.QueryParam("state"))
   330  	q.Set("code", access.Code)
   331  	q.Set("vault", strconv.FormatBool(vault))
   332  	q.Set("used", used)
   333  	if quota != "" {
   334  		q.Set("quota", quota)
   335  	}
   336  	u.RawQuery = q.Encode()
   337  	u.Fragment = ""
   338  	location := u.String() + "#"
   339  	return c.Redirect(http.StatusSeeOther, location)
   340  }
   341  
   342  func initializeMove(c echo.Context) error {
   343  	inst := middlewares.GetInstance(c)
   344  	if !middlewares.IsLoggedIn(c) {
   345  		u := inst.PageURL("/auth/login", url.Values{
   346  			"redirect": {inst.SubDomain(consts.SettingsSlug).String()},
   347  		})
   348  		return c.Redirect(http.StatusSeeOther, u)
   349  	}
   350  
   351  	err := config.GetRateLimiter().CheckRateLimit(inst, limits.ExportType)
   352  	if limits.IsLimitReachedOrExceeded(err) {
   353  		return echo.NewHTTPError(http.StatusNotFound, "Not found")
   354  	}
   355  
   356  	u, err := url.Parse(inst.MoveURL())
   357  	if err != nil {
   358  		return echo.NewHTTPError(http.StatusBadRequest, "bad url: could not parse")
   359  	}
   360  	u.Path = "/initialize"
   361  
   362  	vault := settings.HasVault(inst)
   363  	used, quota, err := auth.DiskInfo(inst.VFS())
   364  	if err != nil {
   365  		return err
   366  	}
   367  
   368  	client, err := move.CreateRequestClient(inst)
   369  	if err != nil {
   370  		return err
   371  	}
   372  	access, err := oauth.CreateAccessCode(inst, client, move.MoveScope, "")
   373  	if err != nil {
   374  		return err
   375  	}
   376  
   377  	q := u.Query()
   378  	q.Set("client_id", client.ClientID)
   379  	q.Set("client_secret", client.ClientSecret)
   380  	q.Set("code", access.Code)
   381  	q.Set("vault", strconv.FormatBool(vault))
   382  	q.Set("used", used)
   383  	if quota != "" {
   384  		q.Set("quota", quota)
   385  	}
   386  	q.Set("cozy_url", inst.PageURL("/", nil))
   387  	u.RawQuery = q.Encode()
   388  	return c.Redirect(http.StatusTemporaryRedirect, u.String())
   389  }
   390  
   391  func requestMove(c echo.Context) error {
   392  	inst := middlewares.GetInstance(c)
   393  	var request *move.Request
   394  	params, err := c.FormParams()
   395  	if err == nil {
   396  		request, err = move.CreateRequest(inst, params)
   397  	}
   398  	if err != nil {
   399  		return c.Render(http.StatusBadRequest, "error.html", echo.Map{
   400  			"Domain":       inst.ContextualDomain(),
   401  			"ContextName":  inst.ContextName,
   402  			"Locale":       inst.Locale,
   403  			"Title":        inst.TemplateTitle(),
   404  			"Favicon":      middlewares.Favicon(inst),
   405  			"Illustration": "/images/generic-error.svg",
   406  			"Error":        err.Error(),
   407  			"SupportEmail": inst.SupportEmailAddress(),
   408  		})
   409  	}
   410  
   411  	publicName, _ := csettings.PublicName(inst)
   412  	mail := mail.Options{
   413  		Mode:         mail.ModeFromStack,
   414  		TemplateName: "move_confirm",
   415  		TemplateValues: map[string]interface{}{
   416  			"ConfirmLink": request.Link,
   417  			"PublicName":  publicName,
   418  			"Source":      inst.ContextualDomain(),
   419  			"Target":      request.TargetHost(),
   420  		},
   421  	}
   422  	msg, err := job.NewMessage(&mail)
   423  	if err != nil {
   424  		return err
   425  	}
   426  	_, err = job.System().PushJob(inst, &job.JobRequest{
   427  		WorkerType: "sendmail",
   428  		Message:    msg,
   429  	})
   430  	if err != nil {
   431  		return err
   432  	}
   433  
   434  	email, _ := inst.SettingsEMail()
   435  	return c.Render(http.StatusOK, "move_confirm.html", echo.Map{
   436  		"Domain":      inst.ContextualDomain(),
   437  		"ContextName": inst.ContextName,
   438  		"Locale":      inst.Locale,
   439  		"Title":       inst.Translate("Move Confirm Title"),
   440  		"Favicon":     middlewares.Favicon(inst),
   441  		"Email":       email,
   442  	})
   443  }
   444  
   445  func startMove(c echo.Context) error {
   446  	inst := middlewares.GetInstance(c)
   447  	if !middlewares.IsLoggedIn(c) {
   448  		return echo.NewHTTPError(http.StatusUnauthorized, "You must be authenticated")
   449  	}
   450  
   451  	request, err := move.StartMove(inst, c.QueryParam("secret"))
   452  	if err != nil {
   453  		return c.Render(http.StatusBadRequest, "error.html", echo.Map{
   454  			"Domain":       inst.ContextualDomain(),
   455  			"ContextName":  inst.ContextName,
   456  			"Locale":       inst.Locale,
   457  			"Title":        inst.TemplateTitle(),
   458  			"ThemeCSS":     middlewares.ThemeCSS(inst),
   459  			"Favicon":      middlewares.Favicon(inst),
   460  			"Illustration": "/images/generic-error.svg",
   461  			"Error":        err.Error(),
   462  			"SupportEmail": inst.SupportEmailAddress(),
   463  		})
   464  	}
   465  
   466  	return c.Redirect(http.StatusSeeOther, request.ImportingURL())
   467  }
   468  
   469  func finalizeMove(c echo.Context) error {
   470  	if err := middlewares.AllowWholeType(c, permission.POST, consts.Imports); err != nil {
   471  		return err
   472  	}
   473  
   474  	inst := middlewares.GetInstance(c)
   475  	if err := move.Finalize(inst, c.QueryParam("subdomain")); err != nil {
   476  		return err
   477  	}
   478  	return c.NoContent(http.StatusNoContent)
   479  }
   480  
   481  func abortMove(c echo.Context) error {
   482  	if err := middlewares.AllowWholeType(c, permission.POST, consts.Imports); err != nil {
   483  		return err
   484  	}
   485  
   486  	inst := middlewares.GetInstance(c)
   487  	if err := lifecycle.Unblock(inst); err != nil {
   488  		return err
   489  	}
   490  	return c.NoContent(http.StatusNoContent)
   491  }
   492  
   493  func importVault(c echo.Context) error {
   494  	inst := middlewares.GetInstance(c)
   495  	if !middlewares.IsLoggedIn(c) {
   496  		u := inst.PageURL("/auth/login", url.Values{
   497  			"redirect": {inst.FromURL(c.Request().URL)},
   498  		})
   499  		return c.Redirect(http.StatusSeeOther, u)
   500  	}
   501  
   502  	doc, err := inst.SettingsDocument()
   503  	if err != nil {
   504  		return err
   505  	}
   506  	delete(doc.M, "import_vault")
   507  	_ = couchdb.UpdateDoc(inst, doc)
   508  
   509  	return c.Render(http.StatusOK, "move_vault.html", echo.Map{
   510  		"Domain":      inst.ContextualDomain(),
   511  		"ContextName": inst.ContextName,
   512  		"Locale":      inst.Locale,
   513  		"Title":       inst.Translate("Move Vault Title"),
   514  		"Favicon":     middlewares.Favicon(inst),
   515  		"Link":        inst.DefaultRedirection(),
   516  	})
   517  }
   518  
   519  // Routes defines the routing layout for the /move module.
   520  func Routes(g *echo.Group) {
   521  	g.POST("/exports", createExport)
   522  	g.GET("/exports/:export-mac", exportHandler)
   523  	g.GET("/exports/data/:export-mac", exportDataHandler)
   524  
   525  	g.POST("/imports/precheck", precheckImport)
   526  	g.POST("/imports", createImport)
   527  
   528  	g.POST("/importing", blockForImport)
   529  	g.GET("/importing", waitImportHasFinished)
   530  	g.GET("/importing/realtime", wsImporting)
   531  
   532  	g.GET("/authorize", getAuthorizeCode)
   533  	g.POST("/initialize", initializeMove)
   534  
   535  	g.POST("/request", requestMove)
   536  	g.GET("/go", startMove)
   537  	g.POST("/finalize", finalizeMove)
   538  	g.POST("/abort", abortMove)
   539  	g.GET("/vault", importVault)
   540  }
   541  
   542  func wrapError(err error) error {
   543  	switch err {
   544  	case move.ErrExportNotFound:
   545  		return jsonapi.PreconditionFailed("url", err)
   546  	case move.ErrNotEnoughSpace:
   547  		return jsonapi.Errorf(http.StatusRequestEntityTooLarge, "%s", err)
   548  	}
   549  	return err
   550  }