diff --git a/pkg/models/error.go b/pkg/models/error.go index 0b793aecc..8f1a47553 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -2624,3 +2624,32 @@ func (err ErrTimeEntryEndBeforeStart) HTTPError() web.HTTPError { Message: "A time entry's end time cannot be before its start time.", } } + +// ================= +// User export errors +// ================= + +// ErrUserDataExportDoesNotExist represents an error where a user has no ready data export to download. +type ErrUserDataExportDoesNotExist struct{} + +// IsErrUserDataExportDoesNotExist checks if an error is ErrUserDataExportDoesNotExist. +func IsErrUserDataExportDoesNotExist(err error) bool { + _, ok := err.(ErrUserDataExportDoesNotExist) + return ok +} + +func (err ErrUserDataExportDoesNotExist) Error() string { + return "No user data export found" +} + +// ErrCodeUserDataExportDoesNotExist holds the unique world-error code of this error +const ErrCodeUserDataExportDoesNotExist = 19001 + +// HTTPError holds the http error description +func (err ErrUserDataExportDoesNotExist) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusNotFound, + Code: ErrCodeUserDataExportDoesNotExist, + Message: "No user data export found.", + } +} diff --git a/pkg/models/export.go b/pkg/models/export.go index 4772fa2d5..65d1f2fae 100644 --- a/pkg/models/export.go +++ b/pkg/models/export.go @@ -404,6 +404,59 @@ func exportProjectBackgrounds(s *xorm.Session, u *user.User, wr *zip.Writer) (er return utils.WriteFilesToZip(backgroundFiles, wr) } +// GetUserDataExportFile loads the user's ready data export with its bytes open for +// reading. It returns ErrUserDataExportDoesNotExist when the user never requested an +// export or the underlying file is gone. The caller must close the returned reader. +func GetUserDataExportFile(u *user.User) (*files.File, error) { + if u.ExportFileID == 0 { + return nil, ErrUserDataExportDoesNotExist{} + } + + exportFile := &files.File{ID: u.ExportFileID} + if err := exportFile.LoadFileMetaByID(); err != nil { + if files.IsErrFileDoesNotExist(err) { + return nil, ErrUserDataExportDoesNotExist{} + } + return nil, err + } + if err := exportFile.LoadFileByID(); err != nil { + if os.IsNotExist(err) { + return nil, ErrUserDataExportDoesNotExist{} + } + return nil, err + } + + return exportFile, nil +} + +// GetUserDataExportStatus returns metadata about the user's current data export, or +// nil when none exists. The expiry mirrors the cleanup cron's 7-day retention. +func GetUserDataExportStatus(u *user.User) (*UserExportStatus, error) { + if u.ExportFileID == 0 { + return nil, nil + } + + exportFile := &files.File{ID: u.ExportFileID} + if err := exportFile.LoadFileMetaByID(); err != nil { + return nil, err + } + + return &UserExportStatus{ + ID: exportFile.ID, + Size: exportFile.Size, + Created: exportFile.Created, + Expires: exportFile.Created.Add(7 * 24 * time.Hour), + }, nil +} + +// UserExportStatus is the metadata returned for a user's current data export. +type UserExportStatus struct { + ID int64 `json:"id" readOnly:"true" doc:"The id of the export file."` + Size uint64 `json:"size" readOnly:"true" doc:"The size of the export file in bytes."` + Created time.Time `json:"created" readOnly:"true" doc:"When the export was created."` + Expires time.Time `json:"expires" readOnly:"true" doc:"When the export will be automatically deleted (7 days after creation)."` +} + func RegisterOldExportCleanupCron() { const logPrefix = "[User Export Cleanup Cron] " diff --git a/pkg/routes/api/v1/user_export.go b/pkg/routes/api/v1/user_export.go index 6efc311c0..405f98a29 100644 --- a/pkg/routes/api/v1/user_export.go +++ b/pkg/routes/api/v1/user_export.go @@ -19,14 +19,11 @@ package v1 import ( "io" "net/http" - "os" "strconv" - "time" "code.vikunja.io/api/pkg/config" "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" "github.com/labstack/echo/v5" @@ -127,25 +124,10 @@ func DownloadUserDataExport(c *echo.Context) error { return err } - // Check if user has an export file - exportNotFoundError := echo.NewHTTPError(http.StatusNotFound, "No user data export found.") - if u.ExportFileID == 0 { - return exportNotFoundError - } - - // Download - exportFile := &files.File{ID: u.ExportFileID} - err = exportFile.LoadFileMetaByID() + exportFile, err := models.GetUserDataExportFile(u) if err != nil { - if files.IsErrFileDoesNotExist(err) { - return exportNotFoundError - } - return err - } - err = exportFile.LoadFileByID() - if err != nil { - if os.IsNotExist(err) { - return exportNotFoundError + if models.IsErrUserDataExportDoesNotExist(err) { + return echo.NewHTTPError(http.StatusNotFound, "No user data export found.") } return err } @@ -163,19 +145,12 @@ func DownloadUserDataExport(c *echo.Context) error { return nil } -type UserExportStatus struct { - ID int64 `json:"id"` - Size uint64 `json:"size"` - Created time.Time `json:"created"` - Expires time.Time `json:"expires"` -} - // GetUserExportStatus returns metadata about the current user export if it exists // @Summary Get current user data export // @tags user // @Produce json // @Security JWTKeyAuth -// @Success 200 {object} v1.UserExportStatus +// @Success 200 {object} models.UserExportStatus // @Router /user/export [get] func GetUserExportStatus(c *echo.Context) error { s := db.NewSession() @@ -186,20 +161,12 @@ func GetUserExportStatus(c *echo.Context) error { return err } - if u.ExportFileID == 0 { - return c.JSON(http.StatusOK, struct{}{}) - } - - exportFile := &files.File{ID: u.ExportFileID} - if err := exportFile.LoadFileMetaByID(); err != nil { + status, err := models.GetUserDataExportStatus(u) + if err != nil { return err } - - status := UserExportStatus{ - ID: exportFile.ID, - Size: exportFile.Size, - Created: exportFile.Created, - Expires: exportFile.Created.Add(7 * 24 * time.Hour), + if status == nil { + return c.JSON(http.StatusOK, struct{}{}) } return c.JSON(http.StatusOK, status) diff --git a/pkg/routes/api/v2/user_export.go b/pkg/routes/api/v2/user_export.go new file mode 100644 index 000000000..820e883d0 --- /dev/null +++ b/pkg/routes/api/v2/user_export.go @@ -0,0 +1,179 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/humaecho5" + "code.vikunja.io/api/pkg/user" + webfiles "code.vikunja.io/api/pkg/web/files" + + "github.com/danielgtaylor/huma/v2" + "xorm.io/xorm" +) + +type userExportPasswordBody struct { + Body struct { + Password string `json:"password" doc:"The authenticated user's password. Required for local users; ignored for users authenticated via an external provider."` + } +} + +type userExportStatusBody struct { + Body *models.UserExportStatus +} + +func RegisterUserExportRoutes(api huma.API) { + tags := []string{"user"} + + Register(api, huma.Operation{ + OperationID: "user-export-request", + Summary: "Request a data export", + Description: "Starts building a full export of the authenticated user's data. Local users must confirm with their password. The export runs in the background; an email is sent when it is ready to download.", + Method: http.MethodPost, + Path: "/user/export/request", + Tags: tags, + DefaultStatus: http.StatusOK, + }, userExportRequest) + + Register(api, huma.Operation{ + OperationID: "user-export-download", + Summary: "Download the data export", + Description: "Streams the authenticated user's prepared data export as a zip file. Local users must confirm with their password. Fails with 404 if no export has been prepared. A POST (not GET) because the password is sent in the body.", + Method: http.MethodPost, + Path: "/user/export/download", + Tags: tags, + // Spell out the binary response; the default would be modeled as JSON. + Responses: map[string]*huma.Response{ + "200": { + Description: "The data export as a zip file.", + Content: map[string]*huma.MediaType{ + "application/zip": { + Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"}, + }, + }, + }, + }, + }, userExportDownload) + + Register(api, huma.Operation{ + OperationID: "user-export-status", + Summary: "Get the current data export", + Description: "Returns metadata about the authenticated user's current data export (id, size, creation and expiry time), or null if none has been prepared.", + Method: http.MethodGet, + Path: "/user/export", + Tags: tags, + }, userExportStatus) +} + +func init() { AddRouteRegistrar(RegisterUserExportRoutes) } + +// confirmExportPassword resolves the full DB user and, for local accounts, verifies +// the supplied password — mirroring v1's checkExportRequest. External-provider users +// cannot supply a password and are passed through, as in v1. +func confirmExportPassword(ctx context.Context, s *xorm.Session, password string) (*user.User, error) { + u, err := authUserFromCtx(ctx, s) + if err != nil { + return nil, err + } + if u.IsLocalUser() { + if err := user.CheckUserPassword(u, password); err != nil { + return nil, translateDomainError(err) + } + } + return u, nil +} + +func userExportRequest(ctx context.Context, in *userExportPasswordBody) (*messageBody, error) { + s := db.NewSession() + defer s.Close() + + u, err := confirmExportPassword(ctx, s, in.Body.Password) + if err != nil { + _ = s.Rollback() + return nil, err + } + + events.DispatchOnCommit(s, &models.UserDataExportRequestedEvent{User: u}) + + if err := s.Commit(); err != nil { + _ = s.Rollback() + events.CleanupPending(s) + return nil, translateDomainError(err) + } + events.DispatchPending(ctx, s) + + out := &messageBody{} + out.Body.Message = "Successfully requested data export. We will send you an email when it's ready." + return out, nil +} + +func userExportDownload(ctx context.Context, in *userExportPasswordBody) (*huma.StreamResponse, error) { + s := db.NewSession() + defer s.Close() + + u, err := confirmExportPassword(ctx, s, in.Body.Password) + if err != nil { + _ = s.Rollback() + return nil, err + } + + exportFile, err := models.GetUserDataExportFile(u) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + // The file reader comes from object storage, not the DB session, so it stays + // valid after the commit; the StreamResponse callback runs after this returns. + if err := s.Commit(); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + return &huma.StreamResponse{Body: func(hctx huma.Context) { + defer func() { _ = exportFile.File.Close() }() + c := humaecho5.Unwrap(hctx) + webfiles.WriteFileDownload((*c).Response(), (*c).Request(), exportFile) + }}, nil +} + +func userExportStatus(ctx context.Context, _ *struct{}) (*userExportStatusBody, error) { + s := db.NewSession() + defer s.Close() + + u, err := authUserFromCtx(ctx, s) + if err != nil { + _ = s.Rollback() + return nil, err + } + + status, err := models.GetUserDataExportStatus(u) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &userExportStatusBody{Body: status}, nil +} diff --git a/pkg/webtests/huma_user_export_test.go b/pkg/webtests/huma_user_export_test.go new file mode 100644 index 000000000..4140f8aa6 --- /dev/null +++ b/pkg/webtests/huma_user_export_test.go @@ -0,0 +1,125 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "bytes" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/files" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaUserExport covers the v2 data-export endpoints. Fixture topology +// (pkg/db/fixtures/users.yml + files.yml): +// - user1: local, password 12345678, export_file_id 1 (file row exists, no bytes). +// - user14: non-local (OIDC), no password to confirm. +// - user15: local, no export. +func TestHumaUserExport(t *testing.T) { + t.Run("Request with the correct password", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/request", + `{"password":"12345678"}`, humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "requested data export") + }) + + t.Run("Request with a wrong password is refused", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/request", + `{"password":"wrong-password"}`, humaTokenFor(t, &testuser1), "") + require.NotEqual(t, http.StatusOK, rec.Code, + "a wrong password must not start an export; body: %s", rec.Body.String()) + }) + + t.Run("Request as a non-local user skips the password", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/request", + `{}`, humaTokenFor(t, &testuser14), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Download streams the export bytes", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // user1's export points at file 1; setupTestEnv resets storage, so write + // real bytes for it (size matches the fixture's declared 100 bytes). + content := bytes.Repeat([]byte("v"), 100) + require.NoError(t, (&files.File{ID: 1, Size: uint64(len(content))}).Save(bytes.NewReader(content))) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download", + `{"password":"12345678"}`, humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Equal(t, content, rec.Body.Bytes(), "the streamed export bytes must match") + assert.Contains(t, rec.Header().Get("Content-Disposition"), "test") + }) + + t.Run("Download with a wrong password is refused", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download", + `{"password":"wrong-password"}`, humaTokenFor(t, &testuser1), "") + require.NotEqual(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Download without an export returns 404", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download", + `{"password":"12345678"}`, humaTokenFor(t, &testuser15), "") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Download with a missing physical file returns 404", func(t *testing.T) { + // user1 has export_file_id 1, but setupTestEnv leaves its bytes unwritten. + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/export/download", + `{"password":"12345678"}`, humaTokenFor(t, &testuser1), "") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Status returns the export metadata", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/export", "", humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"id":1`) + assert.Contains(t, rec.Body.String(), `"expires"`) + }) + + t.Run("Status without an export returns null", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/export", "", humaTokenFor(t, &testuser15), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.JSONEq(t, "null", rec.Body.String()) + }) + + t.Run("Unauthenticated request is rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/export", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +}