From b0ede53c051d45a3e861450187e64c5342be5362 Mon Sep 17 00:00:00 2001 From: maggch Date: Tue, 3 Mar 2026 21:44:09 +0800 Subject: [PATCH] fix: handle S3 backend in user export download http.ServeContent requires io.ReadSeeker which S3 files don't support. Add S3 branch using io.Copy with explicit headers, matching the pattern already used in task attachment downloads. Refs: #2347 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/files/s3fs.go | 70 +++++++++++++++++--------------- pkg/routes/api/v1/user_export.go | 12 ++++++ 2 files changed, 50 insertions(+), 32 deletions(-) diff --git a/pkg/files/s3fs.go b/pkg/files/s3fs.go index 99411e30a..3221dd536 100644 --- a/pkg/files/s3fs.go +++ b/pkg/files/s3fs.go @@ -26,7 +26,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/aws/smithy-go" + smithyhttp "github.com/aws/smithy-go/transport/http" "github.com/spf13/afero" ) @@ -74,10 +74,19 @@ func (f *s3Fs) Stat(name string) (os.FileInfo, error) { return nil, s3ToPathError("stat", name, err) } + var size int64 + if head.ContentLength != nil { + size = *head.ContentLength + } + var modTime time.Time + if head.LastModified != nil { + modTime = *head.LastModified + } + return &s3FileInfo{ name: path.Base(name), - size: *head.ContentLength, - modTime: *head.LastModified, + size: size, + modTime: modTime, }, nil } @@ -97,24 +106,21 @@ func (f *s3Fs) Remove(name string) error { // Unsupported operations -func (*s3Fs) Create(string) (afero.File, error) { return nil, ErrS3NotSupported } -func (*s3Fs) Mkdir(string, os.FileMode) error { return ErrS3NotSupported } -func (*s3Fs) MkdirAll(string, os.FileMode) error { return ErrS3NotSupported } -func (*s3Fs) OpenFile(string, int, os.FileMode) (afero.File, error) { return nil, ErrS3NotSupported } -func (*s3Fs) RemoveAll(string) error { return ErrS3NotSupported } -func (*s3Fs) Rename(string, string) error { return ErrS3NotSupported } -func (*s3Fs) Chmod(string, os.FileMode) error { return ErrS3NotSupported } -func (*s3Fs) Chown(string, int, int) error { return ErrS3NotSupported } -func (*s3Fs) Chtimes(string, time.Time, time.Time) error { return ErrS3NotSupported } +func (*s3Fs) Create(string) (afero.File, error) { return nil, ErrS3NotSupported } +func (*s3Fs) Mkdir(string, os.FileMode) error { return ErrS3NotSupported } +func (*s3Fs) MkdirAll(string, os.FileMode) error { return ErrS3NotSupported } +func (*s3Fs) OpenFile(string, int, os.FileMode) (afero.File, error) { return nil, ErrS3NotSupported } +func (*s3Fs) RemoveAll(string) error { return ErrS3NotSupported } +func (*s3Fs) Rename(string, string) error { return ErrS3NotSupported } +func (*s3Fs) Chmod(string, os.FileMode) error { return ErrS3NotSupported } +func (*s3Fs) Chown(string, int, int) error { return ErrS3NotSupported } +func (*s3Fs) Chtimes(string, time.Time, time.Time) error { return ErrS3NotSupported } // s3ToPathError converts S3 SDK errors into os-compatible path errors. func s3ToPathError(op, name string, err error) error { - var opErr *smithy.OperationError - if errors.As(err, &opErr) { - // HeadObject on a non-existent key returns a 404 - if opErr.OperationName == "HeadObject" { - return &os.PathError{Op: op, Path: name, Err: os.ErrNotExist} - } + var respErr *smithyhttp.ResponseError + if errors.As(err, &respErr) && respErr.HTTPStatusCode() == 404 { + return &os.PathError{Op: op, Path: name, Err: os.ErrNotExist} } return &os.PathError{Op: op, Path: name, Err: err} } @@ -176,15 +182,15 @@ func (f *s3File) closeStream() { // Unsupported file operations -func (*s3File) ReadAt([]byte, int64) (int, error) { return 0, ErrS3NotSupported } -func (*s3File) Seek(int64, int) (int64, error) { return 0, ErrS3NotSupported } -func (*s3File) Write([]byte) (int, error) { return 0, ErrS3NotSupported } -func (*s3File) WriteAt([]byte, int64) (int, error) { return 0, ErrS3NotSupported } -func (*s3File) WriteString(string) (int, error) { return 0, ErrS3NotSupported } -func (*s3File) Truncate(int64) error { return ErrS3NotSupported } -func (*s3File) Sync() error { return nil } -func (*s3File) Readdir(int) ([]os.FileInfo, error) { return nil, ErrS3NotSupported } -func (*s3File) Readdirnames(int) ([]string, error) { return nil, ErrS3NotSupported } +func (*s3File) ReadAt([]byte, int64) (int, error) { return 0, ErrS3NotSupported } +func (*s3File) Seek(int64, int) (int64, error) { return 0, ErrS3NotSupported } +func (*s3File) Write([]byte) (int, error) { return 0, ErrS3NotSupported } +func (*s3File) WriteAt([]byte, int64) (int, error) { return 0, ErrS3NotSupported } +func (*s3File) WriteString(string) (int, error) { return 0, ErrS3NotSupported } +func (*s3File) Truncate(int64) error { return ErrS3NotSupported } +func (*s3File) Sync() error { return nil } +func (*s3File) Readdir(int) ([]os.FileInfo, error) { return nil, ErrS3NotSupported } +func (*s3File) Readdirnames(int) ([]string, error) { return nil, ErrS3NotSupported } // s3FileInfo implements os.FileInfo for S3 objects. type s3FileInfo struct { @@ -193,9 +199,9 @@ type s3FileInfo struct { modTime time.Time } -func (fi *s3FileInfo) Name() string { return fi.name } -func (fi *s3FileInfo) Size() int64 { return fi.size } -func (fi *s3FileInfo) Mode() os.FileMode { return 0664 } +func (fi *s3FileInfo) Name() string { return fi.name } +func (fi *s3FileInfo) Size() int64 { return fi.size } +func (fi *s3FileInfo) Mode() os.FileMode { return 0664 } func (fi *s3FileInfo) ModTime() time.Time { return fi.modTime } -func (fi *s3FileInfo) IsDir() bool { return false } -func (fi *s3FileInfo) Sys() interface{} { return nil } +func (fi *s3FileInfo) IsDir() bool { return false } +func (fi *s3FileInfo) Sys() interface{} { return nil } diff --git a/pkg/routes/api/v1/user_export.go b/pkg/routes/api/v1/user_export.go index 90de4c220..83c20e654 100644 --- a/pkg/routes/api/v1/user_export.go +++ b/pkg/routes/api/v1/user_export.go @@ -17,10 +17,13 @@ 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" @@ -147,6 +150,15 @@ func DownloadUserDataExport(c *echo.Context) error { return err } + if config.FilesType.GetString() == "s3" { + c.Response().Header().Set("Content-Disposition", "attachment; filename=\""+exportFile.Name+"\"") + c.Response().Header().Set("Content-Type", "application/zip") + c.Response().Header().Set("Content-Length", strconv.FormatUint(exportFile.Size, 10)) + c.Response().Header().Set("Last-Modified", exportFile.Created.UTC().Format(http.TimeFormat)) + _, err = io.Copy(c.Response(), exportFile.File) + return err + } + http.ServeContent(c.Response(), c.Request(), exportFile.Name, exportFile.Created, exportFile.File) return nil }