feat(api/v2): add user data export endpoints

Port POST /user/export/request, POST /user/export/download (zip stream) and
GET /user/export (status) to v2. Extract the export-file loader and status
builder into pkg/models (GetUserDataExportFile, GetUserDataExportStatus) with
a shared ErrUserDataExportDoesNotExist, and refactor v1 onto them. The v2
download streams via the shared WriteFileDownload writer; local users confirm
with their password, external-provider users are passed through.
This commit is contained in:
kolaente 2026-06-12 10:31:16 +02:00 committed by kolaente
parent ac5e94252b
commit 8c72e83a4d
5 changed files with 394 additions and 41 deletions

View File

@ -2624,3 +2624,32 @@ func (err ErrTimeEntryEndBeforeStart) HTTPError() web.HTTPError {
Message: "A time entry's end time cannot be before its start time.", 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.",
}
}

View File

@ -404,6 +404,59 @@ func exportProjectBackgrounds(s *xorm.Session, u *user.User, wr *zip.Writer) (er
return utils.WriteFilesToZip(backgroundFiles, wr) 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() { func RegisterOldExportCleanupCron() {
const logPrefix = "[User Export Cleanup Cron] " const logPrefix = "[User Export Cleanup Cron] "

View File

@ -19,14 +19,11 @@ package v1
import ( import (
"io" "io"
"net/http" "net/http"
"os"
"strconv" "strconv"
"time"
"code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/user"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
@ -127,25 +124,10 @@ func DownloadUserDataExport(c *echo.Context) error {
return err return err
} }
// Check if user has an export file exportFile, err := models.GetUserDataExportFile(u)
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()
if err != nil { if err != nil {
if files.IsErrFileDoesNotExist(err) { if models.IsErrUserDataExportDoesNotExist(err) {
return exportNotFoundError return echo.NewHTTPError(http.StatusNotFound, "No user data export found.")
}
return err
}
err = exportFile.LoadFileByID()
if err != nil {
if os.IsNotExist(err) {
return exportNotFoundError
} }
return err return err
} }
@ -163,19 +145,12 @@ func DownloadUserDataExport(c *echo.Context) error {
return nil 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 // GetUserExportStatus returns metadata about the current user export if it exists
// @Summary Get current user data export // @Summary Get current user data export
// @tags user // @tags user
// @Produce json // @Produce json
// @Security JWTKeyAuth // @Security JWTKeyAuth
// @Success 200 {object} v1.UserExportStatus // @Success 200 {object} models.UserExportStatus
// @Router /user/export [get] // @Router /user/export [get]
func GetUserExportStatus(c *echo.Context) error { func GetUserExportStatus(c *echo.Context) error {
s := db.NewSession() s := db.NewSession()
@ -186,20 +161,12 @@ func GetUserExportStatus(c *echo.Context) error {
return err return err
} }
if u.ExportFileID == 0 { status, err := models.GetUserDataExportStatus(u)
return c.JSON(http.StatusOK, struct{}{}) if err != nil {
}
exportFile := &files.File{ID: u.ExportFileID}
if err := exportFile.LoadFileMetaByID(); err != nil {
return err return err
} }
if status == nil {
status := UserExportStatus{ return c.JSON(http.StatusOK, struct{}{})
ID: exportFile.ID,
Size: exportFile.Size,
Created: exportFile.Created,
Expires: exportFile.Created.Add(7 * 24 * time.Hour),
} }
return c.JSON(http.StatusOK, status) return c.JSON(http.StatusOK, status)

View File

@ -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 <https://www.gnu.org/licenses/>.
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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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())
})
}