Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
97d16d1
add exiftool for orientation
gtsteffaniak Nov 19, 2025
3ca9f54
updated with compile safe i18n checks (#1623)
gtsteffaniak Nov 19, 2025
3298111
Add exif tool (#1625)
gtsteffaniak Nov 19, 2025
58a01ff
Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into…
gtsteffaniak Nov 20, 2025
695364c
Bump golang.org/x/crypto from 0.44.0 to 0.45.0 in /backend in the go_…
dependabot[bot] Nov 20, 2025
a3b0fc0
Upgrade ffmpeg 8.0.1 (#1634)
gtsteffaniak Nov 20, 2025
86aebde
Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into…
gtsteffaniak Nov 21, 2025
daabefa
Update spanish translation (#1636)
Kurami32 Nov 21, 2025
ee022bc
Fix only office viewing (#1643)
gtsteffaniak Nov 21, 2025
c6d5f32
Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into…
gtsteffaniak Nov 21, 2025
eaedbbe
Fix user notifications (#1644)
gtsteffaniak Nov 21, 2025
e5e93b4
Merge branch 'dev/v1.1.1' of github.com:gtsteffaniak/filebrowser into…
gtsteffaniak Nov 21, 2025
5d38b1e
Download notification (#1645)
gtsteffaniak Nov 21, 2025
c39a193
updated with various bugfixes (#1646)
gtsteffaniak Nov 22, 2025
dc753fe
consolidate share url creation to backend api (#1647)
gtsteffaniak Nov 24, 2025
c0466e9
updated translation for chinese
gtsteffaniak Nov 24, 2025
f36ca31
updated some build and comments (#1652)
gtsteffaniak Nov 24, 2025
23fac83
adjusted list duration
gtsteffaniak Nov 24, 2025
c8073db
access control download changes (#1653)
gtsteffaniak Nov 24, 2025
fa8ceb5
Fix drag and drop and rows in normal view (#1651)
Kurami32 Nov 24, 2025
e4d8a5c
fix editor issues (#1654)
gtsteffaniak Nov 24, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,27 @@

All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).

## v1.1.1-beta

**Notes**:
- [docker] upgraded ffmpeg 8.0 to 8.0.1
- stricter access control checks on file downloads.
- Wrong translation on Chinese "save" button #1650
- Incorrect Spanish Translations in Folder Creation and Rename Actions #1626
- duplicate file detector has stricter partial checksum match #1617

**BugFixes**:
- added missing exiftool to docker image for heic conversion orientation support
- v1.1.0-beta - Incorrect naming of 1 file in directory-info #1621
- disable only office viewing settings not applying
- OnlyOffice integration does not work behind proxy authentication #1422
- Newly created users "add on" to defined scope of previous user #1628 #1518
- disable chown on upload / file saving #1469 #1546
- Uploading a file will silently overwrite any existing file with the same name #1564
- share file url issue
- Fix drag and drop and rows in normal view #1651
- Some text based files are not able to edit. #1567

## v1.1.0-beta

**New Features**:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

## Pinned

:loudspeaker: [What's Coming Soon](https://github.com/gtsteffaniak/filebrowser/discussions/1622)

:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)

## About
Expand Down
5 changes: 3 additions & 2 deletions _docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM gtstef/ffmpeg:8.0-decode AS ffmpeg
FROM gtstef/ffmpeg:8.0.1-decode AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
Expand Down Expand Up @@ -28,7 +28,7 @@ COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata curl
RUN apk --no-cache add ca-certificates mailcap tzdata curl exiftool
RUN adduser -D -s /bin/true -u 1000 filebrowser
USER filebrowser
WORKDIR /home/filebrowser
Expand All @@ -42,6 +42,7 @@ COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
RUN [ "filebrowser", "version" ]
RUN [ "ffmpeg", "-version" ]
RUN [ "ffprobe", "-version" ]
RUN [ "exiftool", "-ver" ]
USER root
# exposing default port for auto discovery.
EXPOSE 80
Expand Down
4 changes: 2 additions & 2 deletions backend/.air.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ tmp_dir = "tmp"
bin = "tmp/filebrowser"
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -c test_config.yaml"
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "http/dist", "http/embed", "swagger/docs"]
exclude_dir = ["tmp", "vendor", "http/dist", "http/embed", "swagger/docs", "preview/thumbnails", "preview/heic"]
log = "air.log"
delay = 1000 # ms

[log]
time = true

[misc]
clean_on_exit = true
clean_on_exit = false
2 changes: 1 addition & 1 deletion backend/.air.windows.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ tmp_dir = "tmp"
time = true

[misc]
clean_on_exit = true
clean_on_exit = false
138 changes: 138 additions & 0 deletions backend/adapters/fs/files/content_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package files

import (
"os"
"path/filepath"
"testing"
"unicode/utf8"

"github.com/stretchr/testify/require"
)

func TestGetContent_UTF8Truncation(t *testing.T) {
// Get the path to the test file
// The test file is in frontend/tests/playwright-files/utf8-truncated.txt
// We need to find it relative to the test execution directory
cwd, err := os.Getwd()
require.NoError(t, err)

// Navigate from backend/adapters/fs/files to the project root
// backend/adapters/fs/files -> backend/adapters/fs -> backend/adapters -> backend -> root
testFilePath := filepath.Join(cwd, "..", "..", "..", "..", "frontend", "tests", "playwright-files", "utf8-truncated.txt")

// Check if file exists, if not try alternative path
if _, err = os.Stat(testFilePath); os.IsNotExist(err) {
// Try from project root
testFilePath = filepath.Join("frontend", "tests", "playwright-files", "utf8-truncated.txt")
if _, err = os.Stat(testFilePath); os.IsNotExist(err) {
t.Skipf("Test file not found at %s, skipping test", testFilePath)
return
}
}

// Get absolute path
var absPath string
absPath, err = filepath.Abs(testFilePath)
require.NoError(t, err)

t.Run("file with UTF-8 truncation at 4096 byte boundary", func(t *testing.T) {
// The test file is longer than 4096 bytes, with valid UTF-8 throughout.
// However, when reading exactly 4096 bytes as a header, it cuts off in the
// middle of a multi-byte UTF-8 sequence (e6 9c, missing the last byte 88 of '月').
// This should trigger the truncation handling in getContent's header validation.
// The fix should trim the incomplete sequence from the header, allowing the
// header check to pass, and then the full file (which is valid UTF-8) should
// pass the full file validation.
content, err := getContent(absPath)

// Should not return an error - the header truncation is handled, and the full file is valid
require.NoError(t, err)

// The content should be the full valid UTF-8 file
require.NotEmpty(t, content, "Content should not be empty - full file is valid UTF-8")

// Verify it contains the expected text
require.Contains(t, content, "文件已备份", "Content should contain Chinese characters")
require.Contains(t, content, "2024年", "Content should contain date information")
})

t.Run("regular UTF-8 text file", func(t *testing.T) {
// Test with a simple text file to ensure normal files still work
tmpFile, err := os.CreateTemp("", "test-utf8-*.txt")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()

testText := "Hello, 世界! This is a test file with UTF-8 characters.\n"
_, err = tmpFile.WriteString(testText)
require.NoError(t, err)
tmpFile.Close()

content, err := getContent(tmpFile.Name())
require.NoError(t, err)
require.Equal(t, testText, content)
})

t.Run("file smaller than header size", func(t *testing.T) {
// Test with a file smaller than 4096 bytes
tmpFile, err := os.CreateTemp("", "test-small-*.txt")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()

testText := "Small file content"
_, err = tmpFile.WriteString(testText)
require.NoError(t, err)
tmpFile.Close()

content, err := getContent(tmpFile.Name())
require.NoError(t, err)
require.Equal(t, testText, content)
})

t.Run("file with Chinese characters at boundary", func(t *testing.T) {
// Create a file that's exactly 4094 bytes, ending with a complete Chinese character
tmpFile, err := os.CreateTemp("", "test-chinese-*.txt")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()

// Create content that's exactly 4094 bytes, ending with complete UTF-8
baseText := "2024年 06月 17日 星期一 04:05:58 CST 文件已备份\n"
content := ""
for len([]byte(content)) < 4094 {
content += baseText
}
// Trim to exactly 4094 bytes
encoded := []byte(content)
if len(encoded) > 4094 {
encoded = encoded[:4094]
}
// Ensure it ends with a complete character by finding the last complete rune
for len(encoded) > 0 {
lastRune, _ := decodeLastRune(encoded)
if lastRune != 0xFFFD { // RuneError
break
}
encoded = encoded[:len(encoded)-1]
}

_, err = tmpFile.Write(encoded)
require.NoError(t, err)
tmpFile.Close()

result, err := getContent(tmpFile.Name())
require.NoError(t, err)
require.NotEmpty(t, result)
require.Equal(t, string(encoded), result)
})
}

// Helper function to check if last rune is valid
func decodeLastRune(p []byte) (rune, int) {
if len(p) == 0 {
return 0, 0
}
r, size := utf8.DecodeLastRune(p)
return r, size
}
52 changes: 34 additions & 18 deletions backend/adapters/fs/files/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -550,17 +550,12 @@ func WriteDirectory(opts utils.FileOptions) error {
}

// Ensure the parent directories exist
// Permissions are set by MkdirAll (subject to umask, which is usually acceptable)
err = os.MkdirAll(realPath, fileutils.PermDir)
if err != nil {
return err
}

// Explicitly set directory permissions to bypass umask
err = os.Chmod(realPath, fileutils.PermDir)
if err != nil {
return err
}

return RefreshIndex(idx.Name, opts.Path, true, true)
}

Expand All @@ -580,15 +575,20 @@ func WriteFile(source, path string, in io.Reader) error {
}
var stat os.FileInfo
// Check if the destination exists and is a directory
if stat, err = os.Stat(realPath); err == nil && stat.IsDir() {
// If it's a directory and we're trying to create a file, remove the directory first
err = os.RemoveAll(realPath)
if err != nil {
return fmt.Errorf("could not remove existing directory to create file: %v", err)
if stat, err = os.Stat(realPath); err == nil {
if stat.IsDir() {
// If it's a directory and we're trying to create a file, remove the directory first
err = os.RemoveAll(realPath)
if err != nil {
return fmt.Errorf("could not remove existing directory to create file: %v", err)
}
}
// If file exists, its permissions will be preserved (O_TRUNC doesn't change permissions)
}

// Open the file for writing (create if it doesn't exist, truncate if it does)
// For new files: permissions are set to fileutils.PermFile (subject to umask, which is usually acceptable)
// For existing files: permissions are preserved automatically (O_TRUNC doesn't change them)
file, err := os.OpenFile(realPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
Expand All @@ -601,12 +601,6 @@ func WriteFile(source, path string, in io.Reader) error {
return err
}

// Explicitly set file permissions to bypass umask
err = os.Chmod(realPath, fileutils.PermFile)
if err != nil {
return err
}

return RefreshIndex(source, path, false, false)
}

Expand Down Expand Up @@ -637,9 +631,31 @@ func getContent(realPath string) (string, error) {
// --- Start of new heuristic checks ---

if n > 0 {
// Trim header to last complete UTF-8 rune to avoid false negatives
// when the header read cuts off in the middle of a multi-byte sequence.
// We decode runes from the end until we find a valid one, trimming
// any incomplete sequences at the end.
trimmedHeader := actualHeader
for len(trimmedHeader) > 0 {
lastRune, size := utf8.DecodeLastRune(trimmedHeader)
if lastRune != utf8.RuneError {
// Found a valid complete rune
break
}
// RuneError occurred - this could be an incomplete sequence or invalid byte
// Trim the last byte and try again
if size == 1 && len(trimmedHeader) > 0 {
trimmedHeader = trimmedHeader[:len(trimmedHeader)-1]
} else {
// Shouldn't happen, but break to avoid infinite loop
break
}
}

// 1. Basic Check: Is the header valid UTF-8?
// If not, it's unlikely an editable UTF-8 text file.
if !utf8.Valid(actualHeader) {
// Use trimmed header to avoid false negatives from truncated sequences
if len(trimmedHeader) > 0 && !utf8.Valid(trimmedHeader) {
return "", nil // Not an error, just not the text file we want
}

Expand Down
10 changes: 7 additions & 3 deletions backend/adapters/fs/files/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"

"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)

Expand All @@ -31,11 +32,14 @@ func MakeUserDirs(u *users.User, disableScopeChange bool) error {
if filepath.Base(scope.Scope) != cleanedUserName && source.Config.CreateUserDir && !disableScopeChange {
fullPath := filepath.Join(source.Path, scope.Scope, cleanedUserName)
parentDir := filepath.Join(source.Path, scope.Scope)
// validate that scope path exists
// If parent directory doesn't exist and createUserDir is enabled, create it
if !Exists(parentDir) {
return fmt.Errorf("MakeUserDirs: scope path does not exist: %s", scope.Scope)
if err := MakeUserDir(parentDir); err != nil {
return fmt.Errorf("MakeUserDirs: failed to create parent scope directory: %s - %v", scope.Scope, err)
}
}
scope.Scope = filepath.Join(scope.Scope, cleanedUserName)
// Use JoinPathAsUnix to ensure scope remains in Unix format (forward slashes)
scope.Scope = utils.JoinPathAsUnix(scope.Scope, cleanedUserName)
err := MakeUserDir(fullPath)
if err != nil {
return fmt.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
Expand Down
21 changes: 15 additions & 6 deletions backend/adapters/fs/fileutils/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ func CopyFile(source, dest string) error {

// copySingleFile handles copying a single file.
func copySingleFile(source, dest string) error {
// Get source file info to preserve permissions
srcInfo, err := os.Stat(source)
if err != nil {
return err
}
sourcePerms := srcInfo.Mode().Perm()

// Open the source file.
src, err := os.Open(source)
if err != nil {
Expand All @@ -75,8 +82,8 @@ func copySingleFile(source, dest string) error {
return err
}

// Create the destination file.
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, PermFile)
// Create the destination file with source permissions
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, sourcePerms)
if err != nil {
return err
}
Expand All @@ -88,11 +95,13 @@ func copySingleFile(source, dest string) error {
return err
}

// Set the configured file permissions instead of copying from source
err = os.Chmod(dest, PermFile)

// Preserve source file permissions
// Handle chmod errors gracefully (e.g., in rootless containers where chmod may be restricted)
err = os.Chmod(dest, sourcePerms)
if err != nil {
return err
// Log but don't fail - chmod may be restricted in some environments
// The file was copied successfully, so we continue
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", dest, err)
}

return nil
Expand Down
2 changes: 1 addition & 1 deletion backend/common/settings/structs.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ type Server struct {
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
CacheDirCleanup *bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory (default: true)
CacheDirCleanup *bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: true)
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
Filesystem Filesystem `json:"filesystem"` // filesystem settings
// not exposed to config
Expand Down
1 change: 1 addition & 0 deletions backend/database/share/share.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type CommonShare struct {
Source string `json:"source,omitempty"` // backend source is path to maintain between name changes
Path string `json:"path,omitempty"`
DownloadURL string `json:"downloadURL,omitempty"`
ShareURL string `json:"shareURL,omitempty"`
DisableShareCard bool `json:"disableShareCard,omitempty"`
EnforceDarkLightMode string `json:"enforceDarkLightMode,omitempty"` // "dark" or "light"
ViewMode string `json:"viewMode,omitempty"` // default view mode for anonymous users: "list", "compact", "normal", "gallery"
Expand Down
Loading
Loading