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 }