Files
filebrozer/http/resource.go
2026-02-08 16:50:29 +01:00

824 lines
22 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
multi := map[string][]string{}
var clear []string
body, err := io.ReadAll(r.Body)
if err != nil {
return http.StatusBadRequest, err
}
// Accept both plain tag maps and objects with __clear__ array
var raw map[string]any
if err := json.Unmarshal(body, &raw); err == nil && raw != nil {
// extract string-valued entries into tags
tags = map[string]string{}
for k, v := range raw {
if k == "__clear__" {
// parse array of strings
if arr, ok := v.([]any); ok {
for _, itm := range arr {
if s, ok := itm.(string); ok {
s = strings.TrimSpace(s)
if s != "" {
clear = append(clear, s)
}
}
}
}
continue
}
if sv, ok := v.(string); ok {
tags[k] = sv
continue
}
if arr, ok := v.([]any); ok {
vals := []string{}
for _, itm := range arr {
if s, ok := itm.(string); ok {
s = strings.TrimSpace(s)
if s != "" {
vals = append(vals, s)
}
}
}
if len(vals) > 0 {
multi[k] = vals
}
}
}
} else {
// fallback to simple map
tags = map[string]string{}
if err := json.Unmarshal(body, &tags); err != nil {
return http.StatusBadRequest, err
}
}
// If the source is a directory, apply changes to all audio files within.
// Otherwise, apply to the single file.
err = d.RunHook(func() error {
fi, statErr := files.NewFileInfo(&files.FileOptions{
Fs: d.user.Fs,
Path: src,
Modify: d.user.Perm.Modify,
Expand: false,
ReadHeader: false,
Checker: d,
})
if statErr != nil {
return statErr
}
if fi.IsDir {
return applyMetadataToDir(r.Context(), d, src, tags, multi, clear)
}
return applyMetadataWithFFmpeg(r.Context(), d, src, tags, multi, clear)
}, 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()).
// For FLAC files with multi-valued tags (ARTISTS, ALBUMARTISTS), it uses
// metaflac directly since ffmpeg doesn't support true multi-valued Vorbis comments.
func applyMetadataWithFFmpeg(ctx context.Context, d *data, src string, tags map[string]string, multi map[string][]string, clear []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"}
// For MP3, prefer ID3v2.4 to support multi-valued tags properly
isMP3 := strings.EqualFold(ext, ".mp3")
// Treat M4A/MP4 similarly for metadata handling specifics
isMP4 := strings.EqualFold(ext, ".m4a") || strings.EqualFold(ext, ".m4b") || strings.EqualFold(ext, ".mp4")
// FLAC supports true multi-valued Vorbis comments via metaflac
isFLAC := strings.EqualFold(ext, ".flac")
if isMP3 {
args = append(args, "-id3v2_version", "4")
}
// For FLAC files with multi-valued tags, use metaflac which properly supports
// multiple values for the same tag (Vorbis comments).
if isFLAC && len(multi) > 0 {
if _, err := exec.LookPath("metaflac"); err == nil {
// Use metaflac for multi-valued tags
if err := applyMultiValuedTagsWithMetaflac(ctx, real, tags, multi, clear); err != nil {
log.Printf("metaflac failed, falling back to ffmpeg: %v", err)
// Fall through to ffmpeg
} else {
return nil
}
}
}
// If MP3 and there are clears, perform ID3v2 in-place removal first.
// This avoids ffmpeg creating unwanted empty entries (e.g., TXXX artifacts).
didID3Clear := false
if isMP3 && len(clear) > 0 {
frames := mapClearsToID3Frames(clear)
txxx := mapClearsToTXXX(clear)
removeNumeric := false
for _, c := range clear {
tok := normalizeKey(c)
if tok == "artists" || tok == "albumartists" {
removeNumeric = true
break
}
}
if err := clearID3v2Frames(real, frames, txxx, removeNumeric); err == nil {
didID3Clear = true
}
// If ID3 clear failed, we'll still try ffmpeg below; but we won't add ffmpeg clears for MP3.
}
// Normalize incoming keys and map to ffmpeg keys
norm := normalizeAndMapToFFmpeg(tags)
changes := 0
setKeys := map[string]struct{}{}
for k, v := range norm {
args = append(args, "-metadata", fmt.Sprintf("%s=%s", k, v))
changes++
setKeys[k] = struct{}{}
}
// Handle multi-valued tags (e.g., Artists, AlbumArtists)
if len(multi) > 0 {
mm := normalizeMultiToFFmpeg(multi)
for ffk, vals := range mm {
// MP4/M4A expects a single value; join for compatibility
if isMP4 && (ffk == "artist" || ffk == "album_artist") {
if len(vals) > 0 {
joined := strings.Join(vals, "; ")
args = append(args, "-metadata", fmt.Sprintf("%s=%s", ffk, joined))
changes++
setKeys[ffk] = struct{}{}
}
continue
}
// MP3 (ID3): prefer writing consolidated artist/album_artist rather than TXXX ARTISTS
if isMP3 {
if ffk == "ARTISTS" {
if len(vals) > 0 {
joined := strings.Join(vals, "; ")
args = append(args, "-metadata", fmt.Sprintf("%s=%s", "artist", joined))
changes++
setKeys["artist"] = struct{}{}
}
continue
}
if ffk == "ALBUMARTISTS" {
if len(vals) > 0 {
joined := strings.Join(vals, "; ")
args = append(args, "-metadata", fmt.Sprintf("%s=%s", "album_artist", joined))
changes++
setKeys["album_artist"] = struct{}{}
}
continue
}
}
// Default: add repeated metadata entries for formats that support it (e.g., Vorbis)
for _, v := range vals {
args = append(args, "-metadata", fmt.Sprintf("%s=%s", ffk, v))
changes++
}
setKeys[ffk] = struct{}{}
}
}
// Map cleared canonical keys to ffmpeg keys and set to empty value.
// For MP3, we skip ffmpeg-based clears because ID3v2 in-place removal is preferred.
if len(clear) > 0 && !isMP3 {
for ffk := range mapClearCanonicalsToFFmpeg(clear) {
// Avoid clearing a key we are explicitly setting in this operation,
// which could result in an empty extra value (e.g., trailing NUL).
if _, isSet := setKeys[ffk]; isSet {
continue
}
args = append(args, "-metadata", fmt.Sprintf("%s=", ffk))
changes++
}
}
// If no ffmpeg changes are needed (e.g., only clears on MP3), we can return.
if changes == 0 {
// If we performed ID3 clear or there was simply nothing to change, exit early.
if didID3Clear || len(clear) == 0 {
if isMP3 {
// Best-effort cleanup of numeric/empty TXXX artifacts
_ = cleanupNumericEmptyTXXX(real)
}
return nil
}
// Otherwise continue and let ffmpeg run (non-MP3 clears already added above).
}
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
}
if isMP3 {
// Post-write cleanup for numeric/empty TXXX artifacts
_ = cleanupNumericEmptyTXXX(real)
}
return nil
}
// applyMetadataToDir iterates all immediate files in a directory and applies
// metadata changes to each supported audio file.
func applyMetadataToDir(ctx context.Context, d *data, dir string, tags map[string]string, multi map[string][]string, clear []string) error {
// List directory entries
f, err := d.user.Fs.Open(dir)
if err != nil {
return err
}
defer f.Close()
names, err := f.Readdirnames(0)
if err != nil {
return err
}
// Supported audio extensions for metadata updates
isAudio := func(ext string) bool {
ext = strings.ToLower(ext)
switch ext {
case ".mp3", ".flac", ".m4a", ".mp4", ".ogg", ".wav":
return true
default:
return false
}
}
processed := 0
failed := 0
for _, name := range names {
p := filepath.Join(dir, name)
info, err := d.user.Fs.Stat(p)
if err != nil {
// skip entries we can't stat
log.Printf("metadata: skip %s: %v", p, err)
continue
}
if info.IsDir() {
continue
}
if !isAudio(filepath.Ext(name)) {
continue
}
if err := applyMetadataWithFFmpeg(ctx, d, p, tags, multi, clear); err != nil {
failed++
log.Printf("metadata: failed for %s: %v", p, err)
continue
}
processed++
}
if processed == 0 {
return fmt.Errorf("no audio files found in album: %w", fberrors.ErrInvalidRequestParams)
}
if failed > 0 {
// Do not fail entire album operation; return success and rely on logs
// to indicate which files failed. This avoids a 500 when some files
// are temporarily locked or have unsupported formats.
return nil
}
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,
})
})
// applyMultiValuedTagsWithMetaflac uses metaflac to write tags to FLAC files.
// Unlike ffmpeg, metaflac properly supports multiple values for the same tag.
func applyMultiValuedTagsWithMetaflac(ctx context.Context, filepath string, tags map[string]string, multi map[string][]string, clear []string) error {
// Normalize tags and multi to proper Vorbis comment names
norm := normalizeAndMapToFFmpeg(tags)
mm := normalizeMultiToFFmpeg(multi)
// Build metaflac arguments
// First, remove tags that we're going to set (to avoid duplicates)
removeArgs := []string{}
for k := range norm {
upperK := strings.ToUpper(k)
removeArgs = append(removeArgs, "--remove-tag="+upperK)
// Also handle ALBUM_ARTIST vs "ALBUM ARTIST" variants
if upperK == "ALBUM_ARTIST" {
removeArgs = append(removeArgs, "--remove-tag=ALBUM ARTIST")
removeArgs = append(removeArgs, "--remove-tag=ALBUMARTIST")
}
}
for k := range mm {
removeArgs = append(removeArgs, "--remove-tag="+k)
}
// Also remove cleared tags
for _, c := range clear {
token := normalizeKey(c)
canonical, ok := canonicalMap[token]
if !ok {
if _, allowed := allowedCanonicals[c]; allowed {
canonical = c
} else {
continue
}
}
// Check multi-valued tags first
if ffk, ok := ffmpegMultiKey[canonical]; ok {
removeArgs = append(removeArgs, "--remove-tag="+ffk)
}
// Also check regular tags
if ffk, ok := ffmpegKey[canonical]; ok {
upperK := strings.ToUpper(ffk)
removeArgs = append(removeArgs, "--remove-tag="+upperK)
// Handle all variants of album artist
if canonical == "AlbumArtist" {
removeArgs = append(removeArgs, "--remove-tag=ALBUM ARTIST")
removeArgs = append(removeArgs, "--remove-tag=ALBUMARTIST")
removeArgs = append(removeArgs, "--remove-tag=ALBUM_ARTIST")
}
// Handle artist variants (lowercase/uppercase)
if canonical == "Artist" {
removeArgs = append(removeArgs, "--remove-tag=artist")
removeArgs = append(removeArgs, "--remove-tag=ARTIST")
}
}
}
// Execute removal first if needed
if len(removeArgs) > 0 {
removeArgs = append(removeArgs, filepath)
cmd := exec.CommandContext(ctx, "metaflac", removeArgs...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("metaflac remove failed: %w: %s", err, string(out))
}
}
// Now set the new tags
setArgs := []string{}
for k, v := range norm {
// Use uppercase for Vorbis comments (standard convention)
setArgs = append(setArgs, fmt.Sprintf("--set-tag=%s=%s", strings.ToUpper(k), v))
}
// Add multi-valued tags (each value as a separate --set-tag)
for k, vals := range mm {
for _, v := range vals {
setArgs = append(setArgs, fmt.Sprintf("--set-tag=%s=%s", k, v))
}
}
// If nothing to set, we're done (removal was already executed above)
if len(setArgs) == 0 {
return nil
}
setArgs = append(setArgs, filepath)
cmd := exec.CommandContext(ctx, "metaflac", setArgs...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("metaflac set failed: %w: %s", err, string(out))
}
return nil
}