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())
+ })
+}