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 }