496 lines
12 KiB
Go
496 lines
12 KiB
Go
package fbhttp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/shirou/gopsutil/v4/disk"
|
|
"github.com/spf13/afero"
|
|
|
|
fberrors "github.com/filebrowser/filebrowser/v2/errors"
|
|
"github.com/filebrowser/filebrowser/v2/files"
|
|
"github.com/filebrowser/filebrowser/v2/fileutils"
|
|
)
|
|
|
|
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: true,
|
|
})
|
|
if err != nil {
|
|
return errToStatus(err), err
|
|
}
|
|
|
|
if file.IsDir {
|
|
file.Sorting = d.user.Sorting
|
|
file.ApplySort()
|
|
return renderJSON(w, r, file)
|
|
}
|
|
|
|
if checksum := r.URL.Query().Get("checksum"); checksum != "" {
|
|
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 = ""
|
|
}
|
|
|
|
// If metadata requested, attempt to read audio tags and attach them
|
|
if r.URL.Query().Get("metadata") == "1" {
|
|
if err := file.ReadAudioTags(); err != nil {
|
|
log.Printf("warning: failed reading audio tags for %s: %v", file.Path, err)
|
|
}
|
|
}
|
|
|
|
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)
|
|
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.user.Fs.MkdirAll(r.URL.Path, d.settings.DirMode)
|
|
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)
|
|
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
|
|
}
|
|
|
|
// Only check parent relationship when a destination was provided.
|
|
// Some actions (like metadata) don't provide a destination and calling
|
|
// filepath.Rel with an empty dst returns an error "can't make relative to ...".
|
|
if dst != "" {
|
|
err = checkParent(src, dst)
|
|
if err != nil {
|
|
return http.StatusBadRequest, err
|
|
}
|
|
}
|
|
|
|
override := r.URL.Query().Get("override") == "true"
|
|
rename := r.URL.Query().Get("rename") == "true"
|
|
// Only check destination existence when a destination was provided.
|
|
if dst != "" && !override && !rename {
|
|
if _, err = d.user.Fs.Stat(dst); err == nil {
|
|
return http.StatusConflict, nil
|
|
}
|
|
}
|
|
if rename {
|
|
dst = addVersionSuffix(dst, d.user.Fs)
|
|
}
|
|
|
|
// Permission for overwriting the file
|
|
if override && !d.user.Perm.Modify {
|
|
return http.StatusForbidden, nil
|
|
}
|
|
|
|
// Special-case metadata action because it needs the request body
|
|
if action == "metadata" {
|
|
if !d.user.Perm.Modify {
|
|
return http.StatusForbidden, nil
|
|
}
|
|
|
|
var tags map[string]string
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
return http.StatusBadRequest, err
|
|
}
|
|
if err := json.Unmarshal(body, &tags); err != nil {
|
|
return http.StatusBadRequest, err
|
|
}
|
|
|
|
err = d.RunHook(func() error {
|
|
return applyMetadataWithFFmpeg(r.Context(), d, src, tags)
|
|
}, action, src, dst, d.user)
|
|
|
|
return errToStatus(err), err
|
|
}
|
|
|
|
err = d.RunHook(func() error {
|
|
return patchAction(r.Context(), action, src, dst, d, fileCache)
|
|
}, action, src, dst, d.user)
|
|
|
|
return errToStatus(err), err
|
|
})
|
|
}
|
|
|
|
// applyMetadataWithFFmpeg attempts to write metadata using ffmpeg by creating
|
|
// a temporary file and replacing the original. This requires that the
|
|
// underlying filesystem exposes a real path (see FileInfo.RealPath()).
|
|
func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[string]string) error {
|
|
fi, 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
|
|
}
|
|
|
|
real := fi.RealPath()
|
|
// If RealPath returns the same virtual path, we cannot run ffmpeg on it.
|
|
if real == "" || real == fi.Path {
|
|
return fmt.Errorf("unable to obtain underlying real file path for %s: %w", fi.Path, fberrors.ErrInvalidRequestParams)
|
|
}
|
|
|
|
// Ensure ffmpeg is available
|
|
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
|
return fmt.Errorf("ffmpeg not found: %w", err)
|
|
}
|
|
|
|
dir := filepath.Dir(real)
|
|
|
|
// Create a unique temporary filename in the same directory and keep the
|
|
// same extension as the original so ffmpeg can infer the output format.
|
|
ext := filepath.Ext(real)
|
|
tmp := filepath.Join(dir, fmt.Sprintf(".metadata_tmp_%d%s", time.Now().UnixNano(), ext))
|
|
|
|
// Ensure the temp file is removed on error
|
|
defer func() {
|
|
if _, statErr := os.Stat(tmp); statErr == nil {
|
|
_ = os.Remove(tmp)
|
|
}
|
|
}()
|
|
|
|
// Build ffmpeg args to preserve existing metadata and override only the
|
|
// provided non-empty fields. We explicitly map input global metadata (0)
|
|
// to output, and avoid `-map_metadata -1` which would clear everything.
|
|
args := []string{"-y", "-i", real, "-map_metadata", "0", "-c", "copy"}
|
|
changes := 0
|
|
for k, v := range tags {
|
|
if strings.TrimSpace(v) == "" {
|
|
continue
|
|
}
|
|
args = append(args, "-metadata", fmt.Sprintf("%s=%s", k, v))
|
|
changes++
|
|
}
|
|
|
|
// If no non-empty changes were requested, do nothing
|
|
if changes == 0 {
|
|
return nil
|
|
}
|
|
args = append(args, tmp)
|
|
|
|
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
|
|
// Capture combined output to provide actionable errors when ffmpeg fails
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("ffmpeg error: %w: %s", err, strings.TrimSpace(string(out)))
|
|
}
|
|
|
|
// replace original
|
|
if err := os.Rename(tmp, real); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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,
|
|
})
|
|
})
|