mirror of
https://github.com/filebrowser/filebrowser.git
synced 2026-07-29 10:40:44 +00:00
A TUS PATCH rejected before its body was read left the client still streaming a chunk. net/http gives up draining an unread request body after 256KiB and then closes the connection, so the status we replied with was lost and the browser saw a bare transport error instead. The client could not tell a conflict from a network fault, retried blindly, and the upload stalled after the first chunk — while curl, which reads the response as it writes, still reported the status and appeared to work. Drain what is left of a rejected chunk so the answer arrives on a connection that stays usable. The explicit r.Body.Close() had to go with it: it ran before the drain, and the server closes the request body itself once the handler returns. Pressing Upload awaited a recursive listing of the whole destination subtree before a single byte was sent, with no sign that anything was happening. On a large destination that reads as a dead button, and the walk kept running after the client gave up, then reported the failed write as a 500. A flat upload can only collide with a direct child of the destination, so walk recursively only for folder uploads, stop the walk once the request is done, and show the action as busy while the destination is checked. Along the way: a failed upload was credited as fully sent, so the progress bar reported 100% when nothing had been written; the progress timer was recreated without clearing the previous one; and the chunk size field threw on a value with no unit and raced its own debounce on submit, so it appeared to refuse every change. Fixes #6006 Refs #5987
498 lines
12 KiB
Go
498 lines
12 KiB
Go
package fbhttp
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
fberrors "github.com/filebrowser/filebrowser/v2/errors"
|
|
"github.com/filebrowser/filebrowser/v2/files"
|
|
"github.com/filebrowser/filebrowser/v2/fileutils"
|
|
"github.com/shirou/gopsutil/v4/disk"
|
|
"github.com/spf13/afero"
|
|
)
|
|
|
|
var resourceGetHandler = withUser(func(w 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: true,
|
|
ReadHeader: d.server.TypeDetectionByHeader,
|
|
Checker: d,
|
|
Content: d.user.Perm.Download,
|
|
})
|
|
if err != nil {
|
|
return errToStatus(err), err
|
|
}
|
|
|
|
encoding := r.Header.Get("X-Encoding")
|
|
if file.IsDir {
|
|
file.Sorting = d.user.Sorting
|
|
file.ApplySort()
|
|
return renderJSON(w, r, file)
|
|
} else if encoding == "true" {
|
|
if !d.user.Perm.Download {
|
|
return http.StatusAccepted, nil
|
|
}
|
|
if file.Type != "text" {
|
|
return renderJSON(w, r, file)
|
|
}
|
|
|
|
f, err := d.user.Fs.Open(r.URL.Path)
|
|
if err != nil {
|
|
return errToStatus(err), err
|
|
}
|
|
defer f.Close()
|
|
|
|
data, err := io.ReadAll(f)
|
|
if err != nil {
|
|
return http.StatusInternalServerError, err
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, err = w.Write(data)
|
|
return 0, err
|
|
}
|
|
|
|
if checksum := r.URL.Query().Get("checksum"); checksum != "" {
|
|
if !d.user.Perm.Download {
|
|
return http.StatusAccepted, nil
|
|
}
|
|
|
|
err := file.Checksum(checksum)
|
|
if errors.Is(err, fberrors.ErrInvalidOption) {
|
|
return http.StatusBadRequest, nil
|
|
} else if err != nil {
|
|
return http.StatusInternalServerError, err
|
|
}
|
|
|
|
// do not waste bandwidth if we just want the checksum
|
|
file.Content = ""
|
|
}
|
|
|
|
return renderJSON(w, r, file)
|
|
})
|
|
|
|
func resourceDeleteHandler(fileCache FileCache) handleFunc {
|
|
return withUser(func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
|
if r.URL.Path == "/" || !d.user.Perm.Delete {
|
|
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
|
|
}
|
|
|
|
err = d.store.Share.DeleteWithPathPrefix(file.Path, d.user.ID)
|
|
if err != nil {
|
|
log.Printf("WARNING: Error(s) occurred while deleting associated shares with file: %s", err)
|
|
}
|
|
|
|
// delete thumbnails
|
|
err = delThumbs(r.Context(), fileCache, file)
|
|
if err != nil {
|
|
return errToStatus(err), err
|
|
}
|
|
|
|
err = d.RunHook(func() error {
|
|
return d.user.Fs.RemoveAll(r.URL.Path)
|
|
}, "delete", r.URL.Path, "", d.user)
|
|
|
|
if err != nil {
|
|
return errToStatus(err), err
|
|
}
|
|
|
|
return http.StatusNoContent, nil
|
|
})
|
|
}
|
|
|
|
func resourcePostHandler(fileCache FileCache) handleFunc {
|
|
return withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
|
if !d.user.Perm.Create || !d.Check(r.URL.Path) {
|
|
return http.StatusForbidden, nil
|
|
}
|
|
|
|
// Directories creation on POST.
|
|
if strings.HasSuffix(r.URL.Path, "/") {
|
|
err := d.RunHook(func() error {
|
|
return d.user.Fs.MkdirAll(r.URL.Path, d.settings.DirMode)
|
|
}, "upload", r.URL.Path, "", d.user)
|
|
return errToStatus(err), 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,
|
|
})
|
|
if err == nil {
|
|
if r.URL.Query().Get("override") != "true" {
|
|
return http.StatusConflict, nil
|
|
}
|
|
|
|
// Permission for overwriting the file
|
|
if !d.user.Perm.Modify {
|
|
return http.StatusForbidden, nil
|
|
}
|
|
|
|
err = delThumbs(r.Context(), fileCache, file)
|
|
if err != nil {
|
|
return errToStatus(err), err
|
|
}
|
|
}
|
|
|
|
err = d.RunHook(func() error {
|
|
info, writeErr := writeFile(d.user.Fs, r.URL.Path, r.Body, d.settings.FileMode, d.settings.DirMode)
|
|
if writeErr != nil {
|
|
return writeErr
|
|
}
|
|
|
|
etag := fmt.Sprintf(`"%x%x"`, info.ModTime().UnixNano(), info.Size())
|
|
w.Header().Set("ETag", etag)
|
|
return nil
|
|
}, "upload", r.URL.Path, "", d.user)
|
|
|
|
if err != nil {
|
|
_ = d.user.Fs.RemoveAll(r.URL.Path)
|
|
}
|
|
|
|
return errToStatus(err), err
|
|
})
|
|
}
|
|
|
|
var resourcePutHandler = 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
|
|
}
|
|
|
|
// Only allow PUT for files.
|
|
if strings.HasSuffix(r.URL.Path, "/") {
|
|
return http.StatusMethodNotAllowed, nil
|
|
}
|
|
|
|
exists, err := afero.Exists(d.user.Fs, r.URL.Path)
|
|
if err != nil {
|
|
return http.StatusInternalServerError, err
|
|
}
|
|
if !exists {
|
|
return http.StatusNotFound, nil
|
|
}
|
|
|
|
err = d.RunHook(func() error {
|
|
info, writeErr := writeFile(d.user.Fs, r.URL.Path, r.Body, d.settings.FileMode, d.settings.DirMode)
|
|
if writeErr != nil {
|
|
return writeErr
|
|
}
|
|
|
|
etag := fmt.Sprintf(`"%x%x"`, info.ModTime().UnixNano(), info.Size())
|
|
w.Header().Set("ETag", etag)
|
|
return nil
|
|
}, "save", r.URL.Path, "", d.user)
|
|
|
|
return errToStatus(err), err
|
|
})
|
|
|
|
func resourcePatchHandler(fileCache FileCache) handleFunc {
|
|
return withUser(func(_ http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
|
src := r.URL.Path
|
|
dst := r.URL.Query().Get("destination")
|
|
action := r.URL.Query().Get("action")
|
|
dst, err := url.QueryUnescape(dst)
|
|
dst = slashClean(dst)
|
|
src = slashClean(src)
|
|
if !d.Check(src) || !d.Check(dst) {
|
|
return http.StatusForbidden, nil
|
|
}
|
|
if err != nil {
|
|
return errToStatus(err), err
|
|
}
|
|
if dst == "/" || src == "/" {
|
|
return http.StatusForbidden, nil
|
|
}
|
|
|
|
err = checkParent(src, dst)
|
|
if err != nil {
|
|
return http.StatusBadRequest, err
|
|
}
|
|
|
|
srcInfo, _ := d.user.Fs.Stat(src)
|
|
dstInfo, _ := d.user.Fs.Stat(dst)
|
|
same := os.SameFile(srcInfo, dstInfo)
|
|
|
|
if action != "rename" || !same {
|
|
override := r.URL.Query().Get("override") == "true"
|
|
rename := r.URL.Query().Get("rename") == "true"
|
|
if !override && !rename {
|
|
if _, err = d.user.Fs.Stat(dst); err == nil {
|
|
return http.StatusConflict, nil
|
|
}
|
|
}
|
|
if rename {
|
|
dst = addVersionSuffix(dst, d.user.Fs)
|
|
}
|
|
|
|
if override && !d.user.Perm.Modify {
|
|
return http.StatusForbidden, nil
|
|
}
|
|
}
|
|
|
|
err = d.RunHook(func() error {
|
|
return patchAction(r.Context(), action, src, dst, d, fileCache)
|
|
}, action, src, dst, d.user)
|
|
|
|
return errToStatus(err), err
|
|
})
|
|
}
|
|
|
|
func checkParent(src, dst string) error {
|
|
rel, err := filepath.Rel(src, dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
rel = filepath.ToSlash(rel)
|
|
if !strings.HasPrefix(rel, "../") && rel != ".." && rel != "." {
|
|
return fberrors.ErrSourceIsParent
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func addVersionSuffix(source string, afs afero.Fs) string {
|
|
counter := 1
|
|
dir, name := path.Split(source)
|
|
ext := filepath.Ext(name)
|
|
base := strings.TrimSuffix(name, ext)
|
|
|
|
for {
|
|
if _, err := afs.Stat(source); err != nil {
|
|
break
|
|
}
|
|
renamed := fmt.Sprintf("%s(%d)%s", base, counter, ext)
|
|
source = path.Join(dir, renamed)
|
|
counter++
|
|
}
|
|
|
|
return source
|
|
}
|
|
|
|
func writeFile(afs afero.Fs, dst string, in io.Reader, fileMode, dirMode fs.FileMode) (os.FileInfo, error) {
|
|
dir, _ := path.Split(dst)
|
|
err := afs.MkdirAll(dir, dirMode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
file, err := afs.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileMode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer file.Close()
|
|
|
|
_, err = io.Copy(file, in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Sync the file to ensure all data is written to storage.
|
|
// to prevent file corruption.
|
|
if err := file.Sync(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Gets the info about the file.
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
func delThumbs(ctx context.Context, fileCache FileCache, file *files.FileInfo) error {
|
|
for _, previewSizeName := range PreviewSizeNames() {
|
|
size, _ := ParsePreviewSize(previewSizeName)
|
|
if err := fileCache.Delete(ctx, previewCacheKey(file, size)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func patchAction(ctx context.Context, action, src, dst string, d *data, fileCache FileCache) error {
|
|
switch action {
|
|
case "copy":
|
|
if !d.user.Perm.Create {
|
|
return fberrors.ErrPermissionDenied
|
|
}
|
|
|
|
return fileutils.Copy(d.user.Fs, src, dst, d.settings.FileMode, d.settings.DirMode)
|
|
case "rename":
|
|
if !d.user.Perm.Rename {
|
|
return fberrors.ErrPermissionDenied
|
|
}
|
|
src = path.Clean("/" + src)
|
|
dst = path.Clean("/" + dst)
|
|
|
|
file, err := files.NewFileInfo(&files.FileOptions{
|
|
Fs: d.user.Fs,
|
|
Path: src,
|
|
Modify: d.user.Perm.Modify,
|
|
Expand: false,
|
|
ReadHeader: false,
|
|
Checker: d,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// delete thumbnails
|
|
err = delThumbs(ctx, fileCache, file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return fileutils.MoveFile(d.user.Fs, src, dst, d.settings.FileMode, d.settings.DirMode)
|
|
default:
|
|
return fmt.Errorf("unsupported action %s: %w", action, fberrors.ErrInvalidRequestParams)
|
|
}
|
|
}
|
|
|
|
// RecursiveEntry is a single file/directory entry returned by the recursive listing endpoint.
|
|
type RecursiveEntry struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
ModTime time.Time `json:"modified"`
|
|
IsDir bool `json:"isDir"`
|
|
}
|
|
|
|
// resourceGetRecursiveHandler returns a flat list of every file and directory
|
|
// under the requested path, walking the tree recursively on the server side
|
|
// so the client only needs a single HTTP call.
|
|
var resourceGetRecursiveHandler = withUser(func(w http.ResponseWriter, r *http.Request, d *data) (int, error) {
|
|
rootPath := r.URL.Path
|
|
if rootPath == "" {
|
|
rootPath = "/"
|
|
}
|
|
|
|
// Make sure the root itself exists and is a directory.
|
|
info, err := d.user.Fs.Stat(rootPath)
|
|
if err != nil {
|
|
return errToStatus(err), err
|
|
}
|
|
if !info.IsDir() {
|
|
return http.StatusBadRequest, fmt.Errorf("path is not a directory")
|
|
}
|
|
|
|
entries := make([]RecursiveEntry, 0)
|
|
|
|
err = afero.Walk(d.user.Fs, rootPath, func(fPath string, info os.FileInfo, err error) error {
|
|
// Walking a large tree is expensive, and every entry is stat'ed through
|
|
// the scoped filesystem. Stop as soon as nobody is waiting for the
|
|
// answer instead of finishing the walk for a client that has gone.
|
|
if ctxErr := r.Context().Err(); ctxErr != nil {
|
|
return ctxErr
|
|
}
|
|
|
|
if err != nil {
|
|
return nil // skip entries we cannot read
|
|
}
|
|
|
|
// Skip the root directory itself.
|
|
if fPath == rootPath {
|
|
return nil
|
|
}
|
|
|
|
// Respect user rules.
|
|
if !d.Check(fPath) {
|
|
if info.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
|
|
entries = append(entries, RecursiveEntry{
|
|
// afero.Walk joins paths with the OS separator, so on Windows fPath
|
|
// uses backslashes. The web API contract is forward slashes, so
|
|
// normalize it here (mirrors search/search.go).
|
|
Path: filepath.ToSlash(fPath),
|
|
Name: info.Name(),
|
|
Size: info.Size(),
|
|
ModTime: info.ModTime(),
|
|
IsDir: info.IsDir(),
|
|
})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
// Once the request is done there is nobody left to answer, whatever the
|
|
// walk reported. Asking for a status here only produces a second header
|
|
// write on a connection that is already gone.
|
|
if r.Context().Err() != nil {
|
|
return 0, err
|
|
}
|
|
return http.StatusInternalServerError, err
|
|
}
|
|
|
|
return renderJSON(w, r, entries)
|
|
})
|
|
|
|
type DiskUsageResponse struct {
|
|
Total uint64 `json:"total"`
|
|
Used uint64 `json:"used"`
|
|
}
|
|
|
|
var diskUsage = withUser(func(w 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: false,
|
|
Checker: d,
|
|
Content: false,
|
|
})
|
|
if err != nil {
|
|
return errToStatus(err), err
|
|
}
|
|
fPath := file.RealPath()
|
|
if !file.IsDir {
|
|
return renderJSON(w, r, &DiskUsageResponse{
|
|
Total: 0,
|
|
Used: 0,
|
|
})
|
|
}
|
|
|
|
usage, err := disk.UsageWithContext(r.Context(), fPath)
|
|
if err != nil {
|
|
return errToStatus(err), err
|
|
}
|
|
return renderJSON(w, r, &DiskUsageResponse{
|
|
Total: usage.Total,
|
|
Used: usage.Used,
|
|
})
|
|
})
|