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>
This commit is contained in:
maggch 2026-03-03 21:44:09 +08:00 committed by kolaente
parent b065c62007
commit b0ede53c05
2 changed files with 50 additions and 32 deletions

View File

@ -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 }

View File

@ -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
}