filebrowser/http/tus_handlers.go
Hippo Lin 59c26eaa80
Implements copy-on-write for TUS uploads
Implements a copy-on-write approach for handling TUS uploads.

This change addresses potential issues with direct file modification by introducing a temporary file for writing incoming data. The temporary file is used to accumulate the changes, and then the content is copied to the destination file upon completion. This ensures data integrity and avoids potential corruption during the upload process.
2025-05-29 06:57:18 +00:00

208 lines
5.7 KiB
Go

package http
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"github.com/spf13/afero"
"github.com/filebrowser/filebrowser/v2/files"
)
func tusPostHandler() handleFunc {
return withUser(func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
file, err := files.NewFileInfo(&files.FileOptions{
Fs: d.user.Fs,
Path: r.URL.Path,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: d.server.TypeDetectionByHeader,
Checker: d,
})
switch {
case errors.Is(err, afero.ErrFileNotFound):
if !d.user.Perm.Create || !d.Check(r.URL.Path) {
return http.StatusForbidden, nil
}
dirPath := filepath.Dir(r.URL.Path)
if _, statErr := d.user.Fs.Stat(dirPath); os.IsNotExist(statErr) {
if mkdirErr := d.user.Fs.MkdirAll(dirPath, files.PermDir); mkdirErr != nil {
return http.StatusInternalServerError, err
}
}
case err != nil:
return errToStatus(err), err
}
fileFlags := os.O_CREATE | os.O_WRONLY
if r.URL.Query().Get("override") == "true" {
fileFlags |= os.O_TRUNC
}
// if file exists
if file != nil {
if file.IsDir {
return http.StatusBadRequest, fmt.Errorf("cannot upload to a directory %s", file.RealPath())
}
}
openFile, err := d.user.Fs.OpenFile(r.URL.Path, fileFlags, files.PermFile)
if err != nil {
return errToStatus(err), err
}
if err := openFile.Close(); err != nil {
return errToStatus(err), err
}
return http.StatusCreated, nil
})
}
func tusHeadHandler() handleFunc {
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
w.Header().Set("Cache-Control", "no-store")
if !d.Check(r.URL.Path) {
return http.StatusForbidden, nil
}
file, err := files.NewFileInfo(&files.FileOptions{
Fs: d.user.Fs,
Path: r.URL.Path,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: d.server.TypeDetectionByHeader,
Checker: d,
})
if err != nil {
return errToStatus(err), err
}
w.Header().Set("Upload-Offset", strconv.FormatInt(file.Size, 10))
w.Header().Set("Upload-Length", "-1")
return http.StatusOK, nil
})
}
func tusPatchHandler() handleFunc {
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
if !d.user.Perm.Modify || !d.Check(r.URL.Path) {
return http.StatusForbidden, nil
}
if r.Header.Get("Content-Type") != "application/offset+octet-stream" {
return http.StatusUnsupportedMediaType, nil
}
uploadOffset, err := getUploadOffset(r)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid upload offset: %w", err)
}
file, err := files.NewFileInfo(&files.FileOptions{
Fs: d.user.Fs,
Path: r.URL.Path,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: d.server.TypeDetectionByHeader,
Checker: d,
})
switch {
case errors.Is(err, afero.ErrFileNotFound):
return http.StatusNotFound, nil
case err != nil:
return errToStatus(err), err
}
switch {
case file.IsDir:
return http.StatusBadRequest, fmt.Errorf("cannot upload to a directory %s", file.RealPath())
case file.Size != uploadOffset:
return http.StatusConflict, fmt.Errorf(
"%s file size doesn't match the provided offset: %d",
file.RealPath(),
uploadOffset,
)
}
// Create a temp file in /tmp directory for copy-on-write approach
tempDir := "/tmp"
tempFilePath := filepath.Join(tempDir, "filebrowser_"+filepath.Base(r.URL.Path)+"_"+strconv.FormatInt(uploadOffset, 10))
tempFile, err := os.OpenFile(tempFilePath, os.O_CREATE|os.O_WRONLY, files.PermFile)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("could not create temp file: %w", err)
}
defer func() {
tempFile.Close()
os.Remove(tempFilePath) // Clean up temp file after use
}()
// If we're not starting from zero, copy the existing content
if uploadOffset > 0 {
srcFile, err := d.user.Fs.Open(r.URL.Path)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("could not open source file: %w", err)
}
_, err = io.Copy(tempFile, srcFile)
srcFile.Close()
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("could not copy existing content: %w", err)
}
}
// Write the new content to the temp file
_, err = tempFile.Seek(uploadOffset, 0)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("could not seek temp file: %w", err)
}
bytesWritten, err := io.Copy(tempFile, r.Body)
r.Body.Close()
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("could not write to temp file: %w", err)
}
// Close the temp file to ensure all data is flushed
tempFile.Close()
// Open the temp file for reading
tempFile, err = os.Open(tempFilePath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("could not open temp file for reading: %w", err)
}
defer tempFile.Close()
// Create/truncate the destination file and copy the content from temp file
destFile, err := d.user.Fs.OpenFile(r.URL.Path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, files.PermFile)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("could not open destination file: %w", err)
}
defer destFile.Close()
_, err = io.Copy(destFile, tempFile)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("could not copy from temp to destination: %w", err)
}
w.Header().Set("Upload-Offset", strconv.FormatInt(uploadOffset+bytesWritten, 10))
return http.StatusNoContent, nil
})
}
func getUploadOffset(r *http.Request) (int64, error) {
uploadOffset, err := strconv.ParseInt(r.Header.Get("Upload-Offset"), 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid upload offset: %w", err)
}
return uploadOffset, nil
}