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:
parent
b40635c3f4
commit
0bc930829d
|
|
@ -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.",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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] "
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue