User Data Export and import (#967)
Co-authored-by: kolaente <k@knt.li> Reviewed-on: https://kolaente.dev/vikunja/api/pulls/967 Co-authored-by: konrad <k@knt.li> Co-committed-by: konrad <k@knt.li>
This commit is contained in:
@ -19,6 +19,8 @@ package v1
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
vikunja_file "code.vikunja.io/api/pkg/modules/migration/vikunja-file"
|
||||
|
||||
microsofttodo "code.vikunja.io/api/pkg/modules/migration/microsoft-todo"
|
||||
|
||||
"code.vikunja.io/api/pkg/modules/migration/trello"
|
||||
@ -91,6 +93,9 @@ func Info(c echo.Context) error {
|
||||
CaldavEnabled: config.ServiceEnableCaldav.GetBool(),
|
||||
EmailRemindersEnabled: config.ServiceEnableEmailReminders.GetBool(),
|
||||
UserDeletionEnabled: config.ServiceEnableUserDeletion.GetBool(),
|
||||
AvailableMigrators: []string{
|
||||
(&vikunja_file.FileMigrator{}).Name(),
|
||||
},
|
||||
Legal: legalInfo{
|
||||
ImprintURL: config.LegalImprintURL.GetString(),
|
||||
PrivacyPolicyURL: config.LegalPrivacyURL.GetString(),
|
||||
|
@ -27,7 +27,7 @@ import (
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type UserDeletionRequest struct {
|
||||
type UserPasswordConfirmation struct {
|
||||
Password string `json:"password" valid:"required"`
|
||||
}
|
||||
|
||||
@ -41,13 +41,13 @@ type UserDeletionRequestConfirm struct {
|
||||
// @tags user
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param credentials body v1.UserDeletionRequest true "The user password."
|
||||
// @Param credentials body v1.UserPasswordConfirmation true "The user password."
|
||||
// @Success 200 {object} models.Message
|
||||
// @Failure 412 {object} web.HTTPError "Bad password provided."
|
||||
// @Failure 500 {object} models.Message "Internal error"
|
||||
// @Router /user/deletion/request [post]
|
||||
func UserRequestDeletion(c echo.Context) error {
|
||||
var deletionRequest UserDeletionRequest
|
||||
var deletionRequest UserPasswordConfirmation
|
||||
if err := c.Bind(&deletionRequest); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "No password provided.")
|
||||
}
|
||||
@ -149,13 +149,13 @@ func UserConfirmDeletion(c echo.Context) error {
|
||||
// @tags user
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param credentials body v1.UserDeletionRequest true "The user password to confirm."
|
||||
// @Param credentials body v1.UserPasswordConfirmation true "The user password to confirm."
|
||||
// @Success 200 {object} models.Message
|
||||
// @Failure 412 {object} web.HTTPError "Bad password provided."
|
||||
// @Failure 500 {object} models.Message "Internal error"
|
||||
// @Router /user/deletion/cancel [post]
|
||||
func UserCancelDeletion(c echo.Context) error {
|
||||
var deletionRequest UserDeletionRequest
|
||||
var deletionRequest UserPasswordConfirmation
|
||||
if err := c.Bind(&deletionRequest); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "No password provided.")
|
||||
}
|
||||
|
136
pkg/routes/api/v1/user_export.go
Normal file
136
pkg/routes/api/v1/user_export.go
Normal file
@ -0,0 +1,136 @@
|
||||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-2021 Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public Licensee as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public Licensee for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public Licensee
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/events"
|
||||
"code.vikunja.io/api/pkg/files"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/web/handler"
|
||||
"github.com/labstack/echo/v4"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func checkExportRequest(c echo.Context) (s *xorm.Session, u *user.User, err error) {
|
||||
var pass UserPasswordConfirmation
|
||||
if err := c.Bind(&pass); err != nil {
|
||||
return nil, nil, echo.NewHTTPError(http.StatusBadRequest, "No password provided.")
|
||||
}
|
||||
|
||||
err = c.Validate(pass)
|
||||
if err != nil {
|
||||
return nil, nil, echo.NewHTTPError(http.StatusBadRequest, err)
|
||||
}
|
||||
|
||||
s = db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
err = s.Begin()
|
||||
if err != nil {
|
||||
return nil, nil, handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
u, err = user.GetCurrentUserFromDB(s, c)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, nil, handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
err = user.CheckUserPassword(u, pass.Password)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, nil, handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// RequestUserDataExport is the handler to request a user data export
|
||||
// @Summary Request a user data export.
|
||||
// @tags user
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security JWTKeyAuth
|
||||
// @Param password body v1.UserPasswordConfirmation true "User password to confirm the data export request."
|
||||
// @Success 200 {object} models.Message
|
||||
// @Failure 400 {object} web.HTTPError "Something's invalid."
|
||||
// @Failure 500 {object} models.Message "Internal server error."
|
||||
// @Router /user/export/request [post]
|
||||
func RequestUserDataExport(c echo.Context) error {
|
||||
s, u, err := checkExportRequest(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = events.Dispatch(&models.UserDataExportRequestedEvent{
|
||||
User: u,
|
||||
})
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
err = s.Commit()
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, models.Message{Message: "Successfully requested data export. We will send you an email when it's ready."})
|
||||
}
|
||||
|
||||
// DownloadUserDataExport is the handler to download a created user data export
|
||||
// @Summary Download a user data export.
|
||||
// @tags user
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security JWTKeyAuth
|
||||
// @Param password body v1.UserPasswordConfirmation true "User password to confirm the download."
|
||||
// @Success 200 {object} models.Message
|
||||
// @Failure 400 {object} web.HTTPError "Something's invalid."
|
||||
// @Failure 500 {object} models.Message "Internal server error."
|
||||
// @Router /user/export/download [post]
|
||||
func DownloadUserDataExport(c echo.Context) error {
|
||||
s, u, err := checkExportRequest(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.Commit()
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
// Download
|
||||
exportFile := &files.File{ID: u.ExportFileID}
|
||||
err = exportFile.LoadFileMetaByID()
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
err = exportFile.LoadFileByID()
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
http.ServeContent(c.Response(), c.Request(), exportFile.Name, exportFile.Created, exportFile.File)
|
||||
return nil
|
||||
}
|
@ -56,7 +56,7 @@ func ListHandler(c echo.Context) error {
|
||||
}
|
||||
|
||||
storage := &VikunjaCaldavListStorage{
|
||||
list: &models.List{ID: listID},
|
||||
list: &models.ListWithTasksAndBuckets{List: models.List{ID: listID}},
|
||||
user: u,
|
||||
}
|
||||
|
||||
@ -102,7 +102,7 @@ func TaskHandler(c echo.Context) error {
|
||||
taskUID := strings.TrimSuffix(c.Param("task"), ".ics")
|
||||
|
||||
storage := &VikunjaCaldavListStorage{
|
||||
list: &models.List{ID: listID},
|
||||
list: &models.ListWithTasksAndBuckets{List: models.List{ID: listID}},
|
||||
task: &models.Task{UID: taskUID},
|
||||
user: u,
|
||||
}
|
||||
|
@ -39,7 +39,7 @@ const ListBasePath = DavBasePath + `lists`
|
||||
// VikunjaCaldavListStorage represents a list storage
|
||||
type VikunjaCaldavListStorage struct {
|
||||
// Used when handling a list
|
||||
list *models.List
|
||||
list *models.ListWithTasksAndBuckets
|
||||
// Used when handling a single task, like updating
|
||||
task *models.Task
|
||||
// The current user
|
||||
@ -109,7 +109,9 @@ func (vcls *VikunjaCaldavListStorage) GetResources(rpath string, withChildren bo
|
||||
var resources []data.Resource
|
||||
for _, l := range lists {
|
||||
rr := VikunjaListResourceAdapter{
|
||||
list: l,
|
||||
list: &models.ListWithTasksAndBuckets{
|
||||
List: *l,
|
||||
},
|
||||
isCollection: true,
|
||||
}
|
||||
r := data.NewResource(ListBasePath+"/"+strconv.FormatInt(l.ID, 10), &rr)
|
||||
@ -172,10 +174,10 @@ func (vcls *VikunjaCaldavListStorage) GetResourcesByFilters(rpath string, filter
|
||||
for _, t := range vcls.list.Tasks {
|
||||
rr := VikunjaListResourceAdapter{
|
||||
list: vcls.list,
|
||||
task: t,
|
||||
task: &t.Task,
|
||||
isCollection: false,
|
||||
}
|
||||
r := data.NewResource(getTaskURL(t), &rr)
|
||||
r := data.NewResource(getTaskURL(&t.Task), &rr)
|
||||
r.Name = t.Title
|
||||
resources = append(resources, r)
|
||||
}
|
||||
@ -368,8 +370,8 @@ func (vcls *VikunjaCaldavListStorage) DeleteResource(rpath string) error {
|
||||
|
||||
// VikunjaListResourceAdapter holds the actual resource
|
||||
type VikunjaListResourceAdapter struct {
|
||||
list *models.List
|
||||
listTasks []*models.Task
|
||||
list *models.ListWithTasksAndBuckets
|
||||
listTasks []*models.TaskWithComments
|
||||
task *models.Task
|
||||
|
||||
isPrincipal bool
|
||||
@ -415,7 +417,7 @@ func (vlra *VikunjaListResourceAdapter) GetContent() string {
|
||||
}
|
||||
|
||||
if vlra.task != nil {
|
||||
list := models.List{Tasks: []*models.Task{vlra.task}}
|
||||
list := models.ListWithTasksAndBuckets{Tasks: []*models.TaskWithComments{{Task: *vlra.task}}}
|
||||
return caldav.GetCaldavTodosForTasks(&list, list.Tasks)
|
||||
}
|
||||
|
||||
@ -479,8 +481,10 @@ func (vcls *VikunjaCaldavListStorage) getListRessource(isCollection bool) (rr Vi
|
||||
panic("Tasks returned from TaskCollection.ReadAll are not []*models.Task!")
|
||||
}
|
||||
|
||||
listTasks = tasks
|
||||
vcls.list.Tasks = tasks
|
||||
for _, t := range tasks {
|
||||
listTasks = append(listTasks, &models.TaskWithComments{Task: *t})
|
||||
}
|
||||
vcls.list.Tasks = listTasks
|
||||
}
|
||||
|
||||
if err := s.Commit(); err != nil {
|
||||
|
@ -52,6 +52,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
vikunja_file "code.vikunja.io/api/pkg/modules/migration/vikunja-file"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
@ -303,6 +305,8 @@ func registerAPIRoutes(a *echo.Group) {
|
||||
u.POST("/settings/avatar", apiv1.ChangeUserAvatarProvider)
|
||||
u.PUT("/settings/avatar/upload", apiv1.UploadAvatar)
|
||||
u.POST("/settings/general", apiv1.UpdateGeneralUserSettings)
|
||||
u.POST("/export/request", apiv1.RequestUserDataExport)
|
||||
u.POST("/export/download", apiv1.DownloadUserDataExport)
|
||||
|
||||
if config.ServiceEnableTotp.GetBool() {
|
||||
u.GET("/settings/totp", apiv1.UserTOTP)
|
||||
@ -563,7 +567,35 @@ func registerAPIRoutes(a *echo.Group) {
|
||||
|
||||
// Migrations
|
||||
m := a.Group("/migration")
|
||||
registerMigrations(m)
|
||||
|
||||
// List Backgrounds
|
||||
if config.BackgroundsEnabled.GetBool() {
|
||||
a.GET("/lists/:list/background", backgroundHandler.GetListBackground)
|
||||
a.DELETE("/lists/:list/background", backgroundHandler.RemoveListBackground)
|
||||
if config.BackgroundsUploadEnabled.GetBool() {
|
||||
uploadBackgroundProvider := &backgroundHandler.BackgroundProvider{
|
||||
Provider: func() background.Provider {
|
||||
return &upload.Provider{}
|
||||
},
|
||||
}
|
||||
a.PUT("/lists/:list/backgrounds/upload", uploadBackgroundProvider.UploadBackground)
|
||||
}
|
||||
if config.BackgroundsUnsplashEnabled.GetBool() {
|
||||
unsplashBackgroundProvider := &backgroundHandler.BackgroundProvider{
|
||||
Provider: func() background.Provider {
|
||||
return &unsplash.Provider{}
|
||||
},
|
||||
}
|
||||
a.GET("/backgrounds/unsplash/search", unsplashBackgroundProvider.SearchBackgrounds)
|
||||
a.POST("/lists/:list/backgrounds/unsplash", unsplashBackgroundProvider.SetBackground)
|
||||
a.GET("/backgrounds/unsplash/images/:image/thumb", unsplash.ProxyUnsplashThumb)
|
||||
a.GET("/backgrounds/unsplash/images/:image", unsplash.ProxyUnsplashImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func registerMigrations(m *echo.Group) {
|
||||
// Wunderlist
|
||||
if config.MigrationWunderlistEnable.GetBool() {
|
||||
wunderlistMigrationHandler := &migrationHandler.MigrationWeb{
|
||||
@ -604,30 +636,12 @@ func registerAPIRoutes(a *echo.Group) {
|
||||
microsoftTodoMigrationHandler.RegisterRoutes(m)
|
||||
}
|
||||
|
||||
// List Backgrounds
|
||||
if config.BackgroundsEnabled.GetBool() {
|
||||
a.GET("/lists/:list/background", backgroundHandler.GetListBackground)
|
||||
a.DELETE("/lists/:list/background", backgroundHandler.RemoveListBackground)
|
||||
if config.BackgroundsUploadEnabled.GetBool() {
|
||||
uploadBackgroundProvider := &backgroundHandler.BackgroundProvider{
|
||||
Provider: func() background.Provider {
|
||||
return &upload.Provider{}
|
||||
},
|
||||
}
|
||||
a.PUT("/lists/:list/backgrounds/upload", uploadBackgroundProvider.UploadBackground)
|
||||
}
|
||||
if config.BackgroundsUnsplashEnabled.GetBool() {
|
||||
unsplashBackgroundProvider := &backgroundHandler.BackgroundProvider{
|
||||
Provider: func() background.Provider {
|
||||
return &unsplash.Provider{}
|
||||
},
|
||||
}
|
||||
a.GET("/backgrounds/unsplash/search", unsplashBackgroundProvider.SearchBackgrounds)
|
||||
a.POST("/lists/:list/backgrounds/unsplash", unsplashBackgroundProvider.SetBackground)
|
||||
a.GET("/backgrounds/unsplash/images/:image/thumb", unsplash.ProxyUnsplashThumb)
|
||||
a.GET("/backgrounds/unsplash/images/:image", unsplash.ProxyUnsplashImage)
|
||||
}
|
||||
vikunjaFileMigrationHandler := &migrationHandler.FileMigratorWeb{
|
||||
MigrationStruct: func() migration.FileMigrator {
|
||||
return &vikunja_file.FileMigrator{}
|
||||
},
|
||||
}
|
||||
vikunjaFileMigrationHandler.RegisterRoutes(m)
|
||||
}
|
||||
|
||||
func registerCalDavRoutes(c *echo.Group) {
|
||||
|
Reference in New Issue
Block a user