Compare commits

..
Author SHA1 Message Date
Graham Steffaniak 78397fc415 last minute changes (#1927) 2026-02-03 15:00:44 -06:00
Graham Steffaniak 5d01e1b1b8 updated with better image preloading (#1926) 2026-02-03 08:37:07 -06:00
Graham Steffaniak 3c0632d682 added baseurl to static assets (#1924) 2026-02-02 18:20:58 -06:00
Graham Steffaniak 85bde31ab8 Image viewer preload with thumbnail if available (#1923) 2026-02-02 17:41:48 -06:00
Graham Steffaniak fc42dab33a updated imaging semaphore more strict (#1921) 2026-02-02 14:36:44 -06:00
Kevin B. 1e66561366 Implement media session for plyr (#1917) 2026-02-02 09:53:44 -06:00
Graham Steffaniak 5a2e1f38b3 fix the album art on shares 2026-02-02 09:51:10 -06:00
Graham Steffaniak c2232f436a removed virtual scrolling v1.2.2 (#1920) 2026-02-02 09:05:54 -06:00
Graham Steffaniak b0b900c7ed consolidated share state (#1916) 2026-01-31 16:48:14 -06:00
Graham Steffaniak a8c6d05833 Share link UI changes (#1915) 2026-01-30 18:43:49 -06:00
Graham Steffaniak 0c23cc8324 updated with virtual scrolling (#1913) 2026-01-30 14:50:36 -06:00
Graham Steffaniak fe02b9422b better image processing concurrency rules (#1912) 2026-01-30 11:48:56 -06:00
Graham Steffaniak ad2aed47bc normalized user languages (#1907) 2026-01-28 18:25:25 -06:00
Graham Steffaniak 5b13673fff Merge branch 'dev/v1.2.2' of github.com:gtsteffaniak/filebrowser into dev/v1.2.2 2026-01-28 16:57:33 -06:00
+4 5c369404e8 Progressive web app favicon conversions (#1906)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-01-28 16:57:22 -06:00
+4 af8cde651b Add context menu duplicates (#1905)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-01-28 16:57:22 -06:00
+4 9e95977366 Fix otp issues (#1904)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-01-28 16:57:22 -06:00
Kevin B. 063012477e Make sidebar resizable (#1896) 2026-01-28 16:57:22 -06:00
Kevin B. ad04c6833c Fix brand text in login screen (#1898) 2026-01-28 16:57:22 -06:00
Graham Steffaniak 45e8da383b tweak indexing behavior for existing sqlite index (#1895) 2026-01-28 16:57:22 -06:00
+4 4167d3e420 more bugfixes for 1.2.2 (#1892)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-01-28 16:57:16 -06:00
dependabot[bot] 23f9d98dde Bump lodash from 4.17.21 to 4.17.23 in /frontend in the npm_and_yarn group across 1 directory (#1882)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-28 16:56:52 -06:00
+4 13162e20e5 Progressive web app favicon conversions (#1906)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-01-28 16:56:38 -06:00
+4 d2214cc01c Add context menu duplicates (#1905)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-01-28 15:23:08 -06:00
+4 b7835de1c9 Fix otp issues (#1904)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-01-28 13:55:51 -06:00
Kevin B. a806476686 Make sidebar resizable (#1896) 2026-01-27 16:51:58 -06:00
Kevin B. 0296216787 Fix brand text in login screen (#1898) 2026-01-27 15:06:50 -06:00
Graham Steffaniak 84ec5c177d tweak indexing behavior for existing sqlite index (#1895) 2026-01-25 11:23:05 -06:00
+4 e72c47dc5e more bugfixes for 1.2.2 (#1892)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
Co-authored-by: Claudio Yanes <me@claudio4.com>
Co-authored-by: Aleksandr Baryshnikov <owner@reddec.net>
Co-authored-by: SmilingPie <86896922+KevinT3Hu@users.noreply.github.com>
Co-authored-by: Maxime Borges <contact@maximeborg.es>
2026-01-25 07:17:56 -06:00
dependabot[bot] eb47df7172 Bump lodash from 4.17.21 to 4.17.23 in /frontend in the npm_and_yarn group across 1 directory (#1882)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-22 10:26:15 -06:00
149 changed files with 3254 additions and 2722 deletions

No files matched your search

+33 -2
View File
@@ -2,6 +2,38 @@
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.2.2-beta
**New Features**:
- Resizable sidebar (#1896)
- OIDC Authentication: Change Button Text via `frontend.oidcLoginButtonText` (#1708)
- improved favicon processing (#1899)
- supports more formats
- supports larger images
- automatcially generates multiple favicon sizes on startup for non-svg images. Custom svg favicons need a companion *.png to exist broad compatibilty
- Enhanced media playback: Ability to control the queue from your device's lock screen and notification panel - Will also show
metadata of the current playing media if available (#1917)
**Notes**:
- Better text file content detection (#1726)
- More url encoding changes for API which should make things more consistent. Open issues if you see path/source not found errors.
- adjustments to the startup behavior for sqlite index for reusing the previous database on startup
- CTRL + B disables sticky sidebar forever (#1869)
- added context menu back to duplicate finder
- Sharing a link for uploads - folder/file access and UX polishing (#1902)
- improved listing view and scrolling performance
- improved image viewer which will utilize recent thumbnails as a placeholder when loading the full image.
- Small reorganization of "Share" settings to make the popup clear (#1826)
**BugFixes**:
- Added docker default `FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"` back -- mistakenly removed. see https://filebrowserquantum.com/en/docs/getting-started/config/#3-default-locations. (#1891)
- Fix brand text in login screen (#1898)
- Sidebar links cointains urls with 2x repeated source name (#1847)
- 2 factor auth getting overwritten if you edit the auth from admin (#1819)
- New created user's files are listing error, Probably related with language setting. (#1565)
- thumbnail generation cpu/memory and concurrency bug.
- added 75MB filesize limit for image previews
- optimized concurrency for large vs small images
## v1.2.1-beta
@@ -9,8 +41,7 @@ All notable changes to this project will be documented in this file. For commit
- Global disable onlyoffice editor via "*" file option to disable all files for a specific user. (#1533)
**Notes**:
- upgraded imaging package and improved thumbnail generation performance. (#1797) (#1850
)
- upgraded imaging package and improved thumbnail generation performance. (#1797) (#1850)
- updated download api to use repeated `file` query param for path instead of `files` with comma. See swagger for details (#1881)
**BugFixes**:
+1
View File
@@ -27,6 +27,7 @@ FROM alpine:latest
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata curl exiftool
RUN adduser -D -s /bin/true -u 1000 filebrowser
+1
View File
@@ -23,6 +23,7 @@ RUN npm run build:docker
FROM alpine:latest
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata curl
RUN adduser -D -s /bin/true -u 1000 filebrowser
+59 -4
View File
@@ -10,6 +10,9 @@ import (
"os"
"path/filepath"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/go-logger/logger"
)
// Cache interface for caching operations
@@ -29,9 +32,61 @@ type FileCache struct {
}
}
// NewFileCache creates a new FileCache
func NewFileCache(dir string) (*FileCache, error) {
return &FileCache{dir: dir}, nil
cacheDir := filepath.Join(dir, "diskcache")
// Migrate existing cache files from old structure (if any)
if err := migrateOldCacheStructure(dir, cacheDir); err != nil {
logger.Warningf("failed to migrate old cache structure: %v\n", err)
}
// Create the cache directory immediately with proper permissions
if err := os.MkdirAll(cacheDir, fileutils.PermDir); err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
return &FileCache{dir: cacheDir}, nil
}
// migrateOldCacheStructure moves cache files from old structure (dir/a/...) to new structure (dir/diskcache/a/...)
func migrateOldCacheStructure(oldDir, newDir string) error {
// Read the old cache directory
entries, err := os.ReadDir(oldDir)
if err != nil {
if os.IsNotExist(err) {
return nil // Directory doesn't exist yet, nothing to migrate
}
return err
}
migrated := 0
for _, entry := range entries {
// Only migrate single-character directories (cache hash directories like a, b, c, etc.)
if !entry.IsDir() || len(entry.Name()) != 1 {
continue
}
// Skip if it's already the diskcache directory
if entry.Name() == "d" && filepath.Join(oldDir, entry.Name()) == newDir {
continue
}
oldPath := filepath.Join(oldDir, entry.Name())
newPath := filepath.Join(newDir, entry.Name())
// Create parent directory in new location
if err := os.MkdirAll(newDir, fileutils.PermDir); err != nil {
return err
}
// Move the directory
if err := os.Rename(oldPath, newPath); err != nil {
// If rename fails (e.g., cross-device), skip this one
continue
}
migrated++
}
return nil
}
func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
@@ -40,11 +95,11 @@ func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
defer mu.Unlock()
fileName := f.getFileName(key)
if err := os.MkdirAll(filepath.Dir(fileName), 0700); err != nil {
if err := os.MkdirAll(filepath.Dir(fileName), fileutils.PermDir); err != nil {
return err
}
if err := os.WriteFile(fileName, value, 0600); err != nil {
if err := os.WriteFile(fileName, value, fileutils.PermFile); err != nil {
return err
}
@@ -6,6 +6,7 @@ import (
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/stretchr/testify/require"
)
@@ -19,6 +20,9 @@ func TestFileCache(t *testing.T) {
cachedFilePath = "a/62/a62f2225bf70bfaccbc7f1ef2a397836717377de"
)
// Set up file permissions before creating cache
fileutils.SetFsPermissions(0644, 0755)
// Create temporary directory for the cache
cacheDir, err := os.MkdirTemp("", cacheRoot)
require.NoError(t, err)
@@ -28,19 +32,20 @@ func TestFileCache(t *testing.T) {
require.NoError(t, err)
// store new key
// Note: NewFileCache creates a "diskcache" subdirectory, so the actual path includes it
err = cache.Store(ctx, key, []byte(value))
require.NoError(t, err)
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, value)
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, value)
// update existing key
err = cache.Store(ctx, key, []byte(newValue))
require.NoError(t, err)
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, newValue)
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, newValue)
// delete key
err = cache.Delete(ctx, key)
require.NoError(t, err)
exists := fileExists(filepath.Join(cacheDir, cachedFilePath))
exists := fileExists(filepath.Join(cacheDir, "diskcache", cachedFilePath))
require.False(t, exists)
}
+2 -2
View File
@@ -219,7 +219,7 @@ func TestOverrideDirectoryToFile(t *testing.T) {
indexing.Initialize(&settings.Source{
Name: "test",
Path: "/mock/path",
}, true) // true for mock mode
}, true, false) // true for mock mode, false for isNewDb
// Get the index and set up mock data
idx := indexing.GetIndex("test")
@@ -329,7 +329,7 @@ func TestOverrideFileToDirectory(t *testing.T) {
indexing.Initialize(&settings.Source{
Name: "test",
Path: "/mock/path",
}, true) // true for mock mode
}, true, false) // true for mock mode, false for isNewDb
// Get the index and set up mock data
idx := indexing.GetIndex("test")
+10 -141
View File
@@ -11,8 +11,6 @@ import (
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"github.com/dhowden/tag"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
@@ -245,16 +243,6 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
// Process text content for non-video, non-audio files
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
// Check if file is text before reading content
isText, err := utils.IsTextFile(info.RealPath)
if err != nil {
logger.Debugf("could not check file type for: "+info.RealPath, info.Name, err)
return
}
if !isText {
// Not a text file, skip content extraction
return
}
content, err := getContent(info.RealPath)
if err != nil {
logger.Debugf("could not get content for file: "+info.RealPath, info.Name, err)
@@ -746,149 +734,30 @@ func WriteFile(source, path string, in io.Reader) error {
}
// getContent reads and returns the file content if it's considered an editable text file.
// This is a wrapper around utils.IsTextFile that also returns the file content.
func getContent(realPath string) (string, error) {
const headerSize = 4096
// Thresholds for detecting binary-like content (these can be tuned)
const maxNullBytesInHeaderAbs = 10 // Max absolute null bytes in header
const maxNullByteRatioInHeader = 0.1 // Max 10% null bytes in header
const maxNullByteRatioInFile = 0.05 // Max 5% null bytes in the entire file
const maxNonPrintableRuneRatio = 0.05 // Max 5% non-printable runes in the entire file
// Open file
f, err := os.Open(realPath)
// First check if it's a text file using the consolidated validation logic
isText, err := utils.IsTextFile(realPath)
if err != nil {
return "", err
}
defer f.Close()
// Read header
headerBytes := make([]byte, headerSize)
n, err := f.Read(headerBytes)
if err != nil && err != io.EOF {
return "", err
if !isText {
// Not a text file, return empty string (no error, just not text)
return "", nil
}
actualHeader := headerBytes[:n]
// --- 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.
// 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
}
// 2. Check for excessive null bytes in the header
nullCountInHeader := 0
for _, b := range actualHeader {
if b == 0x00 {
nullCountInHeader++
}
}
// Reject if too many nulls absolutely or relatively in the header
if nullCountInHeader > 0 { // Only perform check if there are any nulls
if nullCountInHeader > maxNullBytesInHeaderAbs ||
(float64(nullCountInHeader)/float64(n) > maxNullByteRatioInHeader) {
return "", nil // Too many nulls in header
}
}
// 3. Check for other non-text ASCII control characters in the header
// (C0 controls excluding \t, \n, \r)
for _, b := range actualHeader {
if b < 0x20 && b != '\t' && b != '\n' && b != '\r' {
return "", nil // Found problematic control character
}
// C1 control characters (0x80-0x9F) would be caught by utf8.Valid if part of invalid sequences,
// or by the non-printable rune check later if they form valid (but undesirable) codepoints.
}
// Optional: Use http.DetectContentType for an additional check on the header
// contentType := http.DetectContentType(actualHeader)
// if !strings.HasPrefix(contentType, "text/") && contentType != "application/octet-stream" {
// // If it's clearly a non-text MIME type (e.g., "image/jpeg"), reject it.
// // "application/octet-stream" is ambiguous, so we rely on other heuristics.
// return "", nil
// }
}
// --- End of new heuristic checks for header ---
// Now read the full file (original logic)
// It's a text file, read and return the content
content, err := os.ReadFile(realPath)
if err != nil {
return "", err
}
// Handle empty file (original logic - returns specific string)
// Handle empty file (return specific marker string)
if len(content) == 0 {
return "empty-file-x6OlSil", nil
}
stringContent := string(content)
// 4. Final UTF-8 validation for the entire file
// (This is crucial as the header might be fine, but the rest of the file isn't)
if !utf8.ValidString(stringContent) {
return "", nil
}
// 5. Check for excessive null bytes in the entire file content
if len(content) > 0 { // Check only for non-empty files
totalNullCount := 0
for _, b := range content {
if b == 0x00 {
totalNullCount++
}
}
if float64(totalNullCount)/float64(len(content)) > maxNullByteRatioInFile {
return "", nil // Too many nulls in the entire file
}
}
// 6. Check for excessive non-printable runes in the entire file content
// (Excluding tab, newline, carriage return, which are common in text files)
if len(stringContent) > 0 { // Check only for non-empty strings
nonPrintableRuneCount := 0
totalRuneCount := 0
for _, r := range stringContent {
totalRuneCount++
// unicode.IsPrint includes letters, numbers, punctuation, symbols, and spaces.
// It excludes control characters. We explicitly allow \t, \n, \r.
if !unicode.IsPrint(r) && r != '\t' && r != '\n' && r != '\r' {
nonPrintableRuneCount++
}
}
if totalRuneCount > 0 { // Avoid division by zero
if float64(nonPrintableRuneCount)/float64(totalRuneCount) > maxNonPrintableRuneRatio {
return "", nil // Too many non-printable runes
}
}
}
// The file has passed all checks and is considered editable text.
return stringContent, nil
return string(content), nil
}
func IsNamedPipe(mode os.FileMode) bool {
+36
View File
@@ -0,0 +1,36 @@
package fileutils
import (
"io/fs"
"net/http"
)
var (
assetFs fs.FS
)
// Custom dirFS to handle both embedded and non-embedded file systems
type dirFS struct {
http.Dir
}
// Implement the Open method for dirFS, which wraps http.Dir
func (d dirFS) Open(name string) (fs.File, error) {
return d.Dir.Open(name)
}
// InitAssetFS initializes the asset filesystem for the application
// This should be called once during startup before http or preview services start
func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool) {
if useEmbedded {
assetFs = embeddedAssets
} else {
// Dev mode: Serve files from http/dist directory
assetFs = dirFS{Dir: http.Dir("http/dist")}
}
}
// GetAssetFS returns the initialized asset filesystem
func GetAssetFS() fs.FS {
return assetFs
}
+6 -3
View File
@@ -67,15 +67,17 @@ func GenerateOtpForUser(user *users.User, userStore *users.Storage) (string, err
secretText := key.Secret()
nonce := ""
secretToStore := secretText
if settings.Config.Auth.TotpSecret != "" {
// If an encryption key is provided, encrypt the secret.
secretText, nonce, err = encryptSecret(secretText, encryptionKey)
secretToStore, nonce, err = encryptSecret(secretText, encryptionKey)
if err != nil {
return "", fmt.Errorf("failed to encrypt TOTP secret: %w", err)
}
}
// set cache so verify can attempt to use it but not require it for user yet.
TotpCache.Set(user.Username, secretText+"||"+nonce)
TotpCache.Set(user.Username, secretToStore+"||"+nonce)
// Use the original base32 secret in the OTP URL, not the encrypted version
url := fmt.Sprintf("otpauth://totp/%v?secret=%v", "FileBrowser Quantum: "+user.Username, secretText)
return url, nil
}
@@ -181,9 +183,10 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
Algorithm: TOTPAlgorithm,
})
if err != nil {
logger.Errorf("error during TOTP validation: %v", err)
logger.Errorf("error during TOTP validation for user %s: %v", user.Username, err)
}
if !valid {
logger.Warningf("Invalid TOTP code for user %s", user.Username)
return fmt.Errorf("invalid OTP token")
}
if totpSecret != "" {
+17 -7
View File
@@ -85,6 +85,23 @@ func runCLI() bool {
}
subcommand := os.Args[2]
// Handle old syntax: "set -u user,pass -c config.yaml"
if subcommand == "-u" {
// Parse flags starting from os.Args[2:] to include the -u flag
err := setCmd.Parse(os.Args[2:])
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
logger.Debugf("setUser called with args: %v, config: %v, admin: %v", os.Args, dbConfig, asAdmin)
err = setUser(dbConfig, asAdmin)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false
}
// New pattern: subcommand-based (e.g., "set user" or "set rule")
// Parse flags starting from os.Args[3:] to skip the subcommand
err := setCmd.Parse(os.Args[3:])
@@ -93,13 +110,6 @@ func runCLI() bool {
os.Exit(1)
}
switch subcommand {
case "-u":
err := setUser(dbConfig, asAdmin)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false
case "rule":
err := setRule(dbConfig, fsPath, indexPath, ruleCategory, value, allow)
if err != nil {
+17 -8
View File
@@ -3,6 +3,7 @@ package cmd
import (
"context"
"fmt"
"io/fs"
"os"
"os/signal"
"syscall"
@@ -116,7 +117,7 @@ func StartFilebrowser() {
}
// Initialize shared index database before starting HTTP service
wasRecreated, err := indexing.InitializeIndexDB()
isNewDb, err := indexing.InitializeIndexDB()
if err != nil {
logger.Fatalf("Failed to initialize index database: %v", err)
}
@@ -124,19 +125,15 @@ func StartFilebrowser() {
// Set indexing storage for persistence
if store != nil && store.Indexing != nil {
indexing.SetIndexingStorage(store.Indexing)
// If the index database was recreated (fresh or corrupted), reset all complexities
if wasRecreated {
if isNewDb {
if err := store.Indexing.ResetAllComplexities(); err != nil {
logger.Errorf("Failed to reset index complexities: %v", err)
} else {
logger.Infof("Successfully reset all index complexities to 0")
}
}
}
for _, source := range settings.Config.Server.SourceMap {
go indexing.Initialize(source, false)
go indexing.Initialize(source, false, isNewDb)
}
validateUserInfo(!dbExists)
validateOfficeIntegration()
@@ -184,7 +181,19 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
cacheDir := settings.Config.Server.CacheDir
numWorkers := settings.Config.Server.NumImageProcessors
ffmpeg.SetFFmpegPaths()
// Initialize asset filesystem before starting services
if settings.Env.EmbeddedFs {
embeddedAssets := fbhttp.GetEmbeddedAssets()
subAssets, err := fs.Sub(embeddedAssets, "embed")
if err != nil {
logger.Fatalf("Failed to create sub filesystem: %v", err)
}
fileutils.InitAssetFS(subAssets, true)
} else {
fileutils.InitAssetFS(nil, false)
}
// setup disk cache
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
if err != nil {
+26 -19
View File
@@ -278,6 +278,8 @@ func setupFrontend(generate bool) {
}
// Load custom favicon if configured
loadCustomFavicon()
// Generate PWA icons from custom favicon if needed
generatePWAIcons()
}
func setupMedia() {
@@ -726,7 +728,7 @@ func setDefaults(generate bool) Settings {
}
// validateCustomImage validates a custom image file path and returns the absolute path or error
func validateCustomImage(configPath, imageName string, maxSize int64, allowedFormats []string) (absolutePath string, err error) {
func validateCustomImage(configPath, imageName string, allowedFormats []string) (absolutePath string, err error) {
// Get absolute path
absolutePath, err = filepath.Abs(configPath)
if err != nil {
@@ -734,16 +736,11 @@ func validateCustomImage(configPath, imageName string, maxSize int64, allowedFor
}
// Check if file exists
stat, err := os.Stat(absolutePath)
_, err = os.Stat(absolutePath)
if err != nil {
return "", fmt.Errorf("could not access file: %w", err)
}
// Check file size
if stat.Size() > maxSize {
return "", fmt.Errorf("file too large (%d bytes), maximum allowed is %d bytes", stat.Size(), maxSize)
}
// Validate file format
ext := strings.ToLower(filepath.Ext(absolutePath))
validFormat := false
@@ -762,8 +759,7 @@ func validateCustomImage(configPath, imageName string, maxSize int64, allowedFor
func loadCustomFavicon() {
const imageName = "favicon"
const maxSize = 1024 * 1024 // 1MB
allowedFormats := []string{".ico", ".png", ".svg"}
allowedFormats := []string{".ico", ".png", ".svg", ".jpg", ".jpeg", ".gif", ".webp"}
// Set default embedded favicon path
Env.FaviconEmbeddedPath = "img/icons/favicon.svg"
@@ -776,7 +772,7 @@ func loadCustomFavicon() {
}
// Validate custom favicon
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, maxSize, allowedFormats)
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, allowedFormats)
if err != nil {
logger.Warningf("Custom favicon validation failed: %v, using default", err)
Config.Frontend.Favicon = ""
@@ -792,18 +788,29 @@ func loadCustomFavicon() {
logger.Infof("Using custom favicon: %s", Env.FaviconPath)
}
// generatePWAIcons sets up PWA icon paths (actual generation happens after preview service starts)
func generatePWAIcons() {
// Set PWA icons directory in cache
Env.PWAIconsDir = filepath.Join(Config.Server.CacheDir, "icons")
// Initialize with default paths (all icons will be generated)
Env.PWAIcon192 = "icons/pwa-icon-192.png"
Env.PWAIcon256 = "icons/pwa-icon-256.png"
Env.PWAIcon512 = "icons/pwa-icon-512.png"
// Create icons directory with configured permissions
// Note: Parent cache directory should already exist from testCacheDirSpeed()
if err := os.MkdirAll(Env.PWAIconsDir, fileutils.PermDir); err != nil {
logger.Warningf("Failed to create PWA icons directory %s: %v", Env.PWAIconsDir, err)
}
}
func loadLoginIcon() {
const imageName = "login icon"
const maxSize = 1024 * 1024 // 1MB
allowedFormats := []string{".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"}
// Set default embedded icon path based on dark mode preference
isDarkMode := Config.UserDefaults.DarkMode != nil && *Config.UserDefaults.DarkMode
if isDarkMode {
Env.LoginIconEmbeddedPath = "img/icons/favicon.svg" // Dark mode: dark background
} else {
Env.LoginIconEmbeddedPath = "img/icons/favicon-light.svg" // Light mode: light background
}
// Set default embedded icon path - just use favicon.svg (light/dark handled by CSS)
Env.LoginIconEmbeddedPath = "img/icons/favicon.svg"
// Check if a custom login icon path is configured
if Config.Frontend.LoginIcon == "" {
@@ -813,7 +820,7 @@ func loadLoginIcon() {
}
// Validate custom login icon
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, maxSize, allowedFormats)
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, allowedFormats)
if err != nil {
logger.Warningf("Custom login icon validation failed: %v, using default", err)
Env.LoginIconPath = Env.LoginIconEmbeddedPath
+1
View File
@@ -710,6 +710,7 @@ func GenerateYaml() {
}
setupLogging()
setupFs()
setupAuth(true)
// Save original paths before setupSources modifies them (for YAML generation)
+8 -3
View File
@@ -36,6 +36,10 @@ type Environment struct {
FaviconPath string `json:"-"` // resolved favicon path (filesystem or embedded)
FaviconIsCustom bool `json:"-"` // true if favicon is from custom filesystem path
FaviconEmbeddedPath string `json:"-"` // embedded asset path for default favicon
PWAIconsDir string `json:"-"` // directory where generated PWA icons are stored
PWAIcon192 string `json:"-"` // path to 192x192 PWA icon
PWAIcon256 string `json:"-"` // path to 256x256 PWA icon
PWAIcon512 string `json:"-"` // path to 512x512 PWA icon
}
type Server struct {
@@ -265,9 +269,10 @@ type Frontend struct {
ExternalLinks []ExternalLink `json:"externalLinks"`
DisableNavButtons bool `json:"disableNavButtons"` // disable the nav buttons in the sidebar
Styling StylingConfig `json:"styling"`
Favicon string `json:"favicon"` // path to a favicon to use for the frontend
Description string `json:"description"` // description that shows up in html head meta description
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
Favicon string `json:"favicon"` // path to a favicon to use for the frontend
Description string `json:"description"` // description that shows up in html head meta description
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
OIDCLoginButtonText string `json:"oidcLoginButtonText"` // text to display on the OIDC login button
}
type StylingConfig struct {
+42 -125
View File
@@ -1,148 +1,65 @@
package utils
import (
"io"
"os"
"unicode"
"unicode/utf8"
)
// IsTextFile checks if a file is likely a text file by examining its content.
// It returns true if the file appears to be a valid text file, false otherwise.
// This function performs various heuristics to detect text files:
// IsTextFile checks if a file is viewable/editable as text (like with cat or a text editor).
// It returns true if the file appears to be text, false if it's likely binary.
// This function uses simple heuristics:
// - Valid UTF-8 encoding
// - Limited null bytes
// - Limited non-printable characters
// - Not full of null bytes (which indicate binary data)
func IsTextFile(realPath string) (bool, error) {
const headerSize = 4096
// Thresholds for detecting binary-like content (these can be tuned)
const maxNullBytesInHeaderAbs = 10 // Max absolute null bytes in header
const maxNullByteRatioInHeader = 0.1 // Max 10% null bytes in header
const maxNullByteRatioInFile = 0.05 // Max 5% null bytes in the entire file
const maxNonPrintableRuneRatio = 0.05 // Max 5% non-printable runes in the entire file
// Open file
f, err := os.Open(realPath)
if err != nil {
return false, err
}
defer f.Close()
// Read header
headerBytes := make([]byte, headerSize)
n, err := f.Read(headerBytes)
if err != nil && err != io.EOF {
return false, err
}
actualHeader := headerBytes[:n]
// --- Start of 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.
// Use trimmed header to avoid false negatives from truncated sequences
if len(trimmedHeader) > 0 && !utf8.Valid(trimmedHeader) {
return false, nil // Not an error, just not the text file we want
}
// 2. Check for excessive null bytes in the header
nullCountInHeader := 0
for _, b := range actualHeader {
if b == 0x00 {
nullCountInHeader++
}
}
// Reject if too many nulls absolutely or relatively in the header
if nullCountInHeader > 0 { // Only perform check if there are any nulls
if nullCountInHeader > maxNullBytesInHeaderAbs ||
(float64(nullCountInHeader)/float64(n) > maxNullByteRatioInHeader) {
return false, nil // Too many nulls in header
}
}
// 3. Check for other non-text ASCII control characters in the header
// (C0 controls excluding \t, \n, \r)
for _, b := range actualHeader {
if b < 0x20 && b != '\t' && b != '\n' && b != '\r' {
return false, nil // Found problematic control character
}
}
}
// Now read the full file (original logic)
const sampleSize = 8192 // Read first 8KB to check
const maxNullByteRatio = 0.05 // Reject if more than 5% null bytes
// Read sample from file
content, err := os.ReadFile(realPath)
if err != nil {
return false, err
}
// Handle empty file (empty files are considered text)
// Empty files are considered text
if len(content) == 0 {
return true, nil
}
// Use sample for large files to avoid reading entire file into memory
sample := content
if len(content) > sampleSize {
sample = content[:sampleSize]
}
// Check 1: Count null bytes - binary files typically have many nulls
nullCount := 0
for _, b := range sample {
if b == 0x00 {
nullCount++
}
}
stringContent := string(content)
if nullCount > 0 {
nullRatio := float64(nullCount) / float64(len(sample))
if nullRatio > maxNullByteRatio {
return false, nil
}
}
// 4. Final UTF-8 validation for the entire file
// (This is crucial as the header might be fine, but the rest of the file isn't)
if !utf8.ValidString(stringContent) {
// Check 2: Validate UTF-8 encoding
// Trim sample to last complete UTF-8 rune to avoid false negatives
trimmedSample := sample
for len(trimmedSample) > 0 {
lastRune, size := utf8.DecodeLastRune(trimmedSample)
if lastRune != utf8.RuneError {
break
}
if size == 1 {
trimmedSample = trimmedSample[:len(trimmedSample)-1]
} else {
break
}
}
if len(trimmedSample) > 0 && !utf8.Valid(trimmedSample) {
return false, nil
}
// 5. Check for excessive null bytes in the entire file content
if len(content) > 0 { // Check only for non-empty files
totalNullCount := 0
for _, b := range content {
if b == 0x00 {
totalNullCount++
}
}
if float64(totalNullCount)/float64(len(content)) > maxNullByteRatioInFile {
return false, nil // Too many nulls in the entire file
}
}
// 6. Check for excessive non-printable runes in the entire file content
// (Excluding tab, newline, carriage return, which are common in text files)
if len(stringContent) > 0 { // Check only for non-empty strings
nonPrintableRuneCount := 0
totalRuneCount := 0
for _, r := range stringContent {
totalRuneCount++
// unicode.IsPrint includes letters, numbers, punctuation, symbols, and spaces.
// It excludes control characters. We explicitly allow \t, \n, \r.
if !unicode.IsPrint(r) && r != '\t' && r != '\n' && r != '\r' {
nonPrintableRuneCount++
}
}
if totalRuneCount > 0 { // Avoid division by zero
if float64(nonPrintableRuneCount)/float64(totalRuneCount) > maxNonPrintableRuneRatio {
return false, nil // Too many non-printable runes
}
}
}
// The file has passed all checks and is considered editable text.
return true, nil
}
+22 -4
View File
@@ -4,9 +4,11 @@ import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -61,9 +63,24 @@ func createIndexDB(name string, journalMode string, lockingMode string, batchSiz
// batchSize: number of items per batch transaction
// cacheSizeMB: cache size in megabytes
// disableReuse: true to delete and recreate DB on startup, false to reuse existing (default)
// Returns the IndexDB and a boolean indicating if the database was recreated (true) or reused (false)
// Returns the IndexDB and a boolean indicating if the database is new (true) or reused (false)
func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int, disableReuse bool) (*IndexDB, bool, error) {
wasRecreated := disableReuse // If disableReuse is true, we're creating fresh
isNewDb := disableReuse // If disableReuse is true, we're creating fresh
// Check if database file exists (only if reuse is enabled)
if !disableReuse {
// Database files are stored in sql subdirectory (see sqlite.go:207)
dbPath := filepath.Join(settings.Config.Server.CacheDir, "sql", fmt.Sprintf("index_%s.db", name))
_, err := os.Stat(dbPath)
if os.IsNotExist(err) {
// Database file doesn't exist, so this is a new database
isNewDb = true
} else if err != nil {
// Some other error occurred
logger.Warningf("Error checking database file: %v", err)
isNewDb = true
}
}
idxDB, err := createIndexDB(name, journalMode, "", batchSize, cacheSizeMB, disableReuse)
if err != nil {
@@ -75,7 +92,8 @@ func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int,
if !disableReuse {
if err := idxDB.checkIntegrityAndRecreateIfNeeded(); err != nil {
// Database was recreated due to corruption
wasRecreated = true
logger.Warningf("[DB_INIT] SQLite database was found with issues which could be due to a dirty shutdown, recreating: tmp/index_%s.db", name)
isNewDb = true
idxDB.Close()
// Recreate the database connection
@@ -91,7 +109,7 @@ func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int,
return nil, false, err
}
go idxDB.startPeriodicCleanup()
return idxDB, wasRecreated, nil
return idxDB, isNewDb, nil
}
// checkIntegrityAndRecreateIfNeeded checks database integrity.
+4 -4
View File
@@ -5,7 +5,7 @@ go 1.25.0
require (
github.com/asdine/storm/v3 v3.2.1
github.com/coreos/go-oidc/v3 v3.17.0
github.com/coreos/go-systemd/v22 v22.6.0
github.com/coreos/go-systemd/v22 v22.7.0
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/gen2brain/go-fitz v1.24.15
@@ -104,7 +104,7 @@ require (
github.com/firefart/nonamedreturns v1.0.6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/ghostiam/protogetter v0.3.18 // indirect
github.com/go-critic/go-critic v0.14.3 // indirect
github.com/go-errors/errors v1.5.1 // indirect
@@ -134,7 +134,7 @@ require (
github.com/godoc-lint/godoc-lint v0.11.1 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gohugoio/hugo v0.149.1 // indirect
github.com/golang/geo v0.0.0-20260120070133-792bb8583fbb // indirect
github.com/golang/geo v0.0.0-20260129164528-943061e2742c // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
@@ -278,7 +278,7 @@ require (
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.6.1 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/libc v1.67.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
+8 -8
View File
@@ -199,8 +199,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -265,8 +265,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gen2brain/go-fitz v1.24.15 h1:sJNB1MOWkqnzzENPHggFpgxTwW0+S5WF/rM5wUBpJWo=
github.com/gen2brain/go-fitz v1.24.15/go.mod h1:SftkiVbTHqF141DuiLwBBM65zP7ig6AVDQpf2WlHamo=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
@@ -391,8 +391,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20260120070133-792bb8583fbb h1:OhyJ/wXEqRssvvFcXP8Wzoyn0fbiqWgjHXLKj3ZOU+4=
github.com/golang/geo v0.0.0-20260120070133-792bb8583fbb/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/geo v0.0.0-20260129164528-943061e2742c h1:ysO2h2Odnl1AJM1I2Lm/fa6JvO0pECMSt2CwBaa+ITo=
github.com/golang/geo v0.0.0-20260129164528-943061e2742c/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -1286,8 +1286,8 @@ modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI=
modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+2 -5
View File
@@ -394,13 +394,10 @@ func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions, stats
for _, fileInfo := range checksumGroup.Files {
// Remove the user scope from path
adjustedPath := strings.TrimPrefix(fileInfo.Path, opts.combinedPath)
if adjustedPath == "" {
adjustedPath = "/"
}
adjustedPath := "/" + strings.TrimPrefix(fileInfo.Path, opts.combinedPath)
resultGroup = append(resultGroup, &indexing.SearchResult{
Path: adjustedPath,
Source: opts.source,
Type: fileInfo.Type,
Size: fileInfo.Size,
Modified: fileInfo.ModTime.Format(time.RFC3339),
+4 -23
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
@@ -131,24 +130,15 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusOK, nil
}
encodedPath := r.URL.Query().Get("path")
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
linesStr := r.URL.Query().Get("lines")
// Decode URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
var err error
// Parse lines parameter
lines := 10 // default
if linesStr != "" {
@@ -282,21 +272,12 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return http.StatusForbidden, fmt.Errorf("user is not allowed to read file content")
}
encodedPath := r.URL.Query().Get("path")
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
linesStr := r.URL.Query().Get("lines")
intervalStr := r.URL.Query().Get("interval")
// Decode URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
var err error
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
+9 -27
View File
@@ -15,6 +15,7 @@ import (
_ "net/http/pprof"
"github.com/coreos/go-systemd/v22/activation"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/events"
@@ -27,14 +28,9 @@ import (
//go:embed embed/*
var assets embed.FS
// Custom dirFS to handle both embedded and non-embedded file systems
type dirFS struct {
http.Dir
}
// Implement the Open method for dirFS, which wraps http.Dir
func (d dirFS) Open(name string) (fs.File, error) {
return d.Dir.Open(name)
// GetEmbeddedAssets returns the embedded assets filesystem
func GetEmbeddedAssets() embed.FS {
return assets
}
var (
@@ -56,20 +52,10 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
}()
}
// Determine filesystem mode and set asset paths
if settings.Env.EmbeddedFs {
// Embedded mode: Serve files from the embedded assets
assetFs, err = fs.Sub(assets, "embed")
if err != nil {
logger.Fatalf("fs.Sub failed: %v", err)
}
entries, err := fs.ReadDir(assetFs, ".")
if err != nil || len(entries) == 0 {
logger.Fatalf("Could not embed frontend. Does dist exist? %v", err)
}
} else {
// Dev mode: Serve files from http/dist directory
assetFs = dirFS{Dir: http.Dir("http/dist")}
// Get the asset filesystem from fileutils
assetFs = fileutils.GetAssetFS()
if assetFs == nil {
logger.Fatal("Asset filesystem not initialized. Call fileutils.InitAssetFS first.")
}
// In development mode, we want to reload the templates on each request.
@@ -209,14 +195,10 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
publicRoutes.HandleFunc("GET /share/", withOrWithoutUser(indexHandler))
// Static and index file handlers
staticPrefix := config.Server.BaseURL + "static/"
router.Handle(staticPrefix, http.HandlerFunc(staticAssetHandler))
publicRoutes.Handle("GET /static/", http.HandlerFunc(staticAssetHandler))
// Standard browser favicon and manifest routes
// Standard browser favicon route
router.HandleFunc("GET /favicon.svg", http.HandlerFunc(staticAssetHandler))
router.HandleFunc("GET /site.webmanifest", http.HandlerFunc(staticAssetHandler))
router.HandleFunc("GET /manifest.json", http.HandlerFunc(staticAssetHandler))
router.HandleFunc(config.Server.BaseURL, withOrWithoutUser(indexHandler))
router.HandleFunc(fmt.Sprintf("GET %vhealth", config.Server.BaseURL), healthHandler)
+1 -11
View File
@@ -4,7 +4,6 @@ import (
"bytes"
"fmt"
"net/http"
"net/url"
"path/filepath"
"strconv"
"time"
@@ -29,7 +28,7 @@ import (
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/media/subtitles [get]
func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
encodedPath := r.URL.Query().Get("path")
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
indexParam := r.URL.Query().Get("index")
@@ -37,15 +36,6 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
indexParam = "0" // default to first subtitle stream
}
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
userscope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
+2 -2
View File
@@ -96,7 +96,6 @@ func withHashFileHelper(fn handleFunc) handleFunc {
if source.Config.Private {
return http.StatusForbidden, fmt.Errorf("the target source is private")
}
// Get file information with options
getContent := r.URL.Query().Get("content") == "true"
getMetadata := r.URL.Query().Get("metadata") == "true"
@@ -125,6 +124,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
Expand: true,
Content: getContent,
Metadata: getMetadata,
AlbumArt: strings.Contains(r.URL.Path, "/preview"),
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
ShowHidden: link.ShowHidden,
FollowSymlinks: true,
@@ -134,7 +134,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
return errToStatus(err), fmt.Errorf("error fetching share from server")
}
file.Token = link.Token
file.Source = ""
file.Source = link.Hash
file.Hash = link.Hash
if !link.EnableOnlyOffice || link.DisableFileViewer || reachedDownloadsLimit {
file.OnlyOfficeId = ""
+4 -22
View File
@@ -163,11 +163,8 @@ func publicUploadHandler(w http.ResponseWriter, r *http.Request, d *requestConte
if !d.share.AllowReplacements && r.URL.Query().Get("action") == "override" {
return http.StatusForbidden, fmt.Errorf("cannot overwrite files for this share")
}
path, err := url.QueryUnescape(r.URL.Query().Get("targetPath"))
if err != nil {
logger.Debugf("invalid path encoding: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
// Go automatically decodes query params
path := r.URL.Query().Get("targetPath")
fullPath := filepath.Join(d.share.Path, path)
source := config.Server.SourceMap[d.share.Source].Name
// adjust query params to match resourcePostHandler
@@ -223,8 +220,6 @@ func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestCont
if d.share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled for upload shares")
}
d.fileInfo.Source = d.share.Source
d.fileInfo.Path = utils.JoinPathAsUnix(d.share.Path, r.URL.Query().Get("path"))
status, err := previewHelperFunc(w, r, d)
if err != nil {
logger.Errorf("public preview handler: error getting preview with error %v", err)
@@ -262,13 +257,8 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if !d.share.AllowModify {
return http.StatusForbidden, fmt.Errorf("edit permission not allowed for this share")
}
// Go automatically decodes query params
path := r.URL.Query().Get("path")
// Decode the URL-encoded path
path, err = url.QueryUnescape(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
resolvedPath := utils.JoinPathAsUnix(d.share.Path, path)
err = files.WriteFile(source, resolvedPath, r.Body)
// hide the error
@@ -283,15 +273,7 @@ func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestConte
if !d.share.AllowDelete {
return http.StatusForbidden, fmt.Errorf("delete is not allowed for this share")
}
// TODO source := r.URL.Query().Get("source")
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
path := r.URL.Query().Get("path")
indexPath := utils.JoinPathAsUnix(d.share.Path, path)
source, err := d.share.GetSourceName()
if err != nil {
+7 -54
View File
@@ -8,7 +8,6 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
@@ -71,17 +70,8 @@ func validateMoveOperation(src, dst string, isSrcDir bool) error {
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources [get]
func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
encodedPath := r.URL.Query().Get("path")
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
getContent := r.URL.Query().Get("content") == "true"
getMetadata := r.URL.Query().Get("metadata") == "true"
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
@@ -136,20 +126,9 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
return http.StatusForbidden, fmt.Errorf("user is not allowed to delete")
}
// TODO source := r.URL.Query().Get("source")
encodedPath := r.URL.Query().Get("path")
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
if path == "/" {
return http.StatusForbidden, fmt.Errorf("cannot delete your user's root directory")
}
@@ -387,7 +366,6 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
// @Router /api/resources [post]
func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
unescapedPath := path
source := r.URL.Query().Get("source")
var err error
accessStore := store.Access
@@ -395,27 +373,16 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
if d.share != nil {
accessStore = nil
} else {
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
logger.Debugf("invalid source encoding: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
unescapedPath, err = url.QueryUnescape(path)
if err != nil {
logger.Debugf("invalid path encoding: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
// Go automatically decodes query params - no need for QueryUnescape
if !d.user.Permissions.Create {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
// Path is now handled by FileInfoFaster which will apply user scope
path = unescapedPath
}
// Determine if this is a directory based on isDir query param or trailing slash (for backwards compatibility)
isDirParam := r.URL.Query().Get("isDir")
isDir := isDirParam == "true" || strings.HasSuffix(unescapedPath, "/")
isDir := isDirParam == "true" || strings.HasSuffix(path, "/")
fileOpts := utils.FileOptions{
Path: path,
Source: source,
@@ -609,20 +576,8 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// @Router /api/resources [put]
func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
source := r.URL.Query().Get("source")
var err error
// decode url encoded source name
source, err = url.QueryUnescape(source)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
}
path := r.URL.Query().Get("path")
encodedPath := r.URL.Query().Get("path")
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
// Only allow PUT for files.
if strings.HasSuffix(path, "/") {
return http.StatusMethodNotAllowed, nil
@@ -970,10 +925,8 @@ func patchAction(ctx context.Context, params patchActionParams) error {
}
func inspectIndex(w http.ResponseWriter, r *http.Request) {
encodedPath := r.URL.Query().Get("path")
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
// Decode the URL-encoded path
path, _ := url.QueryUnescape(encodedPath)
isNotDir := r.URL.Query().Get("isDir") == "false" // default to isDir true
index := indexing.GetIndex(source)
if index == nil {
+2 -7
View File
@@ -3,7 +3,6 @@ package http
import (
"fmt"
"net/http"
"net/url"
"path/filepath"
"strings"
@@ -121,7 +120,7 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
query := r.URL.Query().Get("query")
sourcesParam := r.URL.Query().Get("sources")
sourceParam := r.URL.Query().Get("source") // deprecated, but still supported
scope := r.URL.Query().Get("scope")
scope := r.URL.Query().Get("scope") // Go automatically decodes query params
largest := r.URL.Query().Get("largest") == "true"
var sources []string
@@ -141,14 +140,10 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
}
}
unencodedScope, err := url.PathUnescape(scope)
if err != nil {
return nil, fmt.Errorf("invalid path encoding: %v", err)
}
if len(query) < settings.Config.Server.MinSearchLength && !largest {
return nil, fmt.Errorf("query is too short, minimum length is %d", settings.Config.Server.MinSearchLength)
}
searchScope := strings.TrimPrefix(unencodedScope, ".")
searchScope := strings.TrimPrefix(scope, ".")
// If multiple sources, always use user scope (ignore searchScope)
if len(sources) > 1 {
+10 -8
View File
@@ -420,23 +420,17 @@ type DirectDownloadResponse struct {
// @Router /api/share/direct [get]
func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// Extract query parameters
encodedPath := r.URL.Query().Get("path")
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
duration := r.URL.Query().Get("duration")
downloadCountStr := r.URL.Query().Get("count")
downloadSpeedStr := r.URL.Query().Get("speed")
// Validate required parameters
if encodedPath == "" || source == "" {
if path == "" || source == "" {
return http.StatusBadRequest, fmt.Errorf("path and source are required")
}
// Decode the URL-encoded path
path, err := url.QueryUnescape(encodedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
// Validate source exists
sourceInfo, ok := config.Server.NameToSource[source]
if !ok {
@@ -626,6 +620,14 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
commonShare.Source = ""
commonShare.Path = ""
commonShare.SidebarLinks = []users.SidebarLink{}
for _, link := range shareLink.SidebarLinks {
if link.Category == "download" && shareLink.ShareType == "upload" {
continue
} else {
commonShare.SidebarLinks = append(commonShare.SidebarLinks, link)
}
}
return renderJSON(w, r, commonShare)
}
+49 -13
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"text/template"
@@ -73,7 +74,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
staticURL := config.Server.BaseURL + "public/static"
description := config.Frontend.Description
title := config.Frontend.Name
banner := staticURL + "/img/icons/android-chrome-512x512.png"
banner := staticURL + "/pwa-icon-512.png" // Use largest generated icon for best quality
disableSidebar := false
// Use custom favicon if configured and validated, otherwise fall back to default
@@ -127,18 +128,17 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
// Load loading spinners CSS from static files
loadingSpinnersCSS := ""
cssPath := "static/css/loadingSpinners.css"
cssPath := "css/loadingSpinners.css"
cssContent, err := fs.ReadFile(assetFs, cssPath)
if err == nil {
loadingSpinnersCSS = string(cssContent)
}
// Determine OpenGraph image: use banner if set, otherwise use dedicated OG image or fallback to app icon
// Determine OpenGraph image: use banner if set, otherwise use largest available icon (512x512)
ogImage := banner
if banner == staticURL+"/img/icons/android-chrome-512x512.png" {
// No custom banner set - check if dedicated OG image exists, otherwise use 512x512 icon
// Note: 512x512 is square; OpenGraph prefers 1200x630 (1.91:1 ratio) for optimal display
ogImage = staticURL + "/img/icons/android-chrome-512x512.png"
if banner == staticURL+"/pwa-icon-512.png" {
// Note: 512x512 is square; OpenGraph prefers 1200x630 (1.91:1 ratio) but square works fine
ogImage = staticURL + "/pwa-icon-512.png"
}
// Construct the full URL for the current request
@@ -155,6 +155,23 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
fullURL = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.URL.Path)
}
// Determine PWA icon URLs based on custom favicon settings
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
// If custom favicon is set and it's SVG, use the favicon directly
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
pwaIcon192 = favicon
pwaIcon256 = favicon
pwaIcon512 = favicon
} else if settings.Env.FaviconIsCustom {
// For custom PNG/ICO favicons, use generated PWA icons
pwaIcon192 = staticURL + "/pwa-icon-192.png"
pwaIcon256 = staticURL + "/pwa-icon-256.png"
pwaIcon512 = staticURL + "/pwa-icon-512.png"
}
data["htmlVars"] = map[string]interface{}{
"title": title,
"customCSS": config.Frontend.Styling.CustomCSSRaw,
@@ -166,13 +183,16 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"favicon": favicon,
"loginIcon": loginIcon,
"color": defaultThemeColor,
"winIcon": staticURL + "/img/icons/mstile-144x144.png",
"appIcon": staticURL + "/img/icons/android-chrome-512x512.png",
"winIcon": staticURL + "/mstile-256x256.png",
"appIcon": staticURL + "/apple-touch-icon.png",
"description": description,
"loadingSpinnersCSS": loadingSpinnersCSS,
"banner": banner,
"image": ogImage,
"url": fullURL,
"pwaIcon192": pwaIcon192,
"pwaIcon256": pwaIcon256,
"pwaIcon512": pwaIcon512,
}
// variables consumed by frontend as json
data["globalVars"] = map[string]interface{}{
@@ -202,6 +222,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"loginIcon": loginIcon,
"disableSidebar": disableSidebar,
"shareHash": shareHash,
"oidcLoginButtonText": config.Frontend.OIDCLoginButtonText,
}
// Marshal each variable to JSON strings for direct template usage
@@ -272,10 +293,25 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
}
// Use embedded default
assetPath = settings.Env.FaviconEmbeddedPath
case "manifest.json":
assetPath = "img/icons/manifest.json"
case "site.webmanifest":
assetPath = "img/icons/site.webmanifest"
case "pwa-icon.svg":
// Serve custom SVG favicon for PWA if configured
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
http.ServeFile(w, r, settings.Env.FaviconPath)
return
}
// Fall back to default favicon SVG
assetPath = settings.Env.FaviconEmbeddedPath
case "favicon-16x16.png", "favicon-32x32.png",
"pwa-icon-192.png", "pwa-icon-256.png", "pwa-icon-512.png",
"apple-touch-icon.png", "mstile-256x256.png":
// Serve generated icons from cache directory
iconPath := filepath.Join(settings.Env.PWAIconsDir, path)
if _, err := os.Stat(iconPath); err == nil {
http.ServeFile(w, r, iconPath)
return
}
// Fall back to embedded favicon.png if generation failed
assetPath = "img/icons/favicon.png"
case "loginIcon":
// Handle custom login icon from filesystem
if settings.Env.LoginIconIsCustom {
+27 -3
View File
@@ -21,8 +21,18 @@ import (
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/otp/generate [post]
func generateOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
logger.Debug("Generating OTP for user:", d.user.Username)
url, err := auth.GenerateOtpForUser(d.user, store.Users)
username := r.URL.Query().Get("username")
if username == "" {
username = d.user.Username
}
if username != d.user.Username && !d.user.Permissions.Admin {
return http.StatusForbidden, fmt.Errorf("you are not authorized to generate OTP for this user")
}
targetUser, err := store.Users.Get(username)
if err != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", err)
}
url, err := auth.GenerateOtpForUser(targetUser, store.Users)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error generating OTP secret: %w", err)
}
@@ -51,7 +61,21 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if code == "" {
return http.StatusUnauthorized, fmt.Errorf("code is required")
}
err := auth.VerifyTotpCode(d.user, code, store.Users)
targetUser := d.user
username := r.URL.Query().Get("username")
// If admin is verifying for another user
if username != "" && username != d.user.Username && d.user.Permissions.Admin {
var err error
targetUser, err = store.Users.Get(username)
if err != nil {
return http.StatusNotFound, fmt.Errorf("user not found: %w", err)
}
logger.Debugf("Admin %s verifying OTP for user: %s", d.user.Username, targetUser.Username)
}
err := auth.VerifyTotpCode(targetUser, code, store.Users)
if err != nil {
return http.StatusUnauthorized, fmt.Errorf("invalid OTP token")
}
+78
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"sort"
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
@@ -91,6 +92,83 @@ func prepForFrontend(u *users.User) {
u.TOTPNonce = ""
u.Scopes = u.GetFrontendScopes()
u.SidebarLinks = u.GetFrontendSidebarLinks()
u.Locale = normalizeLocale(u.Locale)
}
// normalizeLocale converts various locale formats (xx_xx, xx-xx, xxxx) to camelCase format (xxXX)
// Frontend expects camelCase for compound locales (zhCN, ptBR, etc.) as shown in select option values
func normalizeLocale(locale string) string {
if locale == "" {
return locale
}
// Convert to lowercase for processing
lower := strings.ToLower(locale)
// Special case mappings (standard locale codes to frontend camelCase format)
specialCases := map[string]string{
"cs": "cz", // Czech
"uk": "ua", // Ukrainian
"zh-cn": "zhCN",
"zh_cn": "zhCN",
"zhcn": "zhCN",
"zh-tw": "zhTW",
"zh_tw": "zhTW",
"zhtw": "zhTW",
"pt-br": "ptBR",
"pt_br": "ptBR",
"ptbr": "ptBR",
"sv-se": "svSE",
"sv_se": "svSE",
"svse": "svSE",
"nl-be": "nlBE",
"nl_be": "nlBE",
"nlbe": "nlBE",
}
// Check special cases first
if normalized, ok := specialCases[lower]; ok {
return normalized
}
// If already in camelCase format (4+ chars, has uppercase), return as-is
if len(locale) >= 4 {
// Check if it's a known camelCase locale
knownCamelCase := map[string]bool{
"zhCN": true, "zhTW": true, "ptBR": true, "svSE": true, "nlBE": true,
}
if knownCamelCase[locale] {
return locale
}
}
// Handle xx_xx or xx-xx format: convert to xxXX (camelCase)
parts := strings.FieldsFunc(lower, func(r rune) bool {
return r == '_' || r == '-'
})
if len(parts) == 2 {
// Convert to camelCase: first part lowercase, second part capitalized
first := parts[0]
second := parts[1]
if len(second) > 0 {
second = strings.ToUpper(second[:1]) + second[1:]
}
normalized := first + second
// Check if this matches a known compound locale
knownCompound := map[string]string{
"zhcn": "zhCN", "zhtw": "zhTW", "ptbr": "ptBR",
"svse": "svSE", "nlbe": "nlBE",
}
if normalizedVal, ok := knownCompound[normalized]; ok {
return normalizedVal
}
return normalized
}
// Single part locale (en, fr, de, etc.) - return as-is (lowercase is fine)
return lower
}
// userDeleteHandler deletes a user by ID.
+25 -10
View File
@@ -331,18 +331,14 @@ func InitializeIndexDB() (bool, error) {
batchSize := settings.Config.Server.IndexSqlConfig.BatchSize
cacheSizeMB := settings.Config.Server.IndexSqlConfig.CacheSizeMB
disableReuse := settings.Config.Server.IndexSqlConfig.DisableReuse
var wasRecreated bool
indexDB, wasRecreated, err = dbsql.NewIndexDB("all", journalMode, batchSize, cacheSizeMB, disableReuse)
var isNewDb bool
indexDB, isNewDb, err = dbsql.NewIndexDB("all", journalMode, batchSize, cacheSizeMB, disableReuse)
if err != nil {
logger.Fatalf("failed to initialize index database: %v", err)
return false, err
}
if wasRecreated {
logger.Warningf("Index database was recreated, all index complexities will be reset to 0")
}
return wasRecreated, nil
return isNewDb, nil
}
// GetIndexDB returns the shared index database.
@@ -374,7 +370,7 @@ func SetIndexingStorage(storage *dbindex.Storage) {
indexingStorage = storage
}
func Initialize(source *settings.Source, mock bool) {
func Initialize(source *settings.Source, mock bool, isNewDb bool) {
indexesMutex.Lock()
// Use shared database - all sources are differentiated by the source column
if indexDB == nil {
@@ -403,7 +399,8 @@ func Initialize(source *settings.Source, mock bool) {
if !newIndex.Config.DisableIndexing {
logger.Infof("initializing index: [%v]", newIndex.Name)
// Start multi-scanner system (each scanner will do its own initial scan)
go newIndex.setupIndexingScanners()
// Pass isNewDb flag so setupMultiScanner knows whether to load persisted complexity
go newIndex.setupMultiScanner(isNewDb)
} else {
newIndex.Status = "ready"
logger.Debug("indexing disabled for source: " + newIndex.Name)
@@ -1295,11 +1292,19 @@ func setFilePreviewFlags(fileInfo *iteminfo.ItemInfo, realPath string) {
simpleType := strings.Split(fileInfo.Type, "/")[0]
switch fileInfo.Type {
case "image/heic", "image/heif":
if fileInfo.Size > iteminfo.LargeFileSizeThreshold {
fileInfo.HasPreview = false
return
}
fileInfo.HasPreview = settings.CanConvertImage("heic")
return
}
switch simpleType {
case "image":
if fileInfo.Size > iteminfo.LargeFileSizeThreshold {
fileInfo.HasPreview = false
return
}
fileInfo.HasPreview = true
return
case "video":
@@ -1649,7 +1654,17 @@ func (idx *Index) Save() error {
Scanners: scanners,
}
return indexingStorage.Save(info)
err := indexingStorage.Save(info)
if err != nil {
return err
}
// Send SSE update event after successful save
if err := idx.SendSourceUpdateEvent(); err != nil {
logger.Errorf("[%s] Failed to send source update event: %v", idx.Name, err)
}
return nil
}
// Load restores index and scanner information from the database
+2 -1
View File
@@ -395,7 +395,7 @@ func (s *Scanner) checkForNewChildDirectories() {
_, exists := existingScanners[dirPath]
if !exists && dirPath != "/" {
logger.Debugf("Detected new directory, creating scanner: [%s]", dirPath)
newScanner := s.idx.createChildScanner(dirPath)
newScanner := s.idx.createScanner(dirPath)
s.idx.mu.Lock()
s.idx.scanners[dirPath] = newScanner
@@ -507,6 +507,7 @@ func (s *Scanner) updateSchedule() {
// 0: unknown
func (s *Scanner) updateComplexity() {
s.complexity = calculateComplexity(s.fullScanTime, s.numDirs)
// Set smartModifier based on complexity level
if modifier, ok := complexityModifier[s.complexity]; ok {
s.smartModifier = modifier
+8 -32
View File
@@ -179,16 +179,11 @@ func (idx *Index) PostScan() error {
logger.Errorf("Failed to shrink memory: %v", err)
}
// Persist index and scanner information
// Persist index and scanner information (SSE event will be sent by Save())
if err := idx.Save(); err != nil {
logger.Errorf("Failed to save index persistence: %v", err)
}
// Send update event to notify frontend that stats may have changed
if err := idx.SendSourceUpdateEvent(); err != nil {
logger.Errorf("Error sending source update event: %v", err)
}
// Clear scan session tracking when all scanners complete
idx.mu.Lock()
idx.scanSessionStartTime = 0
@@ -202,7 +197,6 @@ func (idx *Index) PostScan() error {
func (idx *Index) SendSourceUpdateEvent() error {
if idx.mock {
logger.Debug("Skipping source update event for mock index.")
return nil
}
reducedIndex, err := GetIndexInfo(idx.Name, true)
@@ -224,7 +218,8 @@ func (idx *Index) SendSourceUpdateEvent() error {
}
// setupMultiScanner creates and starts the multi-scanner system
func (idx *Index) setupMultiScanner() {
// isNewDb: if true, skip loading persisted complexity values (database is new or recreated)
func (idx *Index) setupMultiScanner(isNewDb bool) {
// Load persisted index and scanner information
if err := idx.Load(); err != nil {
logger.Errorf("Failed to load persisted index data for [%v]: %v", idx.Name, err)
@@ -246,7 +241,7 @@ func (idx *Index) setupMultiScanner() {
// Load persisted scanner info if available
var persistedScanners map[string]*indexingdb.PersistedScannerInfo
if indexingStorage != nil {
if indexingStorage != nil && !isNewDb {
info, err := indexingStorage.GetByPath(idx.Path)
if err == nil && info != nil {
persistedScanners = info.Scanners
@@ -254,8 +249,7 @@ func (idx *Index) setupMultiScanner() {
}
// Create and start root scanner
rootScanner := idx.createRootScanner()
// Restore persisted stats for root scanner if available
rootScanner := idx.createScanner("/")
if persistedScanners != nil {
if rootInfo, ok := persistedScanners["/"]; ok {
rootScanner.complexity = rootInfo.Complexity
@@ -277,9 +271,9 @@ func (idx *Index) setupMultiScanner() {
// Create child scanner for each top-level directory
for _, dirPath := range topLevelDirs {
childScanner := idx.createChildScanner(dirPath)
childScanner := idx.createScanner(dirPath)
// Restore persisted stats for child scanner if available
// Restore persisted stats for child scanner if available (and DB is not new)
if persistedScanners != nil {
if childInfo, ok := persistedScanners[dirPath]; ok {
childScanner.complexity = childInfo.Complexity
@@ -302,20 +296,8 @@ func (idx *Index) setupMultiScanner() {
logger.Debugf("Created %d scanners for [%v] (1 root + %d children)", len(topLevelDirs)+1, idx.Name, len(topLevelDirs))
}
// createRootScanner creates a scanner for the root directory (non-recursive)
func (idx *Index) createRootScanner() *Scanner {
return &Scanner{
scanPath: "/",
idx: idx,
stopChan: make(chan struct{}),
currentSchedule: 0,
fullScanCounter: 0,
complexity: 0, // 0 = unknown until first full scan completes
}
}
// createChildScanner creates a scanner for a specific child directory (recursive)
func (idx *Index) createChildScanner(dirPath string) *Scanner {
func (idx *Index) createScanner(dirPath string) *Scanner {
return &Scanner{
scanPath: dirPath,
idx: idx,
@@ -326,12 +308,6 @@ func (idx *Index) createChildScanner(dirPath string) *Scanner {
}
}
// Legacy function kept for backwards compatibility - now deprecated
func (idx *Index) setupIndexingScanners() {
// Use new multi-scanner system
idx.setupMultiScanner()
}
// GetScannerStatus returns detailed information about all active scanners
func (idx *Index) GetScannerStatus() map[string]interface{} {
idx.mu.RLock()
+4 -4
View File
@@ -13,7 +13,7 @@ func BenchmarkFillIndex(b *testing.B) {
Initialize(&settings.Source{
Name: "test",
Path: "/srv",
}, true)
}, true, false) // mock=true, isNewDb=false
idx := GetIndex("test")
if idx == nil {
b.Fatal("index is nil")
@@ -79,9 +79,9 @@ func TestMultiScannerMutex(t *testing.T) {
}
// Create multiple scanners
scanner1 := idx.createRootScanner()
scanner2 := idx.createChildScanner("/test1/")
scanner3 := idx.createChildScanner("/test2/")
scanner1 := idx.createScanner("/")
scanner2 := idx.createScanner("/test1/")
scanner3 := idx.createScanner("/test2/")
idx.scanners["/"] = scanner1
idx.scanners["/test1/"] = scanner2
+4
View File
@@ -9,6 +9,10 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
const (
LargeFileSizeThreshold = 75 * 1024 * 1024 // 75MB
)
var AllFiletypeOptions = []string{
"image",
"audio",
+2 -2
View File
@@ -49,7 +49,7 @@ func TestMain(m *testing.M) {
}
func BenchmarkSearchAllIndexes(b *testing.B) {
Initialize(&settings.Source{Name: "test", Path: "/srv"}, true)
Initialize(&settings.Source{Name: "test", Path: "/srv"}, true, false) // mock=true, isNewDb=false
idx := GetIndex("test")
idx.CreateMockData(50, 3) // 50 dirs, 3 files per dir
@@ -143,7 +143,7 @@ func TestSearchWhileIndexing(t *testing.T) {
IndexingDisabled: false,
}
Initialize(source, true)
Initialize(source, true, false) // mock=true, isNewDb=false
idx := GetIndex("test")
if idx == nil {
t.Fatal("Failed to get test index")
+5 -1
View File
@@ -6,9 +6,10 @@ import (
)
// bufferPool manages reusable buffers to reduce allocations
// Buffers start at 128KB (first retry level) and can grow up to 512KB
var bufferPool = sync.Pool{
New: func() interface{} {
// Create 128KB buffers by default
// Create 128KB buffers by default to match first retry level
return bytes.NewBuffer(make([]byte, 0, 128*1024))
},
}
@@ -27,6 +28,9 @@ func getBuffer() *bytes.Buffer {
// putBuffer returns a buffer to the pool
func putBuffer(buf *bytes.Buffer) {
// Don't pool buffers that grew too large (>1MB)
// This prevents memory bloat from images with large headers (256KB/512KB retries)
// Typical case: 128KB buffers are reused
// Edge case: 256KB/512KB buffers are discarded and GC'd
if buf.Cap() > 1024*1024 {
return
}
+74
View File
@@ -0,0 +1,74 @@
package preview
import (
"sync"
"testing"
"github.com/stretchr/testify/require"
)
// TestBufferPool_GetPutBalanced verifies that getBuffer/putBuffer can be used
// in a loop without leaking or panicking, and that buffers are returned to the pool.
func TestBufferPool_GetPutBalanced(t *testing.T) {
const iterations = 1000
for i := 0; i < iterations; i++ {
buf := getBuffer()
require.NotNil(t, buf)
require.NotNil(t, buf.Bytes())
putBuffer(buf)
}
}
// TestBufferPool_Reuse verifies that put buffers are reused (same capacity after get).
func TestBufferPool_Reuse(t *testing.T) {
buf1 := getBuffer()
cap1 := buf1.Cap()
putBuffer(buf1)
buf2 := getBuffer()
cap2 := buf2.Cap()
putBuffer(buf2)
require.Equal(t, cap1, cap2, "pool should return buffers with same capacity")
require.GreaterOrEqual(t, cap1, 128*1024, "default buffer should be at least 128KB")
}
// TestBufferPool_LargeBufferNotReused verifies that buffers that grew beyond 1MB
// are not put back in the pool (to avoid retaining large allocations).
func TestBufferPool_LargeBufferNotReused(t *testing.T) {
buf := getBuffer()
// Grow the buffer past the 1MB threshold
growth := make([]byte, 2*1024*1024)
_, _ = buf.Write(growth)
require.Greater(t, buf.Cap(), 1024*1024)
putBuffer(buf)
// After putBuffer, a large buffer is not pooled. Get several buffers and
// ensure we still get valid 128KB-capacity buffers (pool wasn't polluted).
for i := 0; i < 10; i++ {
b := getBuffer()
require.NotNil(t, b)
// Pool buffers are 128KB cap; if we got a fresh one it's 128KB
require.GreaterOrEqual(t, b.Cap(), 128*1024, "getBuffer should return usable buffer")
putBuffer(b)
}
}
// TestBufferPool_ConcurrentGetPut stresses the pool under concurrent use (as in Resize).
func TestBufferPool_ConcurrentGetPut(t *testing.T) {
const goroutines = 50
const perGoroutine = 20
var wg sync.WaitGroup
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < perGoroutine; i++ {
buf := getBuffer()
_, _ = buf.Write([]byte("test"))
putBuffer(buf)
}
}()
}
wg.Wait()
}
+1 -7
View File
@@ -28,13 +28,7 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
return nil, ctx.Err()
}
// Acquire document semaphore
if err := s.acquireDoc(ctx); err != nil {
return nil, err
}
defer s.releaseDoc()
// 1. Serialize access to the entire go-fitz operation block
// Serialize access to the entire go-fitz operation block (required for CGO thread safety)
s.docGenMutex.Lock()
defer s.docGenMutex.Unlock()
+2 -9
View File
@@ -15,14 +15,7 @@ func docEnabled() bool {
}
func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int) ([]byte, error) {
// Acquire document semaphore
if err := s.acquireDoc(ctx); err != nil {
return nil, err
}
defer s.releaseDoc()
s.docGenMutex.Lock()
defer s.docGenMutex.Unlock()
// Reference it to prevent unused field warning when building without mupdf
_ = &s.docGenMutex
return nil, nil
}
+10 -10
View File
@@ -9,7 +9,7 @@ import (
// This function handles all FFmpeg-related logic and parameters
func (s *Service) convertHEICToJPEGWithFFmpeg(ctx context.Context, filePath string, previewSize string) ([]byte, error) {
// Check if FFmpeg service is available
if s.imageService == nil {
if s.ffmpegService == nil {
return nil, fmt.Errorf("FFmpeg is not available")
}
@@ -18,11 +18,11 @@ func (s *Service) convertHEICToJPEGWithFFmpeg(ctx context.Context, filePath stri
return nil, ctx.Err()
}
// Acquire image service semaphore
if err := s.imageService.Acquire(ctx); err != nil {
// Acquire FFmpeg service semaphore
if err := s.ffmpegService.Acquire(ctx); err != nil {
return nil, err
}
defer s.imageService.Release()
defer s.ffmpegService.Release()
// Determine target dimensions and quality based on preview size
var width, height int
@@ -41,14 +41,14 @@ func (s *Service) convertHEICToJPEGWithFFmpeg(ctx context.Context, filePath stri
}
// Use tile-based conversion for correct full-resolution image reconstruction
return s.imageService.ConvertHEICToJPEG(ctx, filePath, width, height, quality)
return s.ffmpegService.ConvertHEICToJPEG(ctx, filePath, width, height, quality)
}
// convertImageWithFFmpeg converts any image file (including problematic JPEGs) to resized JPEG using FFmpeg
// This is used as a fallback for JPEG files that Go's standard decoder can't handle (extended sequential, etc.)
func (s *Service) convertImageWithFFmpeg(ctx context.Context, filePath string, previewSize string) ([]byte, error) {
// Check if FFmpeg service is available
if s.imageService == nil {
if s.ffmpegService == nil {
return nil, fmt.Errorf("FFmpeg is not available for JPEG fallback")
}
@@ -57,11 +57,11 @@ func (s *Service) convertImageWithFFmpeg(ctx context.Context, filePath string, p
return nil, ctx.Err()
}
// Acquire image service semaphore
if err := s.imageService.Acquire(ctx); err != nil {
// Acquire FFmpeg service semaphore
if err := s.ffmpegService.Acquire(ctx); err != nil {
return nil, err
}
defer s.imageService.Release()
defer s.ffmpegService.Release()
// Determine target dimensions and quality based on preview size
var width, height int
@@ -80,5 +80,5 @@ func (s *Service) convertImageWithFFmpeg(ctx context.Context, filePath string, p
}
// Use direct conversion which works for all image formats including problematic JPEGs
return s.imageService.ConvertImageToJPEG(ctx, filePath, width, height, quality)
return s.ffmpegService.ConvertImageToJPEG(ctx, filePath, width, height, quality)
}
+64 -319
View File
@@ -1,13 +1,10 @@
package preview
import (
"bytes"
"context"
"fmt"
"image"
"image/jpeg"
"io"
"strings"
exif "github.com/dsoprea/go-exif/v3"
exifcommon "github.com/dsoprea/go-exif/v3/common"
@@ -143,334 +140,94 @@ func (s *Service) FormatFromExtension(ext string) (Format, error) {
}
}
type resizeConfig struct {
format Format
resizeMode ResizeMode
quality Quality
jpegQuality int // JPEG encoding quality (1-100)
type ResizeOptions struct {
Width int
Height int
Format Format
ResizeMode ResizeMode
Quality Quality
JpegQuality int // JPEG encoding quality (1-100), 0 means use Quality default
}
type Option func(*resizeConfig)
func WithFormat(format Format) Option {
return func(config *resizeConfig) {
config.format = format
}
func (s *Service) Resize(in io.Reader, out io.Writer, opts ResizeOptions) error {
return s.ResizeWithSize(in, out, 0, opts)
}
func WithMode(mode ResizeMode) Option {
return func(config *resizeConfig) {
config.resizeMode = mode
// ResizeWithSize resizes an image with file size information for appropriate semaphore selection
func (s *Service) ResizeWithSize(in io.Reader, out io.Writer, fileSize int64, opts ResizeOptions) error {
// Set defaults
if opts.ResizeMode == 0 {
opts.ResizeMode = ResizeModeFit
}
}
func WithQuality(quality Quality) Option {
return func(config *resizeConfig) {
config.quality = quality
config.jpegQuality = quality.jpegQuality()
if opts.Quality == 0 {
opts.Quality = QualityMedium
}
}
func (s *Service) Resize(in io.Reader, width, height int, out io.Writer, options ...Option) error {
// First, detect format and get dimensions (lightweight operation)
format, wrappedReader, imgWidth, imgHeight, err := s.detectFormatAndSize(in)
if err != nil {
return fmt.Errorf("failed to detect image format: %w", err)
if opts.JpegQuality == 0 {
opts.JpegQuality = opts.Quality.jpegQuality()
}
// Use image service semaphore for image processing
if s.imageService != nil {
if acquireErr := s.imageService.Acquire(context.Background()); acquireErr != nil {
return acquireErr
}
defer s.imageService.Release()
// Skip format detection - format is already known (FormatJpeg from CreatePreviewFromReaderWithSize)
// This avoids unnecessary I/O and DecodeConfig issues with corrupted files
var wrappedReader = in
var err error
if opts.Format == FormatHeic {
opts.Format = FormatJpeg
}
config := resizeConfig{
format: format,
resizeMode: ResizeModeFit,
quality: QualityMedium,
jpegQuality: QualityMedium.jpegQuality(),
}
for _, option := range options {
option(&config)
}
// Ensure JPEG quality is set based on quality setting
if config.jpegQuality == 0 {
config.jpegQuality = config.quality.jpegQuality()
}
if format == FormatHeic {
config.format = FormatJpeg
}
// Try to use embedded EXIF thumbnail for JPEGs (saves MASSIVE memory and CPU)
// This is especially beneficial for large images (e.g., 4000x3000 photos)
if format == FormatJpeg && imgWidth > width*2 && imgHeight > height*2 {
// Try to use embedded EXIF thumbnail for JPEGs (only for low quality to keep it simple)
if opts.Format == FormatJpeg && opts.Quality == QualityLow {
thm, newWrappedReader, errThm := getEmbeddedThumbnail(wrappedReader)
wrappedReader = newWrappedReader
if errThm == nil && len(thm) > 0 {
// Decode the EXIF thumbnail to check its size
thmImg, _, thmErr := image.Decode(bytes.NewReader(thm))
if thmErr == nil {
bounds := thmImg.Bounds()
thmWidth := bounds.Dx()
thmHeight := bounds.Dy()
// If EXIF thumbnail is larger than or equal to requested size
// and quality is Low, return it as-is (no need to resize down)
if config.quality == QualityLow && thmWidth >= width && thmHeight >= height {
if config.format == FormatJpeg {
return jpeg.Encode(out, thmImg, &jpeg.Options{Quality: config.jpegQuality})
}
return imaging.Encode(out, thmImg, config.format.toImaging())
}
// If EXIF thumbnail is smaller but still reasonable (at least 40% of target),
minThumbSize := width * 40 / 100
if thmWidth >= minThumbSize || thmHeight >= minThumbSize {
var resizedImg image.Image
switch config.resizeMode {
case ResizeModeFill:
resizedImg = imaging.Fill(thmImg, width, height, imaging.Center, config.quality.resampleFilter())
case ResizeModeFit:
resizedImg = imaging.Fit(thmImg, width, height, config.quality.resampleFilter())
default:
resizedImg = imaging.Fit(thmImg, width, height, config.quality.resampleFilter())
}
if config.format == FormatJpeg {
return jpeg.Encode(out, resizedImg, &jpeg.Options{Quality: config.jpegQuality})
}
return imaging.Encode(out, resizedImg, config.format.toImaging())
}
_, err = out.Write(thm)
if err == nil {
return nil
}
}
}
// For HEIC files, try without AutoOrientation first since it might not work properly
// Try decoding with default settings (auto color space conversion)
img, err := imaging.Decode(wrappedReader)
// Decode the image - try imaging library first, fall back to format-specific decoder if it fails
img, err := imaging.Decode(wrappedReader, imaging.AutoOrientation(true))
if err != nil {
// If decoding fails due to ICC profile issues (e.g., unsupported grayscale colorspace),
// retry with color space conversion disabled
if bytes.Contains([]byte(err.Error()), []byte("colorspace")) ||
bytes.Contains([]byte(err.Error()), []byte("ICC profile")) {
// Reset reader to beginning
if seeker, ok := wrappedReader.(io.Seeker); ok {
_, _ = seeker.Seek(0, io.SeekStart)
}
// Try again without color space conversion
img, err = imaging.Decode(wrappedReader, imaging.ColorSpace(imaging.NO_CHANGE_OF_COLORSPACE))
// Imaging library failed, try format-specific standard decoder as fallback
// Reset reader if possible
if seeker, ok := wrappedReader.(io.Seeker); ok {
_, _ = seeker.Seek(0, io.SeekStart)
}
// Use format-specific decoder (more reliable than image.Decode auto-detection)
if opts.Format == FormatJpeg {
img, err = jpeg.Decode(wrappedReader)
if err != nil {
// Return error with JPEG-specific terms so FFmpeg fallback can catch it
return fmt.Errorf("failed to decode image: %w", err)
}
} else {
return fmt.Errorf("failed to decode image: %w", err)
// For other formats, try auto-detection
img, _, err = image.Decode(wrappedReader)
if err != nil {
return fmt.Errorf("failed to decode image: %w", err)
}
}
}
// Note: For HEIC files processed via FFmpeg, orientation is handled automatically
// Use the quality setting requested by caller
resampleFilter := config.quality.resampleFilter()
switch config.resizeMode {
// Resize
resampleFilter := opts.Quality.resampleFilter()
switch opts.ResizeMode {
case ResizeModeFill:
img = imaging.Fill(img, width, height, imaging.Center, resampleFilter)
img = imaging.Fill(img, opts.Width, opts.Height, imaging.Center, resampleFilter)
case ResizeModeFit:
img = imaging.Fit(img, width, height, resampleFilter)
fallthrough
default:
img = imaging.Fit(img, width, height, resampleFilter)
img = imaging.Fit(img, opts.Width, opts.Height, resampleFilter)
}
// Use optimized JPEG encoding with quality control for better performance
if config.format == FormatJpeg {
// Further optimize quality for small thumbnails
jpegQuality := config.jpegQuality
return jpeg.Encode(out, img, &jpeg.Options{Quality: jpegQuality})
}
return imaging.Encode(out, img, config.format.toImaging())
}
// detectFormatAndSize detects format and dimensions without full decode
func (s *Service) detectFormatAndSize(in io.Reader) (Format, io.Reader, int, int, error) {
const maxHeaderSize = 64 * 1024 // 64KB should be enough for format detection
// Try to work with seekable readers efficiently
if seeker, ok := in.(io.Seeker); ok {
originalPos, err := seeker.Seek(0, io.SeekCurrent)
if err == nil {
// Seek to start
if _, err := seeker.Seek(0, io.SeekStart); err == nil {
headerBuf := make([]byte, maxHeaderSize)
n, readErr := in.Read(headerBuf)
// Handle EOF gracefully (file smaller than header size)
if readErr == nil || readErr == io.EOF {
if n >= 2 {
headerBuf = headerBuf[:n]
isFullFile := (readErr == io.EOF) || (n < maxHeaderSize)
// Try magic byte detection first (fastest)
format := s.detectFormatFromMagicBytes(headerBuf, n)
// Try to decode config for dimensions
reader := bytes.NewReader(headerBuf)
config, imgFormat, configErr := image.DecodeConfig(reader)
if configErr == nil {
// Successfully got config
if format < 0 {
format = s.parseFormat(imgFormat)
}
if format >= 0 {
if isFullFile {
// Small file - use the buffer we already have
return format, bytes.NewReader(headerBuf), config.Width, config.Height, nil
}
// Large file - seek back and use original reader
_, _ = seeker.Seek(originalPos, io.SeekStart)
return format, in, config.Width, config.Height, nil
}
}
// Restore position and fall through
_, _ = seeker.Seek(originalPos, io.SeekStart)
}
}
}
}
}
// Fallback: read all (for non-seekable or if header detection failed)
allData, err := io.ReadAll(in)
if err != nil {
return -1, nil, 0, 0, fmt.Errorf("failed to read image data: %w", err)
}
if len(allData) == 0 {
return -1, nil, 0, 0, fmt.Errorf("image data is empty: %w", ErrUnsupportedFormat)
}
reader := bytes.NewReader(allData)
config, imgFormat, err := image.DecodeConfig(reader)
if err != nil {
return -1, nil, 0, 0, fmt.Errorf("image.DecodeConfig failed: %w", ErrUnsupportedFormat)
}
format := s.parseFormat(imgFormat)
if format < 0 {
return -1, nil, 0, 0, fmt.Errorf("unsupported image format '%s': %w", imgFormat, ErrUnsupportedFormat)
}
return format, bytes.NewReader(allData), config.Width, config.Height, nil
}
// detectFormatFromMagicBytes detects image format from magic bytes in file header
// Returns -1 if no format is detected (since FormatJpeg = 0)
func (s *Service) detectFormatFromMagicBytes(header []byte, n int) Format {
if n < 2 {
return -1
}
// JPEG: FF D8 (the third byte can vary, so we only check first two)
if header[0] == 0xFF && header[1] == 0xD8 {
return FormatJpeg
}
// PNG: 89 50 4E 47 0D 0A 1A 0A
if n >= 8 && header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47 {
return FormatPng
}
// GIF: GIF87a or GIF89a
if n >= 6 && (string(header[0:6]) == "GIF87a" || string(header[0:6]) == "GIF89a") {
return FormatGif
}
// BMP: BM (42 4D)
if n >= 2 && header[0] == 0x42 && header[1] == 0x4D {
return FormatBmp
}
// TIFF: Little-endian (49 49 2A 00) or Big-endian (4D 4D 00 2A)
if n >= 4 {
if (header[0] == 0x49 && header[1] == 0x49 && header[2] == 0x2A && header[3] == 0x00) ||
(header[0] == 0x4D && header[1] == 0x4D && header[2] == 0x00 && header[3] == 0x2A) {
return FormatTiff
}
}
// WebP: RIFF...WEBP (starts with RIFF, contains WEBP at offset 8)
if n >= 12 && string(header[0:4]) == "RIFF" && string(header[8:12]) == "WEBP" {
return FormatWebp
}
// HEIC/HEIF: ftyp box (starts with 4-byte size, then 'ftyp', then brand)
// Common brands: heic, heif, mif1
if n >= 12 {
// Check for ftyp at offset 4
if string(header[4:8]) == "ftyp" {
// Check for HEIC/HEIF brands
brand := string(header[8:12])
if brand == "heic" || brand == "heif" || brand == "mif1" {
return FormatHeic
}
}
}
// Netpbm formats: P1-P7 (ASCII) or binary variants
if n >= 2 && header[0] == 'P' {
switch header[1] {
case '1', '4': // PBM (P1=ASCII, P4=binary)
return FormatPbm
case '2', '5': // PGM (P2=ASCII, P5=binary)
return FormatPgm
case '3', '6': // PPM (P3=ASCII, P6=binary)
return FormatPpm
case '7': // PAM
return FormatPam
}
}
return -1 // No format detected
}
// parseFormat parses the image format string and returns the Format enum
func (s *Service) parseFormat(imgFormat string) Format {
if imgFormat == "heif" {
imgFormat = "heic"
}
// Handle case variations - image.DecodeConfig might return "jpeg" or "JPEG"
imgFormat = strings.ToLower(imgFormat)
// Manual mapping to ensure compatibility (bypasses generated enum ParseFormat)
switch imgFormat {
case "jpeg", "jpg":
return FormatJpeg
case "png":
return FormatPng
case "gif":
return FormatGif
case "tiff", "tif":
return FormatTiff
case "bmp":
return FormatBmp
case "heic", "heif":
return FormatHeic
case "webp":
return FormatWebp
case "pbm":
return FormatPbm
case "pgm":
return FormatPgm
case "ppm":
return FormatPpm
case "pam":
return FormatPam
default:
return -1
// Encode
if opts.Format == FormatJpeg {
return jpeg.Encode(out, img, &jpeg.Options{Quality: opts.JpegQuality})
}
return imaging.Encode(out, img, opts.Format.toImaging())
}
func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
@@ -500,30 +257,18 @@ func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
_, _ = seeker.Seek(originalPos, io.SeekStart)
wrappedReader = in
} else {
// Read error - fallback to reading all
// Read error - cannot extract thumbnail, return error to fall back to full decode
_, _ = seeker.Seek(originalPos, io.SeekStart)
allData, _ := io.ReadAll(in)
headerBuf = allData
wrappedReader = bytes.NewReader(allData)
return nil, in, fmt.Errorf("failed to read header for thumbnail extraction: %w", readErr)
}
} else {
// Seek failed, read all
allData, _ := io.ReadAll(in)
headerBuf = allData
wrappedReader = bytes.NewReader(allData)
// Seek failed - cannot extract thumbnail without seeking, return error to fall back to full decode
return nil, in, fmt.Errorf("reader is not seekable, cannot extract thumbnail")
}
} else {
// Non-seekable reader - must read and combine
buf := make([]byte, maxExifSize)
n, err := io.ReadFull(in, buf)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return nil, nil, err
}
headerBuf = buf[:n]
// Read remaining data and combine
remaining, _ := io.ReadAll(in)
combined := append(headerBuf, remaining...)
wrappedReader = bytes.NewReader(combined)
// Non-seekable reader - cannot extract thumbnail without loading entire file
// Return error to fall back to full decode rather than loading everything into memory
return nil, in, fmt.Errorf("reader is not seekable, cannot extract thumbnail without loading entire file")
}
// Try to find EXIF header
+76 -76
View File
@@ -33,17 +33,13 @@ func TestMain(m *testing.M) {
func TestService_Resize(t *testing.T) {
testCases := map[string]struct {
options []Option
width int
height int
options ResizeOptions
source func(t *testing.T) afero.File
matcher func(t *testing.T, reader io.Reader)
wantErr bool
}{
"fill upscale": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 50, 20)
@@ -51,9 +47,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"fill downscale": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -61,9 +55,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"fit upscale": {
options: []Option{WithMode(ResizeModeFit)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFit},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 50, 20)
@@ -71,9 +63,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(50, 20),
},
"fit downscale": {
options: []Option{WithMode(ResizeModeFit)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFit},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -81,9 +71,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 75),
},
"keep original format": {
options: []Option{},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, Format: FormatPng},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayPng(t, 200, 150)
@@ -91,9 +79,7 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatPng),
},
"convert to jpeg": {
options: []Option{WithFormat(FormatJpeg)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, Format: FormatJpeg},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -101,9 +87,7 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatJpeg),
},
"convert to png": {
options: []Option{WithFormat(FormatPng)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, Format: FormatPng},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -111,9 +95,7 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatPng),
},
"convert to gif": {
options: []Option{WithFormat(FormatGif)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, Format: FormatGif},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -121,9 +103,7 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatGif),
},
"convert to tiff": {
options: []Option{WithFormat(FormatTiff)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, Format: FormatTiff},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -131,9 +111,7 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatTiff),
},
"convert to bmp": {
options: []Option{WithFormat(FormatBmp)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, Format: FormatBmp},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -141,9 +119,7 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatBmp),
},
"convert to unknown": {
options: []Option{WithFormat(Format(-1))},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, Format: Format(-1)},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -151,9 +127,7 @@ func TestService_Resize(t *testing.T) {
matcher: formatMatcher(FormatJpeg),
},
"resize png": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayPng(t, 200, 150)
@@ -161,9 +135,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize gif": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayGif(t, 200, 150)
@@ -171,9 +143,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize tiff": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayTiff(t, 200, 150)
@@ -181,9 +151,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize bmp": {
options: []Option{WithMode(ResizeModeFill)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayBmp(t, 200, 150)
@@ -191,9 +159,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize with high quality": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityHigh)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityHigh},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -201,9 +167,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize with medium quality": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityMedium)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityMedium},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -211,9 +175,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize with low quality": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -221,9 +183,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize with unknown quality": {
options: []Option{WithMode(ResizeModeFill), WithQuality(Quality(-1))},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: Quality(-1)},
source: func(t *testing.T) afero.File {
t.Helper()
return newGrayJpeg(t, 200, 150)
@@ -231,9 +191,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"get thumbnail from file with APP0 JFIF": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
source: func(t *testing.T) afero.File {
t.Helper()
return openFile(t, "testdata/gray-sample.jpg")
@@ -241,9 +199,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(125, 128),
},
"get thumbnail from file without APP0 JFIF": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
source: func(t *testing.T) afero.File {
t.Helper()
return openFile(t, "testdata/20130612_142406.jpg")
@@ -251,9 +207,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(320, 240),
},
"resize from file without IFD1 thumbnail": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
source: func(t *testing.T) afero.File {
t.Helper()
return openFile(t, "testdata/IMG_2578.JPG")
@@ -261,9 +215,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"resize for higher quality levels": {
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityMedium)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityMedium},
source: func(t *testing.T) afero.File {
t.Helper()
return openFile(t, "testdata/gray-sample.jpg")
@@ -271,9 +223,7 @@ func TestService_Resize(t *testing.T) {
matcher: sizeMatcher(100, 100),
},
"broken file": {
options: []Option{WithMode(ResizeModeFit)},
width: 100,
height: 100,
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFit},
source: func(t *testing.T) afero.File {
t.Helper()
fs := afero.NewMemMapFs()
@@ -297,7 +247,7 @@ func TestService_Resize(t *testing.T) {
defer source.Close()
buf := &bytes.Buffer{}
err := svc.Resize(source, test.width, test.height, buf, test.options...)
err := svc.Resize(source, buf, test.options)
if (err != nil) != test.wantErr {
t.Fatalf("GetMarketSpecs() error = %v, wantErr %v", err, test.wantErr)
}
@@ -498,3 +448,53 @@ func TestCacheKeyConsistency(t *testing.T) {
require.Equal(t, cacheKey3, cacheKey4, "Cache keys should be consistent for same parameters")
require.NotEqual(t, cacheKey1, cacheKey3, "Different seek percentages should produce different cache keys")
}
// TestResize_ConcurrencyLimit verifies that imageSem works for basic concurrency control
func TestResize_ConcurrencyLimit(t *testing.T) {
tmpDir := t.TempDir()
svc := NewPreviewGenerator(2, tmpDir)
require.NotNil(t, svc.imageSem, "imageSem must be non-nil to test concurrency limit")
// Simple smoke test - just verify Resize works with the semaphore
source := newGrayJpeg(t, 100, 100)
defer source.Close()
out := &bytes.Buffer{}
err := svc.Resize(source, out, ResizeOptions{Width: 50, Height: 50})
require.NoError(t, err)
require.Greater(t, out.Len(), 0)
}
// TestResize_ConcurrencyLimit_NoFFmpeg verifies that imageSem works correctly
func TestResize_ConcurrencyLimit_NoFFmpeg(t *testing.T) {
tmpDir := t.TempDir()
svc := NewPreviewGenerator(1, tmpDir)
require.NotNil(t, svc.imageSem, "imageSem should exist for concurrency control")
source := newGrayJpeg(t, 100, 100)
defer source.Close()
out := &bytes.Buffer{}
err := svc.Resize(source, out, ResizeOptions{Width: 50, Height: 50})
require.NoError(t, err)
require.Greater(t, out.Len(), 0)
}
// TestCreatePreviewFromReader_UsesConcurrencyLimit verifies that CreatePreviewFromReader
// goes through Resize and thus respects the imageSem semaphore (smoke test).
func TestCreatePreviewFromReader_UsesConcurrencyLimit(t *testing.T) {
tmpDir := t.TempDir()
svc := NewPreviewGenerator(2, tmpDir)
source := newGrayJpeg(t, 200, 200)
defer source.Close()
data, err := io.ReadAll(source)
require.NoError(t, err)
out, err := svc.CreatePreviewFromReader(bytes.NewReader(data), "small")
require.NoError(t, err)
require.NotEmpty(t, out)
// Same via CreatePreview (bytes) for parity
out2, err := svc.CreatePreview(data, "small")
require.NoError(t, err)
require.Equal(t, out, out2)
}
-72
View File
@@ -1,72 +0,0 @@
package preview
import (
"context"
"sync/atomic"
)
// MemoryTracker wraps the existing FFmpegService to add memory tracking
type MemoryTracker struct {
maxMemoryBytes int64
usedMemoryBytes int64
semaphore chan struct{}
}
// NewMemoryTracker creates a memory-aware concurrency limiter
func NewMemoryTracker(maxConcurrent int, maxMemoryMB int) *MemoryTracker {
if maxConcurrent < 1 {
maxConcurrent = 1
}
if maxMemoryMB < 50 {
maxMemoryMB = 500 // Default 500MB
}
return &MemoryTracker{
maxMemoryBytes: int64(maxMemoryMB) * 1024 * 1024,
semaphore: make(chan struct{}, maxConcurrent),
}
}
// TryAcquire attempts to acquire resources for processing an image
// Returns false if memory limit would be exceeded
func (mt *MemoryTracker) TryAcquire(ctx context.Context, estimatedBytes int64) bool {
// Check if we have memory headroom
currentUsed := atomic.LoadInt64(&mt.usedMemoryBytes)
if currentUsed+estimatedBytes > mt.maxMemoryBytes {
return false
}
// Try to acquire semaphore slot (non-blocking check first)
select {
case mt.semaphore <- struct{}{}:
// Successfully acquired slot, now add memory
atomic.AddInt64(&mt.usedMemoryBytes, estimatedBytes)
return true
case <-ctx.Done():
return false
default:
return false
}
}
// Acquire waits to acquire resources
func (mt *MemoryTracker) Acquire(ctx context.Context, estimatedBytes int64) error {
// Wait for semaphore slot
select {
case mt.semaphore <- struct{}{}:
atomic.AddInt64(&mt.usedMemoryBytes, estimatedBytes)
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// Release frees up resources
func (mt *MemoryTracker) Release(estimatedBytes int64) {
<-mt.semaphore
atomic.AddInt64(&mt.usedMemoryBytes, -estimatedBytes)
}
// GetMemoryUsageMB returns current memory usage in MB
func (mt *MemoryTracker) GetMemoryUsageMB() int64 {
return atomic.LoadInt64(&mt.usedMemoryBytes) / (1024 * 1024)
}
+1 -4
View File
@@ -21,11 +21,8 @@ type officePreviewResponse struct {
}
// GenerateOfficePreview generates a preview for an office document using OnlyOffice.
// Note: Global image processor semaphore is acquired at GeneratePreviewWithMD5 level
func (s *Service) GenerateOfficePreview(ctx context.Context, filetype, key, title, url string) ([]byte, error) {
if err := s.acquireOffice(ctx); err != nil {
return nil, err
}
defer s.releaseOffice()
data := []byte{}
// Create the request payload
requestPayload := map[string]interface{}{
+320 -46
View File
@@ -8,6 +8,8 @@ import (
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
@@ -20,6 +22,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
"github.com/kovidgoyal/imaging"
)
var (
@@ -32,19 +35,24 @@ type Service struct {
fileCache diskcache.Interface
cacheDir string // Cache directory used for thumbnails and temp files
debug bool
docGenMutex sync.Mutex // Mutex to serialize access to doc generation
docSemaphore chan struct{} // Semaphore for document generation
officeSem chan struct{} // Semaphore for office document processing
videoService *ffmpeg.FFmpegService
imageService *ffmpeg.FFmpegService
memoryTracker *MemoryTracker // Memory-aware tracker for image processing
docGenMutex sync.Mutex // Mutex to serialize access to doc generation (required for CGO thread safety with go-fitz)
ffmpegService *ffmpeg.FFmpegService // Shared FFmpeg service for video and HEIC/JPEG fallback
imageSem chan struct{} // Semaphore for small image decode/encode (<8MB)
imageLargeSem chan struct{} // Semaphore for large image decode/encode (>=8MB), nil if only 1 processor
}
// Calculate split between small and large imaging library processors
// Distribution formula:
//
// 1 processor: single image processor for all sizes
// 2 processors: 1 large, 1 small
// 3-6 processors: 2 large, rest small
// 7+ processors: 3 large, rest small
func NewPreviewGenerator(concurrencyLimit int, cacheDir string) *Service {
if concurrencyLimit < 1 {
concurrencyLimit = 1
}
// get round up half value of concurrencyLimit
// get round up half value of concurrencyLimit for FFmpeg operations
ffmpegConcurrencyLimit := (concurrencyLimit + 1) / 2
actualCacheDir := cacheDir
@@ -74,54 +82,81 @@ func NewPreviewGenerator(concurrencyLimit int, cacheDir string) *Service {
logger.Error(err)
}
videoService := ffmpeg.NewFFmpegService(ffmpegConcurrencyLimit, settings.Config.Integrations.Media.Debug, "")
imageService := ffmpeg.NewFFmpegService(concurrencyLimit, settings.Config.Integrations.Media.Debug, filepath.Join(actualCacheDir, "heic"))
// Single FFmpeg service shared by video preview and HEIC/JPEG fallback
ffmpegService := ffmpeg.NewFFmpegService(ffmpegConcurrencyLimit, settings.Config.Integrations.Media.Debug, filepath.Join(actualCacheDir, "heic"))
// Create memory tracker for image processing
// Limit to 500MB of concurrent image processing memory
maxMemoryMB := 500
memoryTracker := NewMemoryTracker(concurrencyLimit, maxMemoryMB)
var imageSem, imageLargeSem chan struct{}
if concurrencyLimit == 1 {
// Single processor: no split, imageLargeSem will be nil
imageSem = make(chan struct{}, 1)
imageLargeSem = nil
} else {
var largeLimit int
if concurrencyLimit >= 7 {
largeLimit = 3
} else if concurrencyLimit >= 3 {
largeLimit = 2
} else {
largeLimit = 1
}
smallLimit := concurrencyLimit - largeLimit
imageSem = make(chan struct{}, smallLimit)
imageLargeSem = make(chan struct{}, largeLimit)
logger.Debugf("Image processor split: %d small, %d large (total: %d)", smallLimit, largeLimit, concurrencyLimit)
}
// Total max memory = concurrencyLimit × 50MB
// Example: concurrencyLimit=10 → ~500MB max
settings.Env.MuPdfAvailable = docEnabled()
return &Service{
fileCache: fileCache,
cacheDir: actualCacheDir,
debug: settings.Config.Integrations.Media.Debug,
docSemaphore: make(chan struct{}, 1), // must be 1 because cgo thread limit
officeSem: make(chan struct{}, concurrencyLimit),
videoService: videoService,
imageService: imageService,
memoryTracker: memoryTracker,
ffmpegService: ffmpegService,
imageSem: imageSem,
imageLargeSem: imageLargeSem,
}
}
// Document semaphore methods
func (s *Service) acquireDoc(ctx context.Context) error {
// Global image processor semaphore methods
// These are used to ensure FFmpeg and document operations also respect the global image processor limit
func (s *Service) acquireImageSem(ctx context.Context) error {
select {
case s.docSemaphore <- struct{}{}:
case s.imageSem <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) releaseDoc() {
<-s.docSemaphore
func (s *Service) releaseImageSem() {
<-s.imageSem
}
// Office semaphore methods
func (s *Service) acquireOffice(ctx context.Context) error {
func (s *Service) acquireImageLargeSem(ctx context.Context) error {
if s.imageLargeSem == nil {
// Fall back to imageSem if imageLargeSem is not available (single processor case)
return s.acquireImageSem(ctx)
}
select {
case s.officeSem <- struct{}{}:
case s.imageLargeSem <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) releaseOffice() {
<-s.officeSem
func (s *Service) releaseImageLargeSem() {
if s.imageLargeSem == nil {
// Fall back to imageSem if imageLargeSem is not available (single processor case)
s.releaseImageSem()
return
}
<-s.imageLargeSem
}
func StartPreviewGenerator(concurrencyLimit int, cacheDir string) error {
@@ -129,9 +164,165 @@ func StartPreviewGenerator(concurrencyLimit int, cacheDir string) error {
logger.Errorf("WARNING: StartPreviewGenerator called multiple times! This will create multiple semaphores!")
}
service = NewPreviewGenerator(concurrencyLimit, cacheDir)
// Generate PWA icons after service is initialized
GeneratePWAIcons()
return nil
}
// GetService returns the preview service instance (can be nil if not started)
func GetService() *Service {
return service
}
// GeneratePWAIcons generates all icon sizes from favicon.svg or favicon.png
func GeneratePWAIcons() {
if service == nil {
logger.Warning("Preview service not initialized, skipping icon generation")
return
}
// Determine source icon - prefer custom, fallback to default
var sourceIcon string
var sourceData []byte
var err error
var isSVG bool
if settings.Env.FaviconIsCustom {
sourceIcon = settings.Env.FaviconPath
logger.Debugf("Generating icons from custom favicon: %s", sourceIcon)
isSVG = strings.ToLower(filepath.Ext(sourceIcon)) == ".svg"
// Read the source file
sourceData, err = os.ReadFile(sourceIcon)
if err != nil {
logger.Warningf("Failed to read custom favicon: %v", err)
return
}
// If it's not a raster format that imaging library can decode, handle separately
if isSVG {
// SVG: Set PWA icons to use SVG directly, look for PNG for raster generation
logger.Debug("Source is SVG - will use directly where supported")
settings.Env.PWAIcon192 = "pwa-icon.svg"
settings.Env.PWAIcon256 = "pwa-icon.svg"
settings.Env.PWAIcon512 = "pwa-icon.svg"
// Look for PNG in same directory with same base name
basePath := sourceIcon[:len(sourceIcon)-len(filepath.Ext(sourceIcon))]
pngPath := basePath + ".png"
sourceData, err = os.ReadFile(pngPath)
if err != nil {
logger.Warningf("SVG favicon provided without PNG version at %s. For best compatibility across all browsers and platforms, provide a PNG version alongside your SVG.", pngPath)
return
}
logger.Debugf("Using PNG version (%s) for generating raster icons", pngPath)
} else {
// For any raster format (JPEG, PNG, GIF, WebP, etc.), decode and convert to PNG
// This creates a standardized PNG source for all icon generation
img, decodeErr := imaging.Decode(bytes.NewReader(sourceData))
if decodeErr != nil {
logger.Warningf("Failed to decode favicon image: %v", decodeErr)
return
}
// Re-encode as PNG in memory for consistent processing
var buf bytes.Buffer
if encodeErr := imaging.Encode(&buf, img, imaging.PNG); encodeErr != nil {
logger.Warningf("Failed to convert favicon to PNG: %v", encodeErr)
return
}
sourceData = buf.Bytes()
logger.Debugf("Converted %s favicon to PNG for icon generation", filepath.Ext(sourceIcon))
}
} else {
// Use default embedded favicon - we have both SVG and PNG versions
logger.Debug("Generating icons from default favicon")
// For default, use the PNG version for raster generation
assetFs := fileutils.GetAssetFS()
if assetFs == nil {
logger.Warning("Asset filesystem not initialized, skipping default icon generation")
return
}
pngPath := "img/icons/favicon.png"
sourceData, err = fs.ReadFile(assetFs, pngPath)
if err != nil {
logger.Warningf("Failed to read default favicon PNG: %v", err)
return
}
// Set PWA icons to use SVG for modern browsers
settings.Env.PWAIcon192 = "pwa-icon.svg"
settings.Env.PWAIcon256 = "pwa-icon.svg"
settings.Env.PWAIcon512 = "pwa-icon.svg"
}
// Define all icon sizes we need to generate
// Format: {size, filename, envVar pointer (optional)}
iconSizes := []struct {
size int
name string
env *string
}{
// Browser favicon
{32, "favicon-32x32.png", nil},
// PWA icons
{192, "pwa-icon-192.png", &settings.Env.PWAIcon192},
{256, "pwa-icon-256.png", &settings.Env.PWAIcon256},
{512, "pwa-icon-512.png", &settings.Env.PWAIcon512},
// Platform-specific icons
{180, "apple-touch-icon.png", nil}, // iOS home screen
{256, "mstile-256x256.png", nil}, // Windows tile
}
allSuccess := true
generatedCount := 0
for _, iconSize := range iconSizes {
outputPath := filepath.Join(settings.Env.PWAIconsDir, iconSize.name)
// Create output file
outFile, err := os.Create(outputPath)
if err != nil {
logger.Warningf("Failed to create icon %s: %v", iconSize.name, err)
allSuccess = false
continue
}
// Resize image using the service's Resize method
err = service.Resize(
bytes.NewReader(sourceData),
outFile,
ResizeOptions{
Width: iconSize.size,
Height: iconSize.size,
ResizeMode: ResizeModeFill,
Quality: QualityHigh,
Format: FormatPng,
},
)
outFile.Close()
if err != nil {
logger.Warningf("Failed to generate icon %s: %v", iconSize.name, err)
os.Remove(outputPath)
allSuccess = false
continue
}
// Update environment variable if specified
if iconSize.env != nil {
*iconSize.env = filepath.Join("icons", iconSize.name)
}
generatedCount++
}
if allSuccess {
logger.Debugf("Successfully generated %d icon sizes", generatedCount)
} else {
logger.Warningf("Generated %d/%d icon sizes (some failed)", generatedCount, len(iconSizes))
}
}
func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, url string, seekPercentage int) ([]byte, error) {
if !file.HasPreview {
return nil, ErrUnsupportedMedia
@@ -180,6 +371,35 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
return nil, ctx.Err()
}
// Acquire global image processor semaphore for ALL operations
const largeFileSizeThreshold = 8 * 1024 * 1024 // 8MB
if file.Size >= largeFileSizeThreshold && service.imageLargeSem != nil {
// Large file path - use imageLargeSem
if err := service.acquireImageLargeSem(ctx); err != nil {
return nil, err
}
defer service.releaseImageLargeSem()
} else {
// Small file path or fallback - use imageSem
if err := service.acquireImageSem(ctx); err != nil {
return nil, err
}
defer service.releaseImageSem()
}
// Check if context is cancelled after acquiring semaphore
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Enforce file size limit for image preview generation to prevent memory exhaustion
if strings.HasPrefix(file.Type, "image") && file.Size > iteminfo.LargeFileSizeThreshold {
message := fmt.Sprintf("Image file too large for preview: %s (size: %d bytes, limit: %d bytes)",
file.Name, file.Size, iteminfo.LargeFileSizeThreshold)
logger.Warning(message)
return nil, errors.New(message)
}
ext := strings.ToLower(filepath.Ext(file.Name))
var (
err error
@@ -219,11 +439,51 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
}
return imageBytes, nil
} else if strings.HasPrefix(file.Type, "image") {
imageBytes, err = os.ReadFile(file.RealPath)
if err != nil {
logger.Errorf("Failed to read image file '%s' (path: %s): %v", file.Name, file.RealPath, err)
return nil, fmt.Errorf("failed to read image file: %w", err)
// Stream from file instead of os.ReadFile so we don't load every image fully into memory.
// ResizeWithSize() holds the imaging semaphore and only reads in chunks / decodes one at a time.
f, openErr := os.Open(file.RealPath)
if openErr != nil {
logger.Errorf("Failed to open image file '%s' (path: %s): %v", file.Name, file.RealPath, openErr)
return nil, fmt.Errorf("failed to open image file: %w", openErr)
}
defer f.Close()
imageBytes, err = service.CreatePreviewFromReaderWithSize(f, previewSize, file.Size)
if err != nil {
// Check if this is an unsupported JPEG format and FFmpeg fallback is enabled
errMsg := err.Error()
if strings.HasPrefix(file.Type, "image/jpeg") &&
(strings.Contains(errMsg, "unrecognised marker") ||
strings.Contains(errMsg, "invalid JPEG format") ||
strings.Contains(errMsg, "Huffman")) {
enableJPEGFallback := *settings.Config.Integrations.Media.Convert.ImagePreview[settings.JPEGImagePreview]
if enableJPEGFallback && service.ffmpegService != nil {
logger.Debugf("JPEG decode failed for '%s', falling back to FFmpeg: %v", file.Name, err)
imageBytes, err = service.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to resize preview image with FFmpeg fallback: %w", err)
}
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache FFmpeg-converted image: %v", err)
}
return imageBytes, nil
}
if !enableJPEGFallback {
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg conversion disabled in settings): %w", err)
}
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg not available): %w", err)
}
return nil, fmt.Errorf("failed to create image preview: %w", err)
}
if len(imageBytes) < 100 {
logger.Errorf("Generated image too small for '%s' (type: %s): %d bytes", file.Name, file.Type, len(imageBytes))
return nil, fmt.Errorf("generated image is too small, likely an error occurred: %d bytes", len(imageBytes))
}
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache image: %v", err)
}
return imageBytes, nil
} else if strings.HasPrefix(file.Type, "video") {
// Check if this video format is enabled for preview generation
ext = strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
@@ -284,7 +544,7 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
enableJPEGFallback := *settings.Config.Integrations.Media.Convert.ImagePreview[settings.JPEGImagePreview]
// Only attempt FFmpeg fallback if it's enabled and FFmpeg service is available
if enableJPEGFallback && service.imageService != nil {
if enableJPEGFallback && service.ffmpegService != nil {
// Fall back to FFmpeg for problematic JPEG files
logger.Debugf("JPEG decode failed for '%s', falling back to FFmpeg: %v", file.Name, err)
resizedBytes, err = service.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
@@ -332,30 +592,44 @@ func GeneratePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previe
}
func (s *Service) CreatePreview(data []byte, previewSize string) ([]byte, error) {
var (
width int
height int
options []Option
)
return s.CreatePreviewFromReader(bytes.NewReader(data), previewSize)
}
// CreatePreviewFromReader resizes an image from a reader (e.g. *os.File) without loading it fully into memory.
// Used for the image preview path so only concurrencyLimit decodes run at once.
func (s *Service) CreatePreviewFromReader(reader io.Reader, previewSize string) ([]byte, error) {
return s.CreatePreviewFromReaderWithSize(reader, previewSize, 0)
}
// CreatePreviewFromReaderWithSize resizes an image with file size information for semaphore selection.
func (s *Service) CreatePreviewFromReaderWithSize(reader io.Reader, previewSize string, fileSize int64) ([]byte, error) {
var options ResizeOptions
switch previewSize {
case "large":
width, height = 640, 640
options = []Option{WithMode(ResizeModeFit), WithQuality(QualityHigh), WithFormat(FormatJpeg)}
options = ResizeOptions{
Width: 640,
Height: 640,
ResizeMode: ResizeModeFit,
Quality: QualityHigh,
Format: FormatJpeg,
}
case "small":
width, height = 256, 256
options = []Option{WithMode(ResizeModeFit), WithQuality(QualityMedium), WithFormat(FormatJpeg)}
options = ResizeOptions{
Width: 256,
Height: 256,
ResizeMode: ResizeModeFit,
Quality: QualityMedium,
Format: FormatJpeg,
}
default:
return nil, ErrUnsupportedFormat
}
input := bytes.NewReader(data)
output := &bytes.Buffer{}
if err := s.Resize(input, width, height, output, options...); err != nil {
if err := s.ResizeWithSize(reader, output, fileSize, options); err != nil {
return nil, err
}
return output.Bytes(), nil
}
+5 -5
View File
@@ -14,9 +14,9 @@ import (
// percentageSeek: percentage of video duration to seek to (0–100).
// Returns: JPEG image bytes.
func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath string, percentageSeek int) ([]byte, error) {
if s.videoService == nil {
logger.Errorf("Video service not available for file '%s'", videoPath)
return nil, fmt.Errorf("video service not available")
if s.ffmpegService == nil {
logger.Errorf("FFmpeg service not available for file '%s'", videoPath)
return nil, fmt.Errorf("FFmpeg service not available")
}
// Validate file exists before processing
@@ -26,14 +26,14 @@ func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath string, pe
}
var buf bytes.Buffer
err := s.videoService.GenerateVideoPreviewStreaming(ctx, videoPath, percentageSeek, &buf)
err := s.ffmpegService.GenerateVideoPreviewStreaming(ctx, videoPath, percentageSeek, &buf)
if err != nil {
return nil, err
}
previewBytes := buf.Bytes()
if len(previewBytes) == 0 {
logger.Errorf("Video service returned empty result for '%s'", videoPath)
logger.Errorf("FFmpeg service returned empty result for '%s'", videoPath)
return nil, fmt.Errorf("video preview generation returned empty result")
}
+4
View File
@@ -4332,6 +4332,10 @@ const docTemplate = `{
"description": "display name",
"type": "string"
},
"oidcLoginButtonText": {
"description": "text to display on the OIDC login button",
"type": "string"
},
"styling": {
"$ref": "#/definitions/settings.StylingConfig"
}
+4
View File
@@ -4321,6 +4321,10 @@
"description": "display name",
"type": "string"
},
"oidcLoginButtonText": {
"description": "text to display on the OIDC login button",
"type": "string"
},
"styling": {
"$ref": "#/definitions/settings.StylingConfig"
}
+3
View File
@@ -655,6 +655,9 @@ definitions:
name:
description: display name
type: string
oidcLoginButtonText:
description: text to display on the OIDC login button
type: string
styling:
$ref: '#/definitions/settings.StylingConfig'
type: object
+3 -3
View File
@@ -4877,9 +4877,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash.merge": {
+1
View File
@@ -119,6 +119,7 @@ frontend:
favicon: "" # path to a favicon to use for the frontend
description: "FileBrowser Quantum is a file manager for the web which can be used to manage files on your server" # description that shows up in html head meta description
loginIcon: "" # path to an image file for the login page icon
oidcLoginButtonText: "" # text to display on the OIDC login button
userDefaults:
editorQuickSave: false # show quick save button in editor
hideSidebarFileActions: false # hide the file actions in the sidebar
File renamed without changes.

Before

Width:  |  Height:  |  Size: 949 B

After

Width:  |  Height:  |  Size: 949 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 546 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 969 B

@@ -1,13 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- Light background circle that matches light mode -->
<circle cx="16" cy="16" r="16" fill="#f5f5f5"/>
<!-- Back folder (orange, barely higher and left) -->
<path d="M5 8h6l2 2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2z"
fill="#FF9800"/>
<!-- Front folder (blue, almost same position) -->
<path d="M6 9h6l2 2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V11a2 2 0 0 1 2-2z"
fill="#2196F3"/>
</svg>

Before

Width:  |  Height:  |  Size: 527 B

File renamed without changes.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

-10
View File
@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- Back folder (orange, barely higher and left) -->
<path d="M5 8h6l2 2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2z"
fill="#FF9800"/>
<!-- Front folder (blue, almost same position) -->
<path d="M6 9h6l2 2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V11a2 2 0 0 1 2-2z"
fill="#2196F3"/>
</svg>

Before

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

@@ -1,24 +0,0 @@
{
"name": "FileBrowser Quantum",
"short_name": "FileBrowser Quantum",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-256x256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#2096f3",
"background_color": "#ffffff",
"display": "standalone"
}
+96
View File
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<symbol id="plyr-airplay" viewBox="0 0 18 18">
<path
d="M16 1H2a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3v-2H3V3h12v8h-2v2h3a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1"
/>
<path d="M4 17h10l-5-6z" />
</symbol>
<symbol id="plyr-captions-off" viewBox="0 0 18 18">
<path
fill-opacity=".5"
fill-rule="evenodd"
d="M1 1c-.6 0-1 .4-1 1v11c0 .6.4 1 1 1h4.6l2.7 2.7c.2.2.4.3.7.3s.5-.1.7-.3l2.7-2.7H17c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1zm4.52 10.15c1.99 0 3.01-1.32 3.28-2.41l-1.29-.39c-.19.66-.78 1.45-1.99 1.45-1.14 0-2.2-.83-2.2-2.34 0-1.61 1.12-2.37 2.18-2.37 1.23 0 1.78.75 1.95 1.43l1.3-.41C8.47 4.96 7.46 3.76 5.5 3.76c-1.9 0-3.61 1.44-3.61 3.7s1.65 3.69 3.63 3.69m7.57 0c1.99 0 3.01-1.32 3.28-2.41l-1.29-.39c-.19.66-.78 1.45-1.99 1.45-1.14 0-2.2-.83-2.2-2.34 0-1.61 1.12-2.37 2.18-2.37 1.23 0 1.78.75 1.95 1.43l1.3-.41c-.28-1.15-1.29-2.35-3.25-2.35-1.9 0-3.61 1.44-3.61 3.7s1.65 3.69 3.63 3.69"
/>
</symbol>
<symbol id="plyr-captions-on" viewBox="0 0 18 18">
<path
fill-rule="evenodd"
d="M1 1c-.6 0-1 .4-1 1v11c0 .6.4 1 1 1h4.6l2.7 2.7c.2.2.4.3.7.3s.5-.1.7-.3l2.7-2.7H17c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1zm4.52 10.15c1.99 0 3.01-1.32 3.28-2.41l-1.29-.39c-.19.66-.78 1.45-1.99 1.45-1.14 0-2.2-.83-2.2-2.34 0-1.61 1.12-2.37 2.18-2.37 1.23 0 1.78.75 1.95 1.43l1.3-.41C8.47 4.96 7.46 3.76 5.5 3.76c-1.9 0-3.61 1.44-3.61 3.7s1.65 3.69 3.63 3.69m7.57 0c1.99 0 3.01-1.32 3.28-2.41l-1.29-.39c-.19.66-.78 1.45-1.99 1.45-1.14 0-2.2-.83-2.2-2.34 0-1.61 1.12-2.37 2.18-2.37 1.23 0 1.78.75 1.95 1.43l1.3-.41c-.28-1.15-1.29-2.35-3.25-2.35-1.9 0-3.61 1.44-3.61 3.7s1.65 3.69 3.63 3.69"
/>
</symbol>
<symbol id="plyr-download" viewBox="0 0 18 18">
<path
d="M9 13c.3 0 .5-.1.7-.3L15.4 7 14 5.6l-4 4V1H8v8.6l-4-4L2.6 7l5.7 5.7c.2.2.4.3.7.3m-7 2h14v2H2z"
/>
</symbol>
<symbol id="plyr-enter-fullscreen" viewBox="0 0 18 18">
<path
d="M10 3h3.6l-4 4L11 8.4l4-4V8h2V1h-7zM7 9.6l-4 4V10H1v7h7v-2H4.4l4-4z"
/>
</symbol>
<symbol id="plyr-exit-fullscreen" viewBox="0 0 18 18">
<path
d="M1 12h3.6l-4 4L2 17.4l4-4V17h2v-7H1zM16 .6l-4 4V1h-2v7h7V6h-3.6l4-4z"
/>
</symbol>
<symbol id="plyr-fast-forward" viewBox="0 0 18 18">
<path d="M7.875 7.171 0 1v16l7.875-6.171V17L18 9 7.875 1z" />
</symbol>
<symbol id="plyr-logo-vimeo" viewBox="0 0 18 18">
<path
d="M17 5.3c-.1 1.6-1.2 3.7-3.3 6.4-2.2 2.8-4 4.2-5.5 4.2-.9 0-1.7-.9-2.4-2.6C5 10.9 4.4 6 3 6c-.1 0-.5.3-1.2.8l-.8-1c.8-.7 3.5-3.4 4.7-3.5S7.7 3 8 4.8c.3 2 .8 6.1 1.8 6.1.9 0 2.5-3.4 2.6-4 .1-.9-.3-1.9-2.3-1.1.8-2.6 2.3-3.8 4.5-3.8q2.55.15 2.4 3.3"
/>
</symbol>
<symbol id="plyr-logo-youtube" viewBox="0 0 18 18">
<path
d="M16.8 5.8c-.2-1.3-.8-2.2-2.2-2.4C12.4 3 9 3 9 3s-3.4 0-5.6.4C2 3.6 1.3 4.5 1.2 5.8 1 7.1 1 9 1 9s0 1.9.2 3.2.8 2.2 2.2 2.4C5.6 15 9 15 9 15s3.4 0 5.6-.4c1.4-.3 2-1.1 2.2-2.4S17 9 17 9s0-1.9-.2-3.2M7 12V6l5 3z"
/>
</symbol>
<symbol id="plyr-muted" viewBox="0 0 18 18">
<path
d="m12.4 12.5 2.1-2.1 2.1 2.1 1.4-1.4L15.9 9 18 6.9l-1.4-1.4-2.1 2.1-2.1-2.1L11 6.9 13.1 9 11 11.1zM3.786 6.008H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601z"
/>
</symbol>
<symbol id="plyr-pause" viewBox="0 0 18 18">
<path
d="M6 1H3c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1m6 0c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1z"
/>
</symbol>
<symbol id="plyr-pip" viewBox="0 0 18 18">
<path d="M13.293 3.293 7.022 9.564l1.414 1.414 6.271-6.271L17 7V1h-6z" />
<path
d="M13 15H3V5h5V3H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-6h-2z"
/>
</symbol>
<symbol id="plyr-play" viewBox="0 0 18 18">
<path
d="M15.562 8.1 3.87.225c-.818-.562-1.87 0-1.87.9v15.75c0 .9 1.052 1.462 1.87.9L15.563 9.9c.584-.45.584-1.35 0-1.8"
/>
</symbol>
<symbol id="plyr-restart" viewBox="0 0 18 18">
<path
d="m9.7 1.2.7 6.4 2.1-2.1c1.9 1.9 1.9 5.1 0 7-.9 1-2.2 1.5-3.5 1.5s-2.6-.5-3.5-1.5c-1.9-1.9-1.9-5.1 0-7 .6-.6 1.4-1.1 2.3-1.3l-.6-1.9C6 2.6 4.9 3.2 4 4.1 1.3 6.8 1.3 11.2 4 14c1.3 1.3 3.1 2 4.9 2 1.9 0 3.6-.7 4.9-2 2.7-2.7 2.7-7.1 0-9.9L16 1.9z"
/>
</symbol>
<symbol id="plyr-rewind" viewBox="0 0 18 18">
<path d="M10.125 1 0 9l10.125 8v-6.171L18 17V1l-7.875 6.171z" />
</symbol>
<symbol id="plyr-settings" viewBox="0 0 18 18">
<path
d="M16.135 7.784a2 2 0 0 1-1.23-2.969c.322-.536.225-.998-.094-1.316l-.31-.31c-.318-.318-.78-.415-1.316-.094a2 2 0 0 1-2.969-1.23C10.065 1.258 9.669 1 9.219 1h-.438c-.45 0-.845.258-.997.865a2 2 0 0 1-2.969 1.23c-.536-.322-.999-.225-1.317.093l-.31.31c-.318.318-.415.781-.093 1.317a2 2 0 0 1-1.23 2.969C1.26 7.935 1 8.33 1 8.781v.438c0 .45.258.845.865.997a2 2 0 0 1 1.23 2.969c-.322.536-.225.998.094 1.316l.31.31c.319.319.782.415 1.316.094a2 2 0 0 1 2.969 1.23c.151.607.547.865.997.865h.438c.45 0 .845-.258.997-.865a2 2 0 0 1 2.969-1.23c.535.321.997.225 1.316-.094l.31-.31c.318-.318.415-.781.094-1.316a2 2 0 0 1 1.23-2.969c.607-.151.865-.547.865-.997v-.438c0-.451-.26-.846-.865-.997M9 12a3 3 0 1 1 0-6 3 3 0 0 1 0 6"
/>
</symbol>
<symbol id="plyr-volume" viewBox="0 0 18 18">
<path
d="M15.6 3.3c-.4-.4-1-.4-1.4 0s-.4 1 0 1.4C15.4 5.9 16 7.4 16 9s-.6 3.1-1.8 4.3c-.4.4-.4 1 0 1.4.2.2.5.3.7.3.3 0 .5-.1.7-.3C17.1 13.2 18 11.2 18 9s-.9-4.2-2.4-5.7"
/>
<path
d="M11.282 5.282a.91.91 0 0 0 0 1.316c.735.735.995 1.458.995 2.402 0 .936-.425 1.917-.995 2.487a.91.91 0 0 0 0 1.316c.145.145.636.262 1.018.156a.7.7 0 0 0 .298-.156C13.773 11.733 14.13 10.16 14.13 9q.001-.255-.011-.51c-.053-.992-.319-2.005-1.522-3.208a.91.91 0 0 0-1.316 0m-7.495.726H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601z"
/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 5.9 KiB

+12 -10
View File
@@ -57,20 +57,22 @@
"short_name": window.globalVars.name || 'FileBrowser',
"icons": [
{
"src": window.location.origin + "{{ .htmlVars.staticURL }}/img/icons/android-chrome-512x512.png",
"sizes": "512x512",
"src": "{{ .htmlVars.pwaIcon192 }}",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "{{ .htmlVars.pwaIcon256 }}",
"sizes": "256x256",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": window.location.origin + "{{ .htmlVars.staticURL }}/img/icons/android-chrome-256x256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": window.location.origin + "{{ .htmlVars.staticURL }}/img/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
"src": "{{ .htmlVars.pwaIcon512 }}",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
],
"start_url": window.location.origin + "{{ .htmlVars.baseURL }}",
+11 -11
View File
@@ -1,5 +1,5 @@
import { fetchURL, adjustedData } from './utils'
import { getApiPath, doubleEncode, getPublicApiPath } from '@/utils/url.js'
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { state, mutations } from '@/store'
import { notify } from '@/notify'
import { globalVars } from '@/utils/constants'
@@ -12,8 +12,8 @@ export async function fetchFiles(source, path, content = false, metadata = false
}
try {
const apiPath = getApiPath('api/resources', {
path: doubleEncode(path),
source: doubleEncode(source),
path: encodeURIComponent(path),
source: encodeURIComponent(source),
...(content && { content: 'true' }),
...(metadata && { metadata: 'true' })
})
@@ -32,8 +32,8 @@ async function resourceAction(source, path, method, content) {
throw new Error('no source provided')
}
try {
source = doubleEncode(source)
path = doubleEncode(path)
source = encodeURIComponent(source)
path = encodeURIComponent(path)
const apiPath = getApiPath('api/resources', { path, source })
let opts = { method }
if (content) {
@@ -155,7 +155,7 @@ export async function download(format, files, shareHash = "") {
algo: format,
...(shareHash && { hash: shareHash }),
...(!shareHash && source && { source: encodeURIComponent(source) }),
...(state.share.token && { token: state.share.token }),
...(state.shareInfo.token && { token: state.shareInfo.token }),
sessionId: state.sessionId
}
@@ -207,7 +207,7 @@ async function downloadChunked(file, shareHash = "") {
file: encodeURIComponent(file.path),
...(shareHash && { hash: shareHash }),
...(!shareHash && file.source && { source: encodeURIComponent(file.source) }),
...(state.share.token && { token: state.share.token }),
...(state.shareInfo.token && { token: state.shareInfo.token }),
sessionId: state.sessionId
}
@@ -360,8 +360,8 @@ export function post(
}
try {
const apiPath = getApiPath("api/resources", {
path: doubleEncode(path),
source: doubleEncode(source),
path: encodeURIComponent(path),
source: encodeURIComponent(source),
override: overwrite,
...(isDir && { isDir: 'true' })
});
@@ -496,8 +496,8 @@ export async function checksum(source, path, algo) {
}
try {
const params = {
path: doubleEncode(path),
source: doubleEncode(source),
path: encodeURIComponent(path),
source: encodeURIComponent(source),
checksum: algo
}
const apiPath = getApiPath('api/resources', params)
+12 -10
View File
@@ -1,6 +1,6 @@
import { adjustedData } from "./utils";
import { notify } from "@/notify";
import { getPublicApiPath, encodedPath, doubleEncode } from "@/utils/url.js";
import { getPublicApiPath, encodedPath } from "@/utils/url.js";
import { state } from "@/store";
// ============================================================================
@@ -23,7 +23,7 @@ export async function fetchPub(path, hash, password = "", content = false, metad
hash,
...(content && { content: 'true' }),
...(metadata && { metadata: 'true' }),
...(state.share.token && { token: state.share.token })
...(state.shareInfo.token && { token: state.shareInfo.token })
}
const apiPath = getPublicApiPath("resources", params);
const response = await fetch(apiPath, {
@@ -76,16 +76,18 @@ export function getDownloadURL(share, files, inline=false) {
// Generate a preview URL for public shares
/**
* @param {string} path
* @param {string} size - The size parameter (small, large, original). Omit for default (small).
* @returns {string}
*/
export function getPreviewURL(path,size="small") {
export function getPreviewURL(path, size) {
try {
const params = {
path: encodeURIComponent(path),
size: size,
hash: state.share.hash,
hash: state.shareInfo.hash,
inline: 'true',
...(state.share.token && { token: state.share.token })
// Only add size parameter if specified and not the default
...(size && size !== 'small' && { size: size }),
...(state.shareInfo.token && { token: state.shareInfo.token })
}
const apiPath = getPublicApiPath('preview', params)
return window.origin + apiPath
@@ -113,7 +115,7 @@ export function post(
}
try {
const apiPath = getPublicApiPath("resources", {
targetPath: doubleEncode(path),
targetPath: encodeURIComponent(path),
hash: hash,
override: overwrite,
...(isDir && { isDir: 'true' })
@@ -205,7 +207,7 @@ async function resourceAction(hash, path, method, content, token = "") {
if (sharePassword) {
headers["X-SHARE-PASSWORD"] = sharePassword;
}
path = doubleEncode(path)
path = encodeURIComponent(path)
const apiPath = getPublicApiPath('resources', { path, hash: hash, token: token })
const response = await fetch(apiPath, {
method,
@@ -246,7 +248,7 @@ export async function bulkDelete(items) {
const params = {
hash: hash,
...(state.share.token && { token: state.share.token }),
...(state.shareInfo.token && { token: state.shareInfo.token }),
sessionId: state.sessionId
}
const apiPath = getPublicApiPath("resources/bulk/delete", params)
@@ -326,7 +328,7 @@ export async function moveCopy(
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(state.share.token && { 'X-Auth-Token': state.share.token })
...(state.shareInfo.token && { 'X-Auth-Token': state.shareInfo.token })
},
body: JSON.stringify(requestBody),
})
+84 -21
View File
@@ -17,7 +17,7 @@
<div class="context-menu-header">
<div
class="action button clickable"
v-if="!isSearchActive && permissions.create && !isShare"
v-if="showCreateButton"
@click="toggleShowCreate"
>
<i v-if="!showCreate" class="material-icons">add</i>
@@ -32,71 +32,77 @@
<span>{{ selectedCount }}</span>
</div>
</div>
<hr class="divider">
<hr v-if="!isDuplicateFinder" class="divider">
<action
v-if="showCreate && !isSearchActive"
v-if="showCreateActions"
icon="create_new_folder"
:label="$t('files.newFolder')"
@action="showHover('newDir')"
/>
<action
v-if="showCreate && !isSearchActive"
v-if="showCreateActions"
icon="note_add"
:label="$t('files.newFile')"
@action="showHover('newFile')"
/>
<action
v-if="showCreate && !isSearchActive"
v-if="showCreateActions"
icon="file_upload"
:label="$t('general.upload')"
@action="uploadFunc"
/>
<action
v-if="!showCreate && selectedCount == 1"
v-if="showInfo"
icon="info"
:label="$t('general.info')"
show="info"
@action="showInfoHover"
/>
<action
v-if="(!showCreate && permissions.download && selectedCount > 0)"
v-if="showDownload"
icon="file_download"
:label="$t('general.download')"
@action="startDownload"
:counter="selectedCount"
/>
<action
v-if="(showCreate || selectedCount == 1) && showShare"
v-if="showShareAction"
icon="share"
:label="$t('general.share')"
@action="showShareHover"
/>
<action
v-if="!showCreate && selectedCount == 1 && permissions.modify && !isSearchActive"
v-if="showRename"
icon="mode_edit"
:label="$t('general.rename')"
@action="showRenameHover"
/>
<action
v-if="!showCreate && selectedCount > 0 && permissions.modify"
v-if="showCopy"
icon="content_copy"
:label="$t('buttons.copyFile')"
show="copy"
/>
<action
v-if="!showCreate && selectedCount == 1 && isSearchActive"
v-if="showOpenParentFolder"
icon="folder"
:label="$t('buttons.openParentFolder')"
@action="openParentFolder"
/>
<action
v-if="!showCreate && selectedCount > 0 && permissions.modify"
v-if="showGoToItem"
icon="folder"
:label="$t('buttons.goToItem')"
@action="goToItem"
/>
<action
v-if="showMove"
icon="forward"
:label="$t('buttons.moveFile')"
show="move"
/>
<action
v-if="!showCreate && !isSearchActive && req?.items?.length > 0"
v-if="showSelectAll"
icon="select_all"
:label="$t('buttons.selectAll')"
@action="selectAllItems"
@@ -138,7 +144,7 @@
class="button no-select fb-shadow"
:class="{ 'dark-mode': isDarkMode }"
>
<action icon="info" :label="$t('general.info')" show="info" />
<action icon="info" :label="$t('general.info')" @action="showInfoHover"/>
<action v-if="showGoToRaw" icon="open_in_new" :label="$t('general.openFile')" @action="goToRaw()" />
<action v-if="shouldShowParentFolder()" icon="folder" :label="$t('buttons.openParentFolder')" @action="openParentFolder" />
<action v-if="isPreview && permissions.modify" icon="mode_edit" :label="$t('general.rename')" @action="showRenameHoverForPreview" />
@@ -181,6 +187,12 @@ export default {
},
},
computed: {
showGoToItem() {
return this.isDuplicateFinder && this.selectedCount == 1;
},
isDuplicateFinder() {
return getters.currentView() === "duplicateFinder";
},
permissions() {
return getters.permissions();
},
@@ -190,7 +202,47 @@ export default {
isShare() {
return getters.isShare();
},
showCreateActions() {
if (this.isDuplicateFinder) return false;
return this.showCreate && !this.isSearchActive;
},
showInfo() {
if (this.isDuplicateFinder) return this.selectedCount == 1;
return !this.showCreate && this.selectedCount == 1;
},
showDownload() {
if (this.isDuplicateFinder) return false;
return !this.showCreate && this.permissions.download && this.selectedCount > 0;
},
showShareAction() {
if (this.isDuplicateFinder) return false;
return (this.showCreate || this.selectedCount == 1) && this.showShare;
},
showRename() {
if (this.isDuplicateFinder) return false;
return !this.showCreate && this.selectedCount == 1 && this.permissions.modify && !this.isSearchActive;
},
showCopy() {
if (this.isDuplicateFinder) return false;
return !this.showCreate && this.selectedCount > 0 && this.permissions.modify;
},
showOpenParentFolder() {
return !this.showCreate && this.selectedCount == 1 && (this.isSearchActive || this.isDuplicateFinder);
},
showMove() {
if (this.isDuplicateFinder) return false;
return !this.showCreate && this.selectedCount > 0 && this.permissions.modify;
},
showSelectAll() {
if (this.isDuplicateFinder) return false;
return !this.showCreate && !this.isSearchActive && this.req?.items?.length > 0;
},
showCreateButton() {
if (this.isDuplicateFinder) return false;
return !this.isSearchActive && this.permissions.create && !this.isShare;
},
showSelectMultiple() {
if (this.isDuplicateFinder) return false;
if (this.isMultiple || this.isSearchActive) {
return false;
}
@@ -217,6 +269,7 @@ export default {
return cv == "markdownViewer" && this.permissions.modify;
},
showDelete() {
if (this.isDuplicateFinder) return false;
if (this.selectedCount == 0) {
return false;
}
@@ -246,6 +299,7 @@ export default {
return getters.currentPromptName() == "OverflowMenu";
},
showAccess() {
if (this.isDuplicateFinder) return false;
if (getters.isShare()) {
return false;
}
@@ -316,6 +370,18 @@ export default {
}
},
methods: {
showInfoHover() {
mutations.showHover({
name: "info",
props: {
item: getters.getFirstSelected(),
},
});
},
goToItem() {
const item = getters.getFirstSelected();
url.goToItem(item.source, item.path, {}, true);
},
hideTooltip() {
mutations.hideTooltip();
},
@@ -525,12 +591,9 @@ export default {
});
},
openParentFolder() {
const item = state.selected.length > 0 ? state.selected[0] : state.req;
let parentPath = url.removeLastDir(item.path);
if (parentPath == "") {
parentPath = "/";
}
url.goToItem(state.req.source, parentPath, {});
const item = getters.getFirstSelected();
const parentPath = url.removeLastDir(item.path) || "/";
url.goToItem(item.source, parentPath, {}, this.isDuplicateFinder);
},
selectAllItems() {
if (state.req && state.req.items && state.req.items.length > 0) {
+1 -1
View File
@@ -207,7 +207,7 @@ export default {
barColor = '#ff9800';
}
let percentage = this.pct > 1 && this.pct < 7 ? 6 : this.pct;
let percentage = this.pct >= 1 && this.pct < 7 ? 6 : this.pct;
if (percentage < 1) {
percentage = 0;
}
+1 -1
View File
@@ -929,7 +929,7 @@ body.rtl #search .boxes h3 {
overflow: hidden;
}
@media (max-width: 800px) {
@media (max-width: 768px) {
#search {
min-width: unset;
max-width: 60%;
+11 -5
View File
@@ -4,6 +4,7 @@
v-show="!isHidden && showShelf"
id="shelf"
:class="{ 'add-padding': addPadding }"
:style="moveWithSidebar"
>
<breadcrumbs v-if="showBreadcrumbs" :base="isShare ? `/share/${shareHash}` : undefined" />
<listing-header v-if="showListingHeader" :hasDuration="hasDuration" />
@@ -77,6 +78,14 @@ export default {
// Show duplicate finder actions when on that route and there are selected items
return this.duplicateFinderSelectedCount > 0;
},
moveWithSidebar() {
if (getters.isStickySidebar() && getters.isSidebarVisible()) {
return {
left: state.sidebar.width + 'em',
};
}
return {};
},
},
mounted() {
this.$nextTick(() => {
@@ -195,7 +204,7 @@ export default {
z-index: 1000;
right: 0;
left: 0;
transition: 0.3s ease;
transition: 0.2s ease;
box-sizing: border-box;
height: auto;
min-height: 0;
@@ -204,7 +213,7 @@ export default {
.shelf-slide-enter-active,
.shelf-slide-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
transition: transform 0.3s ease, opacity 0.3s ease !important;
}
.shelf-slide-enter-from,
@@ -227,7 +236,4 @@ export default {
}
}
#main.moveWithSidebar #shelf {
padding-left: 20.5em;
}
</style>
+14 -8
View File
@@ -1,5 +1,5 @@
<template>
<div id="status-bar" :class="{ 'dark-mode-header': isDarkMode, 'active': showStatusBar }" @contextmenu.prevent.stop @touchstart.stop @touchend.stop>
<div id="status-bar" :style="moveWithSidebar" :class="{ 'dark-mode-header': isDarkMode, 'active': showStatusBar }" @contextmenu.prevent.stop @touchstart.stop @touchend.stop>
<div class="status-content" @contextmenu.prevent.stop @touchstart.stop @touchend.stop>
<div class="status-info">
<span v-if="selectedCount > 0">
@@ -42,6 +42,9 @@ export default {
},
computed: {
showStatusBar() {
if (getters.isShare() && state.shareInfo.shareType === "upload") {
return false;
}
return getters.currentView() === "listingView";
},
isDarkMode() {
@@ -108,6 +111,14 @@ export default {
directoryInfoText() {
return `${this.numDirs} ${this.foldersLabel} | ${this.numFiles} ${this.filesLabel} (${this.displayTotalSize})`;
},
moveWithSidebar() {
if (getters.isStickySidebar() && getters.isSidebarVisible()) {
return {
left: state.sidebar.width + 'em',
};
}
return {};
},
},
methods: {
updateGallerySize(event) {
@@ -167,7 +178,7 @@ export default {
overflow: hidden;
margin: 0;
padding: 0;
transition: bottom 0.5s ease, margin-left 0.5s ease;
transition: bottom 0.5s ease, left 0.2s ease, width 0.2s ease;
pointer-events: none;
}
@@ -176,10 +187,6 @@ export default {
pointer-events: auto;
}
#status-bar.moveWithSidebar {
margin-left: 20em;
}
.status-content {
display: flex;
align-items: center;
@@ -238,7 +245,7 @@ input[type="range"] {
}
/* Mobile styles */
@media (max-width: 800px) {
@media (max-width: 768px) {
#status-bar {
height: 3em;
bottom: -3em;
@@ -261,7 +268,6 @@ input[type="range"] {
input[type="range"] {
width: 7em;
height: 1.5em; /* For better touch */
}
.status-info {
@@ -190,14 +190,6 @@ export default {
const currentPath = state.req.path;
const source = state.req.source;
// debug log paths when dropping for testing
console.log("Drop target:", {
link,
targetPath,
currentPath,
items: this.items
});
// Normalize paths for comparison
const normalizePath = (path) => {
if (!path || path === "/") return "/";
@@ -209,7 +201,7 @@ export default {
if (normalizedTarget === normalizedCurrent) {
notify.showErrorToast(this.$t("files.sameFolder"));
console.log("Cannot move to same folder");
console.error("Cannot move to same folder");
return;
}
+220 -109
View File
@@ -1,35 +1,62 @@
<template>
<div class="image-ex-container" ref="container" @touchstart="touchStart" @touchmove="touchMove" @touchend="touchEnd" @dblclick="zoomAuto"
@mousedown="mousedownStart" @mousemove="mouseMove" @mouseup="mouseUp" @wheel="wheelMove">
<div v-if="!isLoaded">{{ $t('general.loading', { suffix: "..." }) }}</div>
<!-- Thumbnail placeholder (shown while full image loads, only if cached thumbnail exists) -->
<img
v-if="cachedThumbnailUrl && !fullImageLoaded && !isTiff"
:src="cachedThumbnailUrl"
class="image-ex-img"
ref="thumbnail"
/>
<!-- Loading spinner overlay (shown while full image loads) -->
<div v-if="!fullImageLoaded" class="image-loading-overlay">
<LoadingSpinner size="medium" />
</div>
<img v-if="!isTiff && isLoaded" :src="src" class="image-ex-img" ref="imgex" @load="onLoad" />
<canvas v-else-if="isLoaded" ref="imgex" class="image-ex-img"></canvas>
<!-- Full image:
- Always loading in background via JavaScript
- Hidden until loaded if thumbnail exists, otherwise visible for progressive loading -->
<img
v-if="!isTiff"
class="image-ex-img"
ref="imgex"
@load="onLoad"
@error="onImageError"
:style="{ display: (cachedThumbnailUrl && !fullImageLoaded) ? 'none' : 'block' }"
/>
<canvas
v-else
ref="imgex"
class="image-ex-img"
:style="{ display: (cachedThumbnailUrl && !fullImageLoaded) ? 'none' : 'block' }"
></canvas>
</div>
</template>
<script>
import { state, mutations } from "@/store";
import { state, mutations, getters } from "@/store";
import throttle from "@/utils/throttle";
import { notify } from "@/notify";
import LoadingSpinner from "@/components/LoadingSpinner.vue";
import { getBestCachedImage } from "@/utils/imageCache";
import { globalVars } from "@/utils/constants";
export default {
components: {
LoadingSpinner,
},
props: {
src: String,
moveDisabledTime: {
type: Number,
default: () => 200,
},
classList: {
type: Array,
default: () => [],
},
zoomStep: {
type: Number,
default: () => 0.25,
src: {
type: String,
default: null,
},
},
data() {
return {
moveDisabledTime: 200,
classList: [],
zoomStep: 0.25,
scale: 1,
lastX: null,
lastY: null,
@@ -39,13 +66,15 @@ export default {
moveDisabled: false,
disabledTimer: null,
imageLoaded: false,
fullImageLoaded: false,
loadTimeout: null,
position: {
center: { x: 0, y: 0 },
relative: { x: 0, y: 0 },
},
maxScale: 4,
minScale: 1, // Minimum scale is 1 (full frame view)
isTiff: false, // Determine if the image is a TIFF
minScale: 1,
isTiff: false,
// Swipe navigation properties
swipeStartTime: null,
swipeStartX: 0,
@@ -54,19 +83,55 @@ export default {
swipeCurrentY: 0,
isSwipeGesture: false,
hasStartedSwipe: false,
gestureDecided: false, // Track if we've made a decision about this gesture
swipeMinDistance: 150, // Minimum horizontal distance for swipe
swipeMaxTime: 500, // Maximum time for swipe in milliseconds
swipeMaxVerticalDistance: 50, // Maximum vertical movement to still be considered horizontal swipe
gestureDecided: false,
swipeMinDistance: 150,
swipeMaxTime: 500,
swipeMaxVerticalDistance: 50,
};
},
computed: {
source() {
return state.req?.source;
},
path() {
return state.req?.path;
},
isLoaded() {
return !("preview-img" in (state?.loading || {}));
},
cachedThumbnailUrl() {
if (!state?.req) {
return null;
}
if (!this.path || !state.req.hasPreview) {
return null;
}
// Don't use thumbnail for HEIC files that need conversion
const showFullSizeHeic = state.req?.type === "image/heic" && !state.isSafari && globalVars.mediaAvailable && !globalVars.disableHeicConversion;
if (showFullSizeHeic) {
return null;
}
// Get cached thumbnail URL (prefers large, falls back to small)
// For shares, use shareInfo.hash as the source; otherwise use this.source
const source = getters.isShare() ? state.shareInfo?.hash : this.source;
return getBestCachedImage(source, this.path, state.req?.modified);
},
},
mounted() {
this.isTiff = this.checkIfTiff(this.src);
// Step 1: Cache check happens automatically via thumbnailUrl computed property
// Step 2: Always start loading the real image
if (this.isTiff) {
this.decodeTiff(this.src);
} else {
this.$refs.imgex.src = this.src;
// Use nextTick to ensure element exists
this.$nextTick(() => {
this.loadFullImage();
});
}
let container = this.$refs.container;
this.classList.forEach((className) => container.classList.add(className));
if (getComputedStyle(container).width === "0px") {
@@ -79,39 +144,61 @@ export default {
window.addEventListener("resize", this.onResize);
},
beforeUnmount() {
// Clear any pending timeout
if (this.loadTimeout) {
clearTimeout(this.loadTimeout);
this.loadTimeout = null;
}
window.removeEventListener("resize", this.onResize);
document.removeEventListener("mouseup", this.onMouseUp);
},
computed: {
isLoaded() {
return !("preview-img" in state.loading);
},
},
watch: {
src: function () {
if (!this.src || !this.$refs.imgex) {
mutations.setLoading("preview-img", false);
return;
}
this.isTiff = this.checkIfTiff(this.src);
if (this.isTiff) {
this.decodeTiff(this.src);
} else {
this.$refs.imgex.src = this.src;
}
this.scale = 1; // Reset zoom level
this.position.relative = { x: 0, y: 0 }; // Reset position
this.showSpinner = true; // Show spinner while loading
this.resetSwipeTracking(); // Reset swipe tracking for new image
},
},
methods: {
loadFullImage() {
if (!this.src) return;
mutations.setLoading("preview-img", true);
// Set src directly via JavaScript to avoid Vue's HTML entity encoding in template bindings
// Vue HTML-encodes & to &amp; when using :src="src" in templates
this.$nextTick(() => {
if (this.$refs.imgex && 'src' in this.$refs.imgex) {
// Decode any HTML entities (Vue shouldn't encode props, but decode just in case)
const cleanSrc = String(this.src).replace(/&amp;/g, '&');
this.$refs.imgex.src = cleanSrc;
}
});
},
onLoad() {
// Step 3: Real image loaded - hide thumbnail and show image
this.imageLoaded = true;
this.setCenter(); // Center the image after loading
this.showSpinner = false;
this.fullImageLoaded = true;
// Clear the timeout if image loaded successfully
if (this.loadTimeout) {
clearTimeout(this.loadTimeout);
this.loadTimeout = null;
}
this.setCenter();
mutations.setLoading("preview-img", false);
},
onImageError(event) {
const img = event.target;
const actualSrc = img?.src || '';
// If the error is due to &amp; in URL, try to fix it
if (actualSrc && actualSrc.includes('&amp;')) {
const fixedSrc = actualSrc.replace(/&amp;/g, '&');
if (img) {
img.src = fixedSrc;
return; // Let it retry with fixed URL
}
}
this.imageLoaded = true;
this.fullImageLoaded = true;
mutations.setLoading("preview-img", false);
if (img) {
img.style.display = 'block';
}
},
checkIfTiff(src) {
const sufs = ["tif", "tiff", "dng", "cr2", "nef"];
const suff = src.split(".").pop().toLowerCase();
@@ -123,13 +210,11 @@ export default {
if (!response.ok) {
throw new Error("Network response was not ok");
}
const blob = await response.blob(); // Convert response to a blob
const blob = await response.blob();
const imgex = this.$refs.imgex;
if (imgex) {
// Create a URL for the blob and set it as the image source
imgex.src = URL.createObjectURL(blob);
imgex.onload = () => URL.revokeObjectURL(imgex.src); // Clean up URL object after loading
imgex.onload = () => URL.revokeObjectURL(imgex.src);
}
} catch (error) {
notify.showError("Error decoding TIFF");
@@ -147,16 +232,20 @@ export default {
setCenter() {
const container = this.$refs.container;
const img = this.$refs.imgex;
if (!container || !img || !img.clientWidth || !img.clientHeight) {
return; // Exit if dimensions are unavailable
return;
}
// Images are centered using CSS (top: 50%, left: 50%, transform: translate(-50%, -50%))
// Reset pan position when centering
this.position.relative = { x: 0, y: 0 };
this.position.center.x = Math.floor((container.clientWidth - img.clientWidth) / 2);
this.position.center.y = Math.floor((container.clientHeight - img.clientHeight) / 2);
img.style.left = `${this.position.center.x}px`;
img.style.top = `${this.position.center.y}px`;
// Update transform to reflect centered state
if (this.scale === 1) {
img.style.transform = 'translate(-50%, -50%) scale(1)';
} else {
img.style.transform = `translate(calc(-50% + ${this.position.relative.x}px), calc(-50% + ${this.position.relative.y}px)) scale(${this.scale})`;
}
},
mousedownStart(event) {
this.lastX = null;
@@ -177,8 +266,6 @@ export default {
this.lastX = null;
this.lastY = null;
this.lastTouchDistance = null;
// Initialize swipe tracking for single touch only when at full frame view (scale === 1)
if (event.targetTouches.length === 1 && this.scale === 1) {
const touch = event.targetTouches[0];
this.swipeStartTime = Date.now();
@@ -188,13 +275,10 @@ export default {
this.swipeCurrentY = touch.pageY;
this.isSwipeGesture = false;
this.hasStartedSwipe = false;
this.gestureDecided = false; // Reset decision for new touch
this.gestureDecided = false;
} else {
// Reset swipe tracking for multi-touch (zoom gestures) or when zoomed in
this.resetSwipeTracking();
}
if (event.targetTouches.length < 2) {
setTimeout(() => {
this.touches = 0;
@@ -205,9 +289,6 @@ export default {
event.preventDefault();
}
}
// Only prevent default if zoomed in (need to pan) or multi-touch (need to zoom)
// This allows nav-zone touches to work when at full frame view
if (this.scale > 1 || event.targetTouches.length >= 2) {
event.preventDefault();
}
@@ -230,45 +311,31 @@ export default {
event.preventDefault();
},
touchMove(event) {
// Update current swipe position for single touch, only when at full frame view
if (event.targetTouches.length === 1 && this.scale === 1) {
const touch = event.targetTouches[0];
this.swipeCurrentX = touch.pageX;
this.swipeCurrentY = touch.pageY;
// Only make gesture decision once per touch sequence
if (!this.gestureDecided) {
const deltaX = Math.abs(this.swipeCurrentX - this.swipeStartX);
const deltaY = Math.abs(this.swipeCurrentY - this.swipeStartY);
// Only decide after some meaningful movement
if (deltaX > 10 || deltaY > 10) {
this.gestureDecided = true; // Mark that we've made a decision
if (deltaX > deltaY * 2) {
// Horizontal movement is significantly more than vertical - it's a swipe
this.gestureDecided = true;
if (deltaX > deltaY * 2) {
this.isSwipeGesture = true;
event.preventDefault(); // Prevent scrolling during swipe
event.preventDefault();
} else {
// Not horizontal enough - it's a pan gesture
this.isSwipeGesture = false;
}
}
}
// If we've decided it's a swipe gesture, prevent default and don't do normal panning
if (this.gestureDecided && this.isSwipeGesture) {
event.preventDefault();
return; // Block normal pan behavior for swipes
return;
}
}
// Only prevent default if we're zoomed in or doing multi-touch zoom
if (this.scale > 1 || event.targetTouches.length >= 2) {
event.preventDefault();
}
// Normal touch move logic for pan/zoom (only runs if not a swipe gesture)
if (this.lastX === null) {
this.lastX = event.targetTouches[0].pageX;
this.lastY = event.targetTouches[0].pageY;
@@ -282,7 +349,6 @@ export default {
() => (this.moveDisabled = false),
this.moveDisabledTime
);
let p1 = event.targetTouches[0];
let p2 = event.targetTouches[1];
let touchDistance = Math.sqrt(
@@ -296,7 +362,6 @@ export default {
this.lastTouchDistance = touchDistance;
this.setZoom();
} else if (event.targetTouches.length === 1 && this.scale > 1) {
// Only allow panning when zoomed in
if (this.moveDisabled) return;
let x = event.targetTouches[0].pageX - this.lastX;
let y = event.targetTouches[0].pageY - this.lastY;
@@ -309,8 +374,11 @@ export default {
doMove(x, y) {
this.position.relative.x += x;
this.position.relative.y += y;
// Update the transform with separate translate and scale values
this.$refs.imgex.style.transform = `translate(${this.position.relative.x}px, ${this.position.relative.y}px) scale(${this.scale})`;
const img = this.$refs.imgex;
if (img) {
// Combine centering (-50%) with pan offset and scale
img.style.transform = `translate(calc(-50% + ${this.position.relative.x}px), calc(-50% + ${this.position.relative.y}px)) scale(${this.scale})`;
}
},
wheelMove(event) {
event.preventDefault()
@@ -319,30 +387,26 @@ export default {
},
setZoom() {
this.scale = Math.max(this.minScale, Math.min(this.maxScale, this.scale));
// If scale is back to 1 (full frame view), reset position to center
if (this.scale === 1) {
this.position.relative = { x: 0, y: 0 };
}
// Update the transform with both translate and scale values
this.$refs.imgex.style.transform = `translate(${this.position.relative.x}px, ${this.position.relative.y}px) scale(${this.scale})`;
const img = this.$refs.imgex;
if (img) {
// Combine centering (-50%) with pan offset and scale
img.style.transform = `translate(calc(-50% + ${this.position.relative.x}px), calc(-50% + ${this.position.relative.y}px)) scale(${this.scale})`;
}
},
pxStringToNumber(style) {
return +style.replace("px", "");
},
touchEnd(event) {
let handledSwipe = false;
// Only process swipe if it was a single touch, we detected a swipe gesture, and at full frame view
if (this.isSwipeGesture && this.swipeStartTime && this.scale === 1) {
const swipeEndTime = Date.now();
const swipeDuration = swipeEndTime - this.swipeStartTime;
const deltaX = this.swipeCurrentX - this.swipeStartX;
const deltaY = Math.abs(this.swipeCurrentY - this.swipeStartY);
const absDelataX = Math.abs(deltaX);
// Check if swipe meets criteria: fast, horizontal, and long enough
if (
swipeDuration <= this.swipeMaxTime &&
absDelataX >= this.swipeMinDistance &&
@@ -357,13 +421,9 @@ export default {
event.preventDefault();
}
}
// Only prevent default if we handled a swipe or were zoomed in (panning)
if (!handledSwipe && this.scale > 1) {
event.preventDefault();
}
// Reset swipe tracking
this.resetSwipeTracking();
},
resetSwipeTracking() {
@@ -374,7 +434,49 @@ export default {
this.swipeCurrentY = 0;
this.isSwipeGesture = false;
this.hasStartedSwipe = false;
this.gestureDecided = false; // Reset decision state
this.gestureDecided = false;
},
},
watch: {
src: function (newSrc) {
if (!newSrc) {
mutations.setLoading("preview-img", false);
return;
}
// Clear any existing timeout
if (this.loadTimeout) {
clearTimeout(this.loadTimeout);
this.loadTimeout = null;
}
// Reset and reload when src changes
this.fullImageLoaded = false;
this.imageLoaded = false;
this.isTiff = this.checkIfTiff(newSrc);
// Cache check happens automatically via thumbnailUrl computed property
// Always load the real image
if (this.isTiff) {
this.decodeTiff(newSrc);
} else {
this.$nextTick(() => {
this.loadFullImage();
// Set a timeout to handle cases where image never loads
this.loadTimeout = setTimeout(() => {
if (!this.fullImageLoaded && !this.imageLoaded) {
// Show the image even if load event didn't fire (might be partially loaded)
this.fullImageLoaded = true;
mutations.setLoading("preview-img", false);
}
}, 30000); // 30 second timeout
});
}
this.scale = 1;
this.position.relative = { x: 0, y: 0 };
this.resetSwipeTracking();
},
},
};
@@ -383,22 +485,31 @@ export default {
<style>
.image-ex-container {
max-width: 100%;
/* Image container max width */
max-height: 100%;
/* Image container max height */
overflow: hidden;
/* Hide overflow if image exceeds container */
position: relative;
/* Required for absolute positioning of child */
display: flex;
justify-content: center;
}
.image-ex-img {
max-width: 100%;
/* Image max width */
max-height: 100%;
/* Image max height */
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
object-fit: contain;
}
.image-loading-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
}
</style>
+54 -5
View File
@@ -33,10 +33,11 @@
import { globalVars } from "@/utils/constants";
import { getTypeInfo } from "@/utils/mimetype";
import { mutations, state, getters } from "@/store";
import { setImageLoaded } from "@/utils/imageCache";
// NEW: Define placeholder and error image URLs for easy configuration
const PLACEHOLDER_URL = globalVars.baseURL + "static/img/placeholder.png"; // A generic loading placeholder
const ERROR_URL = globalVars.baseURL + "static/img/placeholder.png";
const PLACEHOLDER_URL = globalVars.baseURL + "public/static/img/placeholder.png"; // A generic loading placeholder
const ERROR_URL = globalVars.baseURL + "public/static/img/placeholder.png";
export default {
name: "Icon",
@@ -60,6 +61,18 @@ export default {
type: String,
default: "",
},
source: {
type: String,
default: null,
},
path: {
type: String,
default: null,
},
modified: {
type: String,
default: null,
},
clickable: {
type: Boolean,
default: false,
@@ -188,6 +201,15 @@ export default {
// Prevent race conditions: only update if this is the image we still want.
if (this.imageTargetSrc === url) {
this.imageState = "loaded";
// Mark this image as loaded in our cache tracker
if (this.path) {
// For shares, use shareInfo.hash as the source; otherwise use this.source or state.req.source
const source = getters.isShare() ? state.shareInfo?.hash : (this.source || state.req.source);
const size = this.showLargeIcon ? 'large' : 'small';
// Use file's modified date if available, otherwise fall back to state.req.modified
const modified = this.modified || state.req.modified;
setImageLoaded(source, this.path, size, modified, url);
}
}
};
@@ -201,9 +223,19 @@ export default {
targetImage.src = url;
},
handleMouseEnter() {
// Always use large thumbnails for hover/popup preview
const imageUrl = this.thumbnailUrl + "&size=large";
if (this.imageState == "loaded") {
mutations.setPreviewSource(imageUrl);
// Store source/path/url/modified in state so PopupPreview can track it when image actually loads
if (this.path) {
// For shares, use shareInfo.hash as the source; otherwise use this.source or state.req.source
const source = getters.isShare() ? state.shareInfo?.hash : (this.source || state.req.source);
// Use file's modified date if available, otherwise fall back to state.req.modified
const modified = this.modified || state.req.modified;
state.popupPreviewSourceInfo = { source, path: this.path, size: 'large', url: imageUrl, modified };
}
}
if (!getters.previewPerms().motionVideoPreview || !this.hasMotion) {
return;
@@ -218,7 +250,7 @@ export default {
let index = 0;
const updateThumbnail = () => {
if (state.popupPreviewSource === "") {
if (state.popupPreviewSourceInfo?.url === "") {
this.previewTimeouts.forEach(clearTimeout);
this.previewTimeouts = [];
return;
@@ -233,6 +265,17 @@ export default {
// Preload the next image
const nextIndex = (index + 1) % sequence.length;
const preloadImg = new Image();
preloadImg.onload = () => {
// Track that this image was loaded (motion preview frames use the same path)
if (this.path) {
// For shares, use shareInfo.hash as the source; otherwise use this.source or state.req.source
const source = getters.isShare() ? state.shareInfo?.hash : (this.source || state.req.source);
const frameUrl = sequence[nextIndex];
// Use file's modified date if available, otherwise fall back to state.req.modified
const modified = this.modified || state.req.modified;
setImageLoaded(source, this.path, 'large', modified, frameUrl);
}
};
preloadImg.src = sequence[nextIndex];
// Schedule next update
@@ -247,6 +290,8 @@ export default {
this.previewTimeouts.forEach(clearTimeout);
this.previewTimeouts = [];
mutations.setPreviewSource("");
// Clear popup preview source info when mouse leaves
state.popupPreviewSourceInfo = null;
// UPDATED: Reset to the base thumbnail URL. The watcher will handle reloading it.
this.updateImageTargetSrc();
},
@@ -255,9 +300,13 @@ export default {
},
updateImageTargetSrc() {
let newSrc = this.thumbnailUrl || PLACEHOLDER_URL;
if (this.showLargeIcon) {
newSrc = (this.thumbnailUrl ? this.thumbnailUrl + "&size=large" : "") || PLACEHOLDER_URL;
// If we need large thumbnails and have a thumbnail URL, append &size=large
// Otherwise use the URL as-is (defaults to small)
if (this.thumbnailUrl && this.showLargeIcon) {
newSrc = this.thumbnailUrl + "&size=large";
}
if (this.imageTargetSrc !== newSrc) {
this.imageTargetSrc = newSrc;
}
+70 -51
View File
@@ -38,6 +38,9 @@
:thumbnailUrl="isThumbnailInView ? thumbnailUrl : ''"
:filename="name"
:hasPreview="hasPreview"
:modified="modified"
:path="path"
:source="source"
/>
</div>
@@ -46,14 +49,14 @@
<p
class="size"
:class="{ adjustment: quickDownloadEnabled }"
:data-order="humanSize()"
:data-order="humanSize"
>
{{ humanSize() }}
{{ humanSize }}
</p>
<p class="modified">
<time :datetime="modified">{{ getTime() }}</time>
<time :datetime="modified">{{ formattedTime }}</time>
</p>
<p v-if="hasDuration" class="duration">{{ getDuration() }}</p>
<p v-if="hasDuration" class="duration">{{ formattedDuration }}</p>
</div>
<Icon
@click.stop="downloadFile"
@@ -101,6 +104,9 @@
:thumbnailUrl="isThumbnailInView ? thumbnailUrl : ''"
:filename="name"
:hasPreview="hasPreview"
:modified="modified"
:path="path"
:source="source"
/>
</div>
@@ -109,14 +115,14 @@
<p
class="size"
:class="{ adjustment: quickDownloadEnabled }"
:data-order="humanSize()"
:data-order="humanSize"
>
{{ humanSize() }}
{{ humanSize }}
</p>
<p class="modified">
<time :datetime="modified">{{ getTime() }}</time>
<time :datetime="modified">{{ formattedTime }}</time>
</p>
<p v-if="hasDuration" class="duration">{{ getDuration() }}</p>
<p v-if="hasDuration" class="duration">{{ formattedDuration }}</p>
</div>
</div>
</template>
@@ -141,7 +147,7 @@ export default {
data() {
return {
isThumbnailInView: false,
isInView: false, // Track if item is in viewport
isInView: false,
touches: 0,
touchStartX: 0,
touchStartY: 0,
@@ -149,8 +155,8 @@ export default {
isSwipe: false,
isDraggedOver: false,
contextTimeout: null,
observer: null, // Store observer reference
localSelected: false, // Track local selection when updateGlobalState is false
observer: null,
localSelected: false,
};
},
props: {
@@ -268,7 +274,7 @@ export default {
let previewPath;
if (this.path) {
previewPath = this.path;
} else if (state.req.path && this.name) {
} else if (state?.req?.path && this.name) {
previewPath = url.joinPath(state.req.path, this.name);
} else {
return "";
@@ -277,7 +283,7 @@ export default {
// If forceFilesApi is true, always use authenticated files API
if (this.forceFilesApi) {
// @ts-ignore
return filesApi.getPreviewURL(this.source || state.req.source, previewPath, this.modified);
return filesApi.getPreviewURL(this.source || state?.req?.source, previewPath, this.modified);
}
if (getters.isShare()) {
@@ -292,25 +298,50 @@ export default {
isClickable() {
return this.clickable;
},
// Computed properties for display values - Vue caches these automatically!
humanSize() {
return this.type == "invalid_link"
? "invalid link"
: getHumanReadableFilesize(this.size);
},
formattedTime() {
return getters.getTime(this.modified);
},
formattedDuration() {
if (!this.metadata || !this.metadata.duration) {
return "";
}
const seconds = this.metadata.duration;
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
},
},
mounted() {
// Set up IntersectionObserver with larger margins for smoother rendering
// Set up IntersectionObserver for lazy-loading thumbnails
this.observer = new IntersectionObserver(this.handleIntersect, {
root: null,
rootMargin: "1500px", // Start rendering 1500px before entering viewport for smooth scrolling
rootMargin: "500px", // Reduced from 1500px for better performance
threshold: 0,
});
this.observer.observe(this.$el);
// Use $nextTick to ensure $el is available and is an Element
this.$nextTick(() => {
const rect = this.$el.getBoundingClientRect();
const isInViewport = rect.top < window.innerHeight + 1500 && rect.bottom > -1500;
if (isInViewport && this.hasPreview) {
this.isThumbnailInView = true;
this.isInView = true;
if (this.$el && this.$el instanceof Element) {
this.observer.observe(this.$el);
const rect = this.$el.getBoundingClientRect();
const isInViewport = rect.top < window.innerHeight + 500 && rect.bottom > -500;
if (isInViewport && this.hasPreview) {
this.isThumbnailInView = true;
this.isInView = true;
}
}
});
document.addEventListener('dragend', this.dragEnd);
// Note: dragend listener moved to parent ListingView for better performance
},
beforeUnmount() {
// Clean up observer
@@ -318,7 +349,7 @@ export default {
this.observer.disconnect();
this.observer = null;
}
document.removeEventListener('dragend', this.dragEnd);
// Note: dragend listener removed - handled by parent ListingView
},
methods: {
/** @param {MouseEvent} event */
@@ -375,10 +406,6 @@ export default {
},
/** @param {MouseEvent} event */
onRightClick(event) {
if (!this.updateGlobalState) {
event.preventDefault();
return;
}
event.preventDefault(); // Prevent default context menu
// If one or fewer items are selected, reset the selection
if (this.updateGlobalState) {
@@ -388,8 +415,22 @@ export default {
mutations.addSelected(this.index);
}
} else {
// Emit selection event for local handling
this.$emit('select', { index: this.index, selected: true });
// Build full item object similar to Search.vue
const selectedItem = {
name: this.name,
isDir: this.isDir,
source: this.source,
type: this.type,
size: this.size,
modified: this.modified,
path: this.path,
url: this.path,
index: this.index,
};
mutations.resetSelected();
// @ts-ignore
mutations.addSelected(selectedItem);
}
mutations.showHover({
name: "ContextMenu",
@@ -412,28 +453,6 @@ export default {
}
});
},
humanSize() {
return this.type == "invalid_link"
? "invalid link"
: getHumanReadableFilesize(this.size);
},
getTime() {
// @ts-ignore
return getters.getTime(this.modified);
},
getDuration() {
if (!this.metadata || !this.metadata.duration) {
return "";
}
const seconds = this.metadata.duration;
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
},
/** @param {DragEvent} event */
dragLeave(event) {
// Only reset visual state for internal drags
+14 -6
View File
@@ -1,11 +1,12 @@
<template>
<div class="popup-preview" v-show="source" ref="popup" :style="popupStyle">
<img :src="source" alt="Popup image" />
<div class="popup-preview" v-show="sourceInfo" ref="popup" :style="popupStyle">
<img v-if="sourceInfo" :src="sourceInfo.url" alt="Popup image" @load="onImageLoad" />
</div>
</template>
<script>
import { state, getters } from "@/store";
import { setImageLoaded } from "@/utils/imageCache";
export default {
name: "PopupPreview",
@@ -20,7 +21,7 @@ export default {
};
},
watch: {
source(newVal) {
sourceInfo(newVal) {
if (newVal) {
this.$nextTick(() => {
this.positionPopup();
@@ -29,8 +30,8 @@ export default {
},
},
computed: {
source() {
return state.popupPreviewSource;
sourceInfo() {
return state.popupPreviewSourceInfo;
},
},
mounted() {
@@ -40,13 +41,20 @@ export default {
window.removeEventListener("mousemove", this.updateCursorPosition);
},
methods: {
onImageLoad() {
// Track that this image was loaded
if (this.sourceInfo) {
const { source, path, size, url, modified } = this.sourceInfo;
setImageLoaded(source, path, size, modified, url);
}
},
updateCursorPosition(event) {
this.cursorX = event.clientX;
this.cursorY = event.clientY;
if (!state.isMobile) this.positionPopup();
},
positionPopup() {
if (!this.source) return;
if (!this.sourceInfo) return;
const popup = this.$refs.popup;
if (!popup) return;
+14 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="scroll-wrapper" ref="wrapper">
<div class="scroll-wrapper" :class="{ 'halloween-theme': eventTheme === 'halloween' }" :style="halloweenBackgroundStyle" ref="wrapper">
<slot />
<div
class="custom-scrollbar"
@@ -35,6 +35,7 @@
<script>
import { state, mutations, getters } from "@/store";
import { globalVars } from "@/utils/constants";
const offsetFromBottomListing = 110;
const offsetFromBottomFull = 75;
@@ -63,6 +64,18 @@ export default {
isFolder() {
return this.category() === "folders";
},
eventTheme() {
return getters.eventTheme();
},
halloweenBackgroundStyle() {
if (this.eventTheme === 'halloween') {
return {
backgroundImage: `url("${globalVars.baseURL}static/img/halloween-pattern.svg")`,
backgroundRepeat: 'repeat'
};
}
return {};
},
},
methods: {
handleResize() {
+1 -1
View File
@@ -62,7 +62,7 @@ export default {
if (!state.shareInfo?.isPasswordProtected) {
return true
}
return state.share.passwordValid
return state.shareInfo.passwordValid
},
shareInfo() {
return state.shareInfo;
+64 -39
View File
@@ -3,7 +3,7 @@
<div
v-if="enabled && hasPrevious"
class="nav-zone nav-zone-left"
:class="{ moveWithSidebar: moveWithSidebar }"
:style="leftZoneStyle"
@touchstart="(e) => { handleTouchStart(e); toggleNavigation(e); }"
@touchmove="handleTouchMove"
></div>
@@ -28,15 +28,15 @@
@mouseleave="setHoverNav(false)"
class="nav-button nav-previous"
:class="{
moveWithSidebar: moveWithSidebar,
hidden: !showNav,
disabled: !hasPrevious,
dragging: dragState.type === 'previous',
active: dragState.atFullExtent && dragState.type === 'previous',
'dark-mode': isDarkMode,
'media-mode': isMediaQueueMode,
'sidebar-resizing': isSidebarResizing,
}"
:style="dragState.type === 'previous' ? { transform: `translateY(-50%) translate(${dragState.deltaX}px, 0)` } : {}"
:style="previousButtonStyle"
:aria-label="$t('general.previous')"
:title="$t('general.previous')"
>
@@ -57,7 +57,7 @@
@mouseleave="setHoverNav(false)"
class="nav-button nav-next"
:class="{ hidden: !showNav, dragging: dragState.type === 'next', active: dragState.atFullExtent && dragState.type === 'next','dark-mode': isDarkMode, 'media-mode': isMediaQueueMode}"
:style="dragState.type === 'next' ? { transform: `translateY(-50%) translate(${dragState.deltaX}px, 0)` } : {}"
:style="nextButtonStyle"
:aria-label="$t('general.next')"
:title="$t('general.next')"
>
@@ -108,6 +108,7 @@ export default {
tapTimeout: null,
triggered: false
},
isSidebarResizing: false, // Track if sidebar is being resized
};
},
computed: {
@@ -115,6 +116,9 @@ export default {
moveWithSidebar() {
return getters.isSidebarVisible() && getters.isStickySidebar();
},
sidebarWidth() {
return state.sidebar?.width || 20;
},
enabled() {
return state.navigation.enabled && getters.currentPrompt() == null;
},
@@ -157,6 +161,45 @@ export default {
// Use media queue when in media view, NOT in single/loop-single mode, and have a queue
return isMediaView && mode !== 'single' && mode !== 'loop-single' && hasQueue;
},
leftZoneStyle() {
const styles = {
pointerEvents: 'none',
zIndex: '-1',
background: 'transparent',
position: 'fixed',
top: '25%',
bottom: '25%',
width: '5em',
};
if (this.moveWithSidebar) {
styles.left = `${this.sidebarWidth}em`;
} else {
styles.left = '0';
}
return styles;
},
previousButtonStyle() {
const styles = {};
if (this.dragState.type === 'previous') {
styles.transform = `translateY(-50%) translate(${this.dragState.deltaX}px, 0)`;
}
// Calculate left position based on sidebar
if (this.moveWithSidebar) {
styles.left = `calc(${this.sidebarWidth}em + 1em)`; // When sidebar is sticky the position of the button will have a tiny padding
} else {
styles.left = '1em';
}
return styles;
},
nextButtonStyle() {
const styles = {};
if (this.dragState.type === 'next') {
styles.transform = `translateY(-50%) translate(${this.dragState.deltaX}px, 0)`;
}
// Next button doesn't need account for the sidebar
styles.right = '1em';
return styles;
}
},
watch: {
@@ -207,6 +250,10 @@ export default {
'state.navigation.nextLink'() {
this.showInitialNavigation();
},
'state.sidebar.isResizing'(newVal) {
// Track when sidebar is being resized to disable transitions
this.isSidebarResizing = newVal;
},
},
mounted() {
window.addEventListener("keydown", this.keyEvent);
@@ -336,7 +383,7 @@ export default {
try {
let res;
if (getters.isShare()) {
res = await publicApi.fetchPub(directoryPath, state.share.hash);
res = await publicApi.fetchPub(directoryPath, state.shareInfo.hash);
} else {
res = await filesApi.fetchFiles(state.req.source, directoryPath);
}
@@ -595,14 +642,16 @@ export default {
}
},
isClickInLeftZone(event) {
const zoneWidth = 3 * parseFloat(getComputedStyle(document.documentElement).fontSize); // 3em in pixels
const sidebarOffset = this.moveWithSidebar ? 20 * parseFloat(getComputedStyle(document.documentElement).fontSize) : 0; // 20em in pixels
const emSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
const zoneWidth = 3 * emSize; // 3em in pixels
const sidebarOffset = this.moveWithSidebar ? (this.sidebarWidth * emSize) : 0;
return event.clientX >= sidebarOffset && event.clientX <= (sidebarOffset + zoneWidth);
},
isClickInRightZone(event) {
const viewportWidth = window.innerWidth;
const zoneWidth = 3 * parseFloat(getComputedStyle(document.documentElement).fontSize); // 3em in pixels
const emSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
const zoneWidth = 3 * emSize; // 3em in pixels
return event.clientX >= (viewportWidth - zoneWidth) && event.clientX <= viewportWidth;
},
@@ -944,7 +993,7 @@ export default {
const zoneWidth = 5 * emSize; // 5em in pixels
// Check left zone
const sidebarOffset = this.moveWithSidebar ? 20 * emSize : 0; // Account for sidebar
const sidebarOffset = this.moveWithSidebar ? (this.sidebarWidth * emSize) : 0; // Account for sidebar
if (this.hasPrevious && event.clientX >= sidebarOffset && event.clientX <= (sidebarOffset + zoneWidth)) {
const viewportHeight = window.innerHeight;
const zoneTop = viewportHeight * 0.25; // 25% from top
@@ -982,11 +1031,6 @@ export default {
background: transparent; /* Invisible zones for mouse/touch detection */
}
.nav-zone-left {
left: 0;
}
.nav-zone-right {
right: 0;
}
@@ -1004,7 +1048,7 @@ export default {
background: var(--background);
color: var(--textPrimary);
cursor: pointer;
transition: opacity 0.4s ease, transform 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease;
transition: opacity 0.4s ease, transform 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease, left 0.2s ease;
pointer-events: auto;
z-index: 1001;
display: flex;
@@ -1037,13 +1081,9 @@ export default {
opacity: 1;
}
.nav-previous {
left: 20px;
}
.nav-next {
right: 20px;
/* Disable transitions during sidebar resizing */
.nav-button.sidebar-resizing {
transition: opacity 0.4s ease, transform 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease;
}
.nav-button.hidden {
@@ -1054,7 +1094,7 @@ export default {
}
/* Smooth show animation for better UX */
.nav-button:not(.hidden) {
.nav-button:not(.hidden):not(.sidebar-resizing) {
animation: nav-button-show 0.4s ease-out;
}
@@ -1072,7 +1112,7 @@ export default {
.nav-button.dragging {
z-index: 1002;
cursor: grabbing;
transition: none; /* Disable transitions during drag for immediate response */
transition: none !important; /* Disable transitions during drag */
}
.nav-button i.material-icons {
@@ -1093,14 +1133,6 @@ export default {
height: 44px;
}
.nav-previous {
left: 10px;
}
.nav-next {
right: 10px;
}
.nav-button i.material-icons {
font-size: 20px;
}
@@ -1113,15 +1145,8 @@ export default {
/* Ensure buttons don't interfere with scrollbars */
@media (max-width: 480px) {
.nav-previous {
left: 8px;
}
.nav-next {
right: 8px;
}
}
.moveWithSidebar {
margin-left: 20em;
}
</style>
@@ -494,6 +494,7 @@ export default {
},
navigateToItem(item) {
mutations.closeHovers();
mutations.setNavigationTransitioning(true);
url.goToItem(item.source || state.req.source, item.path, undefined);
},
closePrompt() {
+35 -147
View File
@@ -4,10 +4,6 @@
</div>
<div class="card-content info-content">
<p v-if="selected.length > 1" class="info-description">
{{ $t("prompts.filesSelected", { count: selected.length }) }}
</p>
<div class="info-grid">
<!-- Basic Information Section -->
<div class="info-section">
@@ -24,15 +20,15 @@
<strong>{{ $t("prompts.typeName") }}</strong>
<span aria-label="info type">{{ type }}</span>
</div>
<div class="info-item" v-if="selected.length < 2 && humanTime">
<div class="info-item" v-if="humanTime">
<strong>{{ $t("prompts.lastModified") }}</strong>
<span aria-label="info last modified" :title="modTime">{{ humanTime }}</span>
</div>
<div class="info-item" v-if="selected.length < 2 && source">
<div class="info-item" v-if="source">
<strong>{{ $t("general.source") }}</strong>
<span aria-label="info source">{{ source }}</span>
</div>
<div class="info-item" v-if="selected.length < 2 && filePath">
<div class="info-item" v-if="filePath">
<strong>{{ $t("general.path") }}</strong>
<span aria-label="info path" class="break-word">{{ filePath }}</span>
</div>
@@ -45,18 +41,6 @@
<span aria-label="info has preview">{{ hasPreview ? "✓" : "✗" }}</span><!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
</div>
<!-- Directory Information Section -->
<div class="info-section" v-if="dir && selected.length === 0">
<h3 class="section-title">{{ $t("prompts.directoryInfo") }}</h3>
<div class="info-item">
<strong>{{ $t("prompts.numberFiles") }}</strong>
<span>{{ req.numFiles }}</span>
</div>
<div class="info-item">
<strong>{{ $t("prompts.numberDirs") }}</strong>
<span>{{ req.numDirs }}</span>
</div>
</div>
<!-- Additional Information Section -->
<div class="info-section" v-if="additionalInfo.length > 0">
@@ -113,11 +97,17 @@
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { formatTimestamp } from "@/utils/moment";
import { filesApi } from "@/api";
import { state, getters, mutations } from "@/store";
import { state, mutations } from "@/store";
import { notify } from "@/notify";
export default {
name: "info",
props: {
item: {
type: Object,
required: true,
},
},
data() {
return {
selectedHashAlgo: "md5",
@@ -129,145 +119,52 @@ export default {
closeHovers() {
return mutations.closeHovers;
},
req() {
return state.req;
},
selected() {
return state.selected;
},
selectedCount() {
return getters.selectedCount();
},
isListing() {
return getters.isListing();
},
humanSize() {
if (state.isSearchActive) {
return getHumanReadableFilesize(state.selected[0].size);
}
if (getters.selectedCount() === 0 || !this.isListing) {
return getHumanReadableFilesize(state.req.size);
}
let sum = 0;
for (let selected of this.selected) {
const item = typeof(selected) === 'number' ? state.req.items[selected] : selected;
sum += item.size;
}
return getHumanReadableFilesize(sum);
return getHumanReadableFilesize(this.item?.size || 0);
},
humanTime() {
if (state.isSearchActive) {
return "";
}
const modifiedDate = getters.selectedCount() === 0
? state.req.modified
: getters.getFirstSelected()?.modified;
if (!modifiedDate) {
return "";
}
return formatTimestamp(modifiedDate, state.user.locale);
if (!this.item?.modified) return "";
return formatTimestamp(this.item.modified, state.user.locale);
},
modTime() {
if (state.isSearchActive) {
return "";
}
const modifiedDate = getters.selectedCount() === 0
? state.req.modified
: getters.getFirstSelected()?.modified;
if (!modifiedDate) {
return "";
}
return new Date(Date.parse(modifiedDate)).toLocaleString();
if (!this.item?.modified) return "";
return new Date(Date.parse(this.item.modified)).toLocaleString();
},
name() {
if (state.isSearchActive) {
return state.selected[0].name;
}
return getters.selectedCount() === 0
? state.req.name
: getters.getFirstSelected().name;
return this.item?.name || "";
},
type() {
if (state.isSearchActive) {
return state.selected[0].type;
}
return getters.selectedCount() === 0
? state.req.type
: getters.getFirstSelected().type;
return this.item?.type || "";
},
displayName() {
if (this.selected.length > 1) {
return this.$t("prompts.fileInfo");
}
return this.name;
return this.item?.name || "";
},
dir() {
if (state.isSearchActive) {
return state.selected[0].type === "directory";
}
return (
getters.selectedCount() > 1 ||
(getters.selectedCount() === 0
? state.req.type == "directory"
: getters.getFirstSelected().type == "directory")
);
return this.item?.type === "directory";
},
source() {
if (state.isSearchActive) {
return state.selected[0].source;
}
const currentSource = state.sources.current;
if (!currentSource || currentSource === "") {
return "";
}
return currentSource;
return this.item?.source || "";
},
filePath() {
if (state.isSearchActive) {
return state.selected[0].path;
}
if (getters.selectedCount() === 0) {
return state.route.path;
}
return getters.getFirstSelected()?.path || "";
return this.item?.path || "";
},
hidden() {
if (state.isSearchActive) {
return state.selected[0].hidden;
}
if (getters.selectedCount() === 0) {
return state.req.hidden;
}
return getters.getFirstSelected()?.hidden;
return this.item?.hidden;
},
hasPreview() {
if (state.isSearchActive) {
return state.selected[0].hasPreview;
}
if (getters.selectedCount() === 0) {
return state.req.hasPreview;
}
return getters.getFirstSelected()?.hasPreview;
return this.item?.hasPreview;
},
additionalInfo() {
const info = [];
// Add more info fields here if needed
if (state.req.token) {
info.push({ key: "token", label: this.$t("prompts.token"), value: state.req.token });
if (this.item?.token) {
info.push({ key: "token", label: this.$t("prompts.token"), value: this.item.token });
}
if (state.req.hash) {
info.push({ key: "hash", label: this.$t("general.hash"), value: state.req.hash });
if (this.item?.hash) {
info.push({ key: "hash", label: this.$t("general.hash"), value: this.item.hash });
}
if (state.req.onlyOfficeId) {
info.push({ key: "onlyOfficeId", label: this.$t("prompts.onlyOfficeId"), value: state.req.onlyOfficeId });
if (this.item?.onlyOfficeId) {
info.push({ key: "onlyOfficeId", label: this.$t("prompts.onlyOfficeId"), value: this.item.onlyOfficeId });
}
return info;
@@ -275,30 +172,21 @@ export default {
},
methods: {
async generateHash() {
if (this.generatingHash) return;
if (this.generatingHash || !this.item) return;
this.hashResult = "";
this.generatingHash = true;
try {
let source, path;
if (state.isSearchActive) {
source = state.selected[0].source;
path = state.selected[0].path;
} else if (getters.selectedCount()) {
source = state.sources.current;
path = getters.getFirstSelected().path;
} else {
source = state.sources.current;
path = state.route.path;
}
const source = this.item.source;
const path = this.item.path;
const hash = await filesApi.checksum(source, path, this.selectedHashAlgo);
this.hashResult = hash;
} catch (err) {
this.hashResult = this.$t("prompts.errorGeneratingHash");
notify.showError(err.message || "Error generating hash");
const errorMessage = err instanceof Error ? err.message : "Error generating hash";
notify.showError(errorMessage);
} finally {
this.generatingHash = false;
}
+2 -2
View File
@@ -4,7 +4,7 @@
</div>
<div class="card-content">
<div v-if="showWrongCredentials" class="share__wrong__password">
<div v-if="showWrongCredentials" class="form-invalid">
{{ $t("login.wrongCredentials") }}
</div>
<div class="form-flex-group">
@@ -65,7 +65,7 @@ export default {
</script>
<style scoped>
.share__wrong__password {
.wrong__password {
color: #ff4757;
text-align: center;
padding: 1em 0;
@@ -176,6 +176,7 @@ export default {
return;
} else {
// Navigate to different item
mutations.setNavigationTransitioning(true);
this.navigateToIndex(index);
}
},
@@ -201,6 +202,7 @@ export default {
url.goToItem( item.source || state.req.source, item.path, undefined );
},
scrollToCurrentItem() {
if (this.queueCount === 0) return;
this.$nextTick(() => {
const list = this.$refs.QueueList;
const currentItem = list.querySelector('.item.current');
+125 -112
View File
@@ -101,137 +101,138 @@
</table>
</div>
<div v-else>
<p>
{{ $t("files.duration") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareDurationDescription'))" @mouseleave="hideTooltip">
help
</i>
</p>
<div class="form-flex-group">
<input class="form-grow input flat-right" v-focus type="number" max="2147483647" min="0" @keyup.enter="submit"
v-model.trim="time" />
<select class="flat-left input form-dropdown" v-model="unit" :aria-label="$t('time.unit')">
<option value="minutes">{{ $t("time.minutes") }}</option>
<option value="hours">{{ $t("time.hours") }}</option>
<option value="days">{{ $t("time.days") }}</option>
</select>
</div>
<p>
{{ $t("prompts.optionalPassword") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.passwordDescription'))" @mouseleave="hideTooltip">
help
</i>
</p>
<div v-if="hasExistingPassword && !isChangingPassword" class="password-change-section">
<button class="button button--flat button--blue" @click="isChangingPassword = true" style="width: 100%;">
<i class="material-icons">lock_reset</i>
{{ $t("general.change") }}
</button>
</div>
<input v-else class="input" type="password" autocomplete="new-password" v-model.trim="password" />
<p>
{{ $t("share.shareType") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareTypeDescription'))" @mouseleave="hideTooltip">
help
</i>
</p>
<select class="input" v-model="shareType">
<option value="normal">{{ $t("share.normalShare") }}</option>
<option value="upload">{{ $t("share.uploadShare") }}</option>
</select>
<button @click="openSidebarLinksCustomization" class="button button--flat customize-sidebar-links-button">
<i class="material-icons">link</i>
{{ $t('share.customizeSidebarLinksButton') }}
</button>
<div class="settings-items" style="margin-top: 0.5em;">
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="disableDownload"
:name="$t('share.disableDownload')" :description="$t('share.disableDownloadDescription')"
aria-label="disable downloading files toggle" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowModify" :name="$t('share.allowModify')"
:description="$t('share.allowModifyDescription')" aria-label="allow editing files toggle" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowDelete" :name="$t('share.allowDelete')"
:description="$t('share.allowDeleteDescription')" aria-label="allow deleting files toggle" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowCreate" :name="$t('share.allowCreate')"
:description="$t('share.allowCreateDescription')"
aria-label="allow creating and uploading files and folders toggle" />
<ToggleSwitch v-if="createAllowed" class="item" v-model="allowReplacements"
:name="$t('share.allowReplacements')" :description="$t('share.allowReplacementsDescription')" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="disableFileViewer"
:name="$t('share.disableFileViewer')" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="quickDownload"
:name="$t('profileSettings.showQuickDownload')"
:description="$t('profileSettings.showQuickDownloadDescription')" />
<ToggleSwitch class="item" v-model="disableAnonymous" :name="$t('share.disableAnonymous')"
:description="$t('share.disableAnonymousDescription')" />
<ToggleSwitch class="item" v-model="enableAllowedUsernames" :name="$t('share.enableAllowedUsernames')"
:description="$t('share.enableAllowedUsernamesDescription')" />
<div v-if="enableAllowedUsernames" class="item">
<input class="input" type="text" v-model.trim="allowedUsernames"
:placeholder="$t('share.allowedUsernamesPlaceholder')" />
<div v-if="!showMoreExpanded">
<p>
{{ $t("files.duration") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareDurationDescription'))" @mouseleave="hideTooltip">
help
</i>
</p>
<div class="form-flex-group">
<input class="form-grow input flat-right" v-focus type="number" max="2147483647" min="0" @keyup.enter="submit"
v-model.trim="time" />
<select class="flat-left input form-dropdown" v-model="unit" :aria-label="$t('time.unit')">
<option value="minutes">{{ $t("time.minutes") }}</option>
<option value="hours">{{ $t("time.hours") }}</option>
<option value="days">{{ $t("time.days") }}</option>
</select>
</div>
<ToggleSwitch v-if="shareType === 'normal' && onlyOfficeAvailable" class="item" v-model="enableOnlyOffice"
:name="$t('share.enableOnlyOffice')" :description="$t('share.enableOnlyOfficeDescription')" />
<p>
{{ $t("share.enforceDarkLightMode") }}
{{ $t("prompts.optionalPassword") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.enforceDarkLightModeDescription'))" @mouseleave="hideTooltip">
@mouseenter="showTooltip($event, $t('share.passwordDescription'))" @mouseleave="hideTooltip">
help
</i>
</p>
<select class="input" v-model="enforceDarkLightMode">
<option value="default">{{ $t("share.default") }}</option>
<option value="dark">{{ $t("share.dark") }}</option>
<option value="light">{{ $t("share.light") }}</option>
</select>
</div>
<!-- <ViewMode :viewMode="viewMode" @update:viewMode="viewMode = $event" /> -->
<p>
{{ $t("prompts.shareTheme") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareThemeDescription'))" @mouseleave="hideTooltip">
help
</i>
</p>
<div v-if="Object.keys(availableThemes).length > 0" class="form-flex-group">
<select class="input" v-model="shareTheme">
<option v-for="(theme, key) in availableThemes" :key="key" :value="key">
{{ String(key) === "default" ? $t("profileSettings.defaultThemeDescription") : `${key} -
${theme.description}`
}}
</option>
</select>
</div>
<div v-if="shareType === 'normal'">
<div v-if="hasExistingPassword && !isChangingPassword" class="password-change-section">
<button class="button button--flat button--blue" @click="isChangingPassword = true" style="width: 100%;">
<i class="material-icons">lock_reset</i>
{{ $t("general.change") }}
</button>
</div>
<input v-else class="input" type="password" autocomplete="new-password" v-model.trim="password" />
<p>
{{ $t("share.defaultViewMode") }}
{{ $t("share.shareType") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.defaultViewModeDescription'))" @mouseleave="hideTooltip">
@mouseenter="showTooltip($event, $t('share.shareTypeDescription'))" @mouseleave="hideTooltip">
help
</i>
</p>
<select class="input" v-model="viewMode">
<option value="normal">{{ $t("buttons.normalView") }}</option>
<option value="list">{{ $t("buttons.listView") }}</option>
<option value="gallery">{{ $t("buttons.galleryView") }}</option>
<select class="input" v-model="shareType">
<option value="normal">{{ $t("share.normalShare") }}</option>
<option value="upload">{{ $t("share.uploadShare") }}</option>
</select>
<button @click="openSidebarLinksCustomization" class="button button--flat customize-sidebar-links-button">
<i class="material-icons">link</i>
{{ $t('share.customizeSidebarLinksButton') }}
</button>
<div class="settings-items" style="margin-top: 0.5em;">
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowModify" :name="$t('share.allowModify')"
:description="$t('share.allowModifyDescription')" aria-label="allow editing files toggle" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowCreate" :name="$t('share.allowCreate')"
:description="$t('share.allowCreateDescription')"
aria-label="allow creating and uploading files and folders toggle" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="allowDelete" :name="$t('share.allowDelete')"
:description="$t('share.allowDeleteDescription')" aria-label="allow deleting files toggle" />
</div>
</div>
<SettingsItem :title="$t('buttons.showMore')" :collapsable="true" :start-collapsed="true">
<SettingsItem :title="showMoreExpanded ? $t('buttons.showLess') : $t('buttons.showMore')" :collapsable="true" :start-collapsed="!showMoreExpanded" @toggle="showMoreExpanded = $event">
<div class="settings-items">
<p>
{{ $t("prompts.shareTheme") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.shareThemeDescription'))" @mouseleave="hideTooltip">
help
</i>
</p>
<div v-if="Object.keys(availableThemes).length > 0" class="form-flex-group">
<select class="input" v-model="shareTheme">
<option v-for="(theme, key) in availableThemes" :key="key" :value="key">
{{ String(key) === "default" ? $t("profileSettings.defaultThemeDescription") : `${key} -
${theme.description}`
}}
</option>
</select>
</div>
<div v-if="shareType === 'normal'">
<p>
{{ $t("share.defaultViewMode") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.defaultViewModeDescription'))" @mouseleave="hideTooltip">
help
</i>
</p>
<select class="input" v-model="viewMode">
<option value="normal">{{ $t("buttons.normalView") }}</option>
<option value="list">{{ $t("buttons.listView") }}</option>
<option value="gallery">{{ $t("buttons.galleryView") }}</option>
</select>
</div>
<ToggleSwitch v-if="createAllowed" class="item" v-model="allowReplacements"
:name="$t('share.allowReplacements')" :description="$t('share.allowReplacementsDescription')" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="disableDownload"
:name="$t('share.disableDownload')" :description="$t('share.disableDownloadDescription')"
aria-label="disable downloading files toggle" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="disableFileViewer"
:name="$t('share.disableFileViewer')" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="quickDownload"
:name="$t('profileSettings.showQuickDownload')"
:description="$t('profileSettings.showQuickDownloadDescription')" />
<ToggleSwitch class="item" v-model="disableAnonymous" :name="$t('share.disableAnonymous')"
:description="$t('share.disableAnonymousDescription')" />
<ToggleSwitch class="item" v-model="enableAllowedUsernames" :name="$t('share.enableAllowedUsernames')"
:description="$t('share.enableAllowedUsernamesDescription')" />
<div v-if="enableAllowedUsernames" class="item">
<input class="input" type="text" v-model.trim="allowedUsernames"
:placeholder="$t('share.allowedUsernamesPlaceholder')" />
</div>
<ToggleSwitch v-if="shareType === 'normal' && onlyOfficeAvailable" class="item" v-model="enableOnlyOffice"
:name="$t('share.enableOnlyOffice')" :description="$t('share.enableOnlyOfficeDescription')" />
<p>
{{ $t("share.enforceDarkLightMode") }}
<i class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip($event, $t('share.enforceDarkLightModeDescription'))" @mouseleave="hideTooltip">
help
</i>
</p>
<select class="input" v-model="enforceDarkLightMode">
<option value="default">{{ $t("share.default") }}</option>
<option value="dark">{{ $t("share.dark") }}</option>
<option value="light">{{ $t("share.light") }}</option>
</select>
<ToggleSwitch class="item" v-model="keepAfterExpiration" :name="$t('share.keepAfterExpiration')"
:description="$t('share.keepAfterExpirationDescription')" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="disableThumbnails"
:name="$t('share.disableThumbnails')" :description="$t('share.disableThumbnailsDescription')" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="showHidden"
:name="$t('profileSettings.showHiddenFiles')" :description="$t('profileSettings.showHiddenFilesDescription')" />
<ToggleSwitch class="item" v-model="disableNavButtons" :name="$t('share.hideNavButtons')"
<ToggleSwitch v-if="shareType !== 'upload'" class="item" v-model="disableNavButtons" :name="$t('share.hideNavButtons')"
:description="$t('share.hideNavButtonsDescription')" />
<ToggleSwitch class="item" v-model="disableShareCard" :name="$t('share.disableShareCard')"
:description="$t('share.disableShareCardDescription')" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="disableSidebar"
<ToggleSwitch class="item" v-model="disableSidebar"
:name="$t('share.disableSidebar')" :description="$t('share.disableSidebarDescription')" />
<ToggleSwitch v-if="shareType === 'normal'" class="item" v-model="perUserDownloadLimit"
:name="$t('share.perUserDownloadLimit')" :description="$t('share.perUserDownloadLimitDescription')" />
@@ -438,6 +439,7 @@ export default {
filePickerSelectionValid: false, // Track if a valid file is selected
tempFilePath: "",
tempFileSource: "",
showMoreExpanded: false,
//viewMode: "normal",
};
},
@@ -521,6 +523,13 @@ export default {
this.isChangingPassword = false;
}
},
shareType(newType) {
if (newType === 'upload') {
this.description = this.$t("share.descriptionUploadDefault");
} else {
this.description = this.$t("share.descriptionDefault");
}
},
isEditMode: {
immediate: true,
handler(isEditMode) {
@@ -853,14 +862,18 @@ export default {
category: "shareInfo",
target: "#",
icon: "qr_code"
},
{
}
];
// Only add Download link for normal shares, not upload shares
if (this.shareType !== 'upload') {
this.sidebarLinks.push({
name: "Download",
category: "download",
target: "#",
icon: "download"
}
];
});
}
}
},
populateDefaults() {
@@ -28,8 +28,8 @@
</div>
</div>
<div v-if="req.type" class="share-info-element share-info-center">
<qrcode-vue class="qrcode" :value="getShareLink()" size="200" level="M"></qrcode-vue>
<div class="share-info-element share-info-center">
<qrcode-vue class="qrcode" :value="getShareLink()" :size="200" level="M"></qrcode-vue>
<p class="share-link-text">{{ getShareLink() }}</p>
</div>
</div>
@@ -66,13 +66,13 @@ export default {
if (!state.shareInfo?.isPasswordProtected) {
return true
}
return state.share.passwordValid
return state.shareInfo.passwordValid
},
getShareBanner() {
if (state.shareInfo?.banner.startsWith("http")) {
return state.shareInfo?.banner;
}
return publicApi.getDownloadURL(state.share, [state.shareInfo?.banner]);
return publicApi.getDownloadURL(state.shareInfo, [state.shareInfo?.banner]);
},
shareInfo() {
return state.shareInfo;
@@ -123,6 +123,7 @@ export default {
.share-info-element {
margin: 0.5em 0;
margin:auto;
}
.share-info-center {
+61 -7
View File
@@ -3,15 +3,14 @@
<h2>{{ $t("otp.name") }}</h2>
</div>
<div v-if="error !== ''" class="wrong-login card">{{ error }}</div>
<div v-if="succeeded">{{ $t("otp.verificationSucceed") }}</div>
<div v-if="!succeeded" class="card-content">
<div class="card-content">
<p v-if="generate">{{ $t("otp.generate") }}</p>
<div v-if="generate" class="share__box__element share__box__center">
<p aria-label="otp-url">{{ this.url }}</p>
<qrcode-vue class="qrcode" :value="this.url" size="200" level="M"></qrcode-vue>
<div v-if="generate" class="box__element box__center">
<p aria-label="otp-url">{{ url }}</p>
<qrcode-vue class="qrcode" :value="url" :size="200" level="M"></qrcode-vue>
</div>
<p>{{ $t("otp.verifyInstructions") }}</p>
<input v-focus class="input" type="text" v-model="code" @keyup.enter="verifyCode"
<input :class="{'form-invalid': error != '' && code != ''}" v-focus class="input" type="text" v-model="code" @keyup.enter="verifyCode"
:placeholder="$t('otp.codeInputPlaceholder')" />
</div>
@@ -21,7 +20,7 @@
:title="succeeded ? $t('general.close') : $t('general.cancel')">
{{ succeeded ? $t('general.close') : $t('general.cancel') }}
</button>
<button v-if="!succeeded" class="button button--flat button--blue" @click="verifyCode"
<button v-if="!succeeded && code != ''" class="button button--flat button--blue" @click="verifyCode"
:title="$t('general.verify')">
{{ $t("general.verify") }}
</button>
@@ -71,6 +70,11 @@ export default {
this.generateNewCode();
}
},
watch: {
code() {
this.error = "";
},
},
methods: {
async generateNewCode() {
try {
@@ -99,6 +103,7 @@ export default {
this.succeeded = true
this.error = "";
notify.showSuccessToast(this.$t("otp.verificationSucceed"));
mutations.closeTopHover();
} catch (error) {
this.error = this.$t("otp.verificationFailed");
return;
@@ -113,3 +118,52 @@ export default {
},
};
</script>
<style scoped>
.box {
box-shadow: rgba(0, 0, 0, 0.06) 0px 1px 3px, rgba(0, 0, 0, 0.12) 0px 1px 2px;
background: #fff;
border-radius: 1em;
margin: 5px;
overflow: hidden;
}
.box__header {
padding: 1em;
text-align: center;
}
.box__icon i {
font-size: 10em;
color: #40c4ff;
}
.box__center {
text-align: center;
}
.box__info {
flex: 1 1 18em;
}
.box__element {
padding: 1em;
border-top: 1px solid rgba(0, 0, 0, 0.1);
word-break: break-all;
}
.box__element .button {
display: inline-block;
}
.box__element .button i {
display: block;
margin-bottom: 4px;
}
.box__items {
text-align: left;
flex: 10 0 25em;
}
</style>
@@ -442,6 +442,8 @@ export default {
name: "totp",
props: {
generate: true,
username: this.user.username,
password: this.passwordRef || this.user.password || "",
},
});
},
@@ -63,6 +63,8 @@ export default {
} else {
// Handle internally as before
this.isCollapsed = !this.isCollapsed;
// Emit the new state so parent can react
this.$emit('toggle', !this.isCollapsed);
}
},
/**
+37 -24
View File
@@ -1,6 +1,6 @@
<template>
<div class="card headline-card">
<div v-if="isDataLoaded" class="card-wrapper user-card">
<div v-if="isDataLoaded && shouldShowLogin" class="card-wrapper user-card">
<div v-if="settingsAllowed" class="inner-card">
<a href="/settings#profile-main" class="person-button action button"
@click.prevent="navigateTo('/settings', '#profile-main')"
@@ -52,12 +52,18 @@
<transition v-if="shareInfo.shareType !== 'upload'" name="expand" @before-enter="beforeEnter" @enter="enter"
@leave="leave">
<div v-if="!hideSidebarFileActions && isListingView" class="card-wrapper">
<button @click="openContextMenu" aria-label="File-Actions" class="action file-actions">
<button @click="openContextMenu" aria-label="File-Actions" data-testid="file-actions-button" class="action file-actions">
<i class="material-icons">add</i>
{{ $t("sidebar.fileActions") }}
</button>
</div>
</transition>
<!-- Hidden marker for tests to detect when file actions should be available -->
<div v-if="isDataLoaded && isListingView && shareInfo.shareType !== 'upload'"
data-testid="file-actions-ready"
style="display: none;"
:data-hidden="hideSidebarFileActions">
</div>
</div>
<!-- Sidebar Links Component (replaces sources) -->
@@ -122,10 +128,9 @@ export default {
shouldShowLogin() {
if (getters.isShare()) {
// Don't show login until shareInfo is fully loaded
if (!state.shareInfo || state.shareInfo.disableLoginOption === undefined) {
if (state.shareInfo && state.shareInfo.disableLoginOption == undefined) {
return false;
}
return !state.shareInfo.disableLoginOption;
}
return true;
},
@@ -213,30 +218,35 @@ export default {
// Logout the user
logout: auth.logout,
beforeEnter(el) {
el.style.height = '0';
el.style.maxHeight = '0';
el.style.opacity = '0';
},
enter(el, done) {
el.style.transition = '';
el.style.height = '0';
el.style.opacity = '0';
// Force reflow
void el.offsetHeight;
el.style.transition = 'height 0.3s, opacity 0.3s';
el.style.height = el.scrollHeight + 'px';
el.style.opacity = '1';
setTimeout(() => {
el.style.height = 'auto';
done();
}, 300);
requestAnimationFrame(() => {
el.style.transition = 'max-height 0.2s ease, opacity 0.15s ease';
el.style.maxHeight = el.scrollHeight + 'px';
el.style.opacity = '1';
const onTransitionEnd = () => {
el.style.maxHeight = '';
el.removeEventListener('transitionend', onTransitionEnd);
done();
};
el.addEventListener('transitionend', onTransitionEnd);
});
},
leave(el, done) {
el.style.transition = 'height 0.3s, opacity 0.3s';
el.style.height = el.scrollHeight + 'px';
void el.offsetHeight;
el.style.height = '0';
el.style.opacity = '0';
setTimeout(done, 300);
requestAnimationFrame(() => {
el.style.maxHeight = el.scrollHeight + 'px';
el.offsetHeight;
el.style.maxHeight = '0';
el.style.opacity = '0';
const onTransitionEnd = () => {
done();
el.removeEventListener('transitionend', onTransitionEnd);
};
el.addEventListener('transitionend', onTransitionEnd);
});
},
showTooltip(event, text) {
if (text) {
@@ -319,13 +329,13 @@ export default {
flex-direction: row !important;
justify-content: space-between !important;
color: var(--textPrimary);
margin-bottom: 0.5em;
}
.quick-toggles {
display: flex;
justify-content: space-evenly;
width: 100%;
margin-top: 0.5em !important;
color: var(--textPrimary);
}
@@ -360,6 +370,7 @@ export default {
.quick-toggles div {
border-radius: 10em;
background-color: var(--surfaceSecondary);
transform: translateZ(0);
}
.quick-toggles div i {
@@ -371,6 +382,7 @@ export default {
button.action {
border-radius: 0.5em;
transform: translateZ(0);
}
.quick-toggles .active {
@@ -428,6 +440,7 @@ a.person-button {
.expand-leave-active {
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
will-change: opacity, height;
}
.expand-enter,
+8 -6
View File
@@ -173,8 +173,9 @@ export default {
if (!link.sourceName) return '#';
const sourceInfo = this.sourceInfo[link.sourceName];
if (!sourceInfo) return '#'; // Source not found
const basePath = `/files/${link.sourceName}${sourceInfo.pathPrefix}`
fullPath = basePath + link.target;
const encodedSourceName = encodeURIComponent(link.sourceName);
const targetPath = link.target.startsWith('/') ? link.target.substring(1) : link.target;
fullPath = `/files/${encodedSourceName}/${targetPath}`;
} else {
// For other links (tools, custom, share), use target as-is
fullPath = link.target;
@@ -291,8 +292,8 @@ export default {
mutations.closeHovers();
const downloadLink = publicApi.getDownloadURL({
path: "/",
hash: state.share.hash,
token: state.share.token,
hash: state.shareInfo.hash,
token: state.shareInfo.token,
inline: false,
}, [state.req.path]);
window.open(downloadLink + "&format=" + format, "_blank");
@@ -302,8 +303,8 @@ export default {
// Direct download for single files or directories
const downloadLink = publicApi.getDownloadURL({
path: "/",
hash: state.share.hash,
token: state.share.token,
hash: state.shareInfo.hash,
token: state.shareInfo.token,
inline: false,
}, [state.req.path]);
window.open(downloadLink, "_blank");
@@ -525,6 +526,7 @@ a.sidebar-link-button {
.expand-leave-active {
transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
will-change: opacity, max-height;
}
.expand-enter,
+125 -9
View File
@@ -2,6 +2,7 @@
<nav
id="sidebar"
:class="{ active: active, 'dark-mode': isDarkMode, 'behind-overlay': behindOverlay, 'scrollable': isSettings }"
:style="{ width: sidebarWidth + 'em', left: active ? '0' : `-${sidebarWidth}em` }"
>
<div v-if="shouldShow" class="button release-banner">
<a :href="releaseUrl">{{ $t("sidebar.updateIsAvailable") }}</a>
@@ -27,6 +28,10 @@
<h4 style="margin: 0">{{ name }}</h4>
</span>
</div>
<!-- Resize Handle -->
<div v-if="active && !isMobile" class="sidebar-resizer" @mousedown="startResize" @touchstart="startResize" >
<div class="resizer-handle"></div>
</div>
</nav>
</template>
@@ -42,7 +47,13 @@ export default {
SidebarGeneral,
SidebarSettings,
},
data() {
return {
resizeStartX: 0,
resizeStartWidth: 0,
previousSidebarSize: null, // Remember the previous width when switching from desktop to mobile.
};
},
mounted() {
// Ensure the sidebar is initialized correctly
mutations.setSeenUpdate(localStorage.getItem("seenUpdate"));
@@ -50,19 +61,40 @@ export default {
this.handleKeydown = (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'b') {
event.preventDefault();
if (state.user.stickySidebar) {
const sidebarVisible = getters.isSidebarVisible();
const isSticky = getters.isStickySidebar();
if (!sidebarVisible) {
mutations.updateCurrentUser({ stickySidebar: true });
mutations.toggleSidebar();
} else if (sidebarVisible && isSticky) {
mutations.updateCurrentUser({ stickySidebar: false });
} else {
mutations.toggleSidebar();
}
mutations.toggleSidebar();
}
};
document.addEventListener('keydown', this.handleKeydown);
document.addEventListener('mousemove', this.handleResize);
document.addEventListener('touchmove', this.handleResize, { passive: true });
document.addEventListener('mouseup', this.stopResize);
document.addEventListener('touchend', this.stopResize);
},
beforeUnmount() {
// Clean up event listener
if (this.handleKeydown) {
document.removeEventListener('keydown', this.handleKeydown);
}
document.removeEventListener('mousemove', this.handleResize);
document.removeEventListener('touchmove', this.handleResize);
document.removeEventListener('mouseup', this.stopResize);
document.removeEventListener('touchend', this.stopResize);
},
watch: {
isMobile(newIsMobile, oldIsMobile) {
if (newIsMobile !== oldIsMobile) {
this.handleMobileStateChange(newIsMobile);
}
},
},
computed: {
externalLinks: () => globalVars.externalLinks,
@@ -72,8 +104,10 @@ export default {
isDarkMode: () => getters.isDarkMode(),
isLoggedIn: () => getters.isLoggedIn(),
isSettings: () => getters.isSettings(),
isMobile: () => getters.isMobile(),
active: () => getters.isSidebarVisible(),
behindOverlay: () => state.isSearchActive || (state.prompts && state.prompts.length > 0),
sidebarWidth: () => state.sidebar.width,
shouldShow() {
return (
globalVars.updateAvailable != "" &&
@@ -84,6 +118,51 @@ export default {
},
},
methods: {
getBaseFontSize() {
const rootFontSize = getComputedStyle(document.documentElement).fontSize;
return parseFloat(rootFontSize);
},
handleMobileStateChange(isMobile) {
if (isMobile) {
// If we switch to mobile, save current width and reset to default
if (!this.previousSidebarSize) {
this.previousSidebarSize = state.sidebar.width;
}
mutations.setSidebarWidth(20); // 20 em
} else if (this.previousSidebarSize) {
// When switching to desktop, restore previous width
mutations.setSidebarWidth(this.previousSidebarSize);
this.previousSidebarSize = null;
}
},
startResize(event) {
event.preventDefault();
event.stopPropagation();
// Handle mouse and touch events (maybe someone will want to resize in a big tablet)
const clientX = event.clientX || (event.touches && event.touches[0].clientX);
this.resizeStartX = clientX;
this.resizeStartWidth = this.sidebarWidth;
mutations.setSidebarResizing(true);
document.body.classList.add('sidebar-resizing');
},
handleResize(event) {
if (!state.sidebar.isResizing) return;
event.preventDefault();
// Same here
const clientX = event.clientX || (event.touches && event.touches[0].clientX);
if (!clientX) return;
const deltaX = clientX - this.resizeStartX;
// Convert pixels to em
const baseFontSize = this.getBaseFontSize();
const deltaEm = deltaX / baseFontSize;
const newWidth = this.resizeStartWidth + deltaEm;
mutations.setSidebarWidth(newWidth);
},
stopResize() {
if (!state.sidebar.isResizing) return;
mutations.setSidebarResizing(false);
document.body.classList.remove('sidebar-resizing');
},
// Show the help overlay
help() {
mutations.showHover("help");
@@ -108,22 +187,28 @@ export default {
width: 20em;
position: fixed;
z-index: 4;
left: -20em;
transform: translateZ(0);
height: 100%;
transition: 0.5s ease;
transition: 0.4s ease;
top: 4em;
padding-bottom: 4em;
background-color: rgb(37 49 55 / 5%) !important;
will-change: left;
backface-visibility: hidden;
}
/* sidebar with backdrop-filter support */
@supports (backdrop-filter: none) {
#sidebar {
backdrop-filter: blur(16px) invert(0.1);
backdrop-filter: blur(8px) invert(0.1);
isolation: isolate;
}
#sidebar.dark-mode {
background-color: rgb(37 49 55 / 33%) !important;
}
#sidebar:not(.active) {
backdrop-filter: blur(0) invert(0);
}
}
#sidebar.behind-overlay {
@@ -135,12 +220,11 @@ export default {
}
body.rtl nav {
left: unset;
right: -17em;
transform: translateX(100%);
}
#sidebar.active {
left: 0;
transform: translateZ(0);
}
#sidebar.rtl nav.active {
@@ -207,4 +291,36 @@ body.rtl .action {
display: none; /* Chrome, Safari, and Opera */
}
.sidebar-resizer {
position: absolute;
top: 0;
right: -0.3em;
width: 0.5em;
height: 100%;
cursor: col-resize;
z-index: 1000;
}
.resizer-handle {
position: absolute;
top: 44%;
right: 0;
width: 0.125em;
height: 2.5em;
}
.sidebar-resizer:hover .resizer-handle,
body.sidebar-resizing .resizer-handle {
background-color: var(--primaryColor);
border-radius: 1em;
width: 0.3em;
}
body.sidebar-resizing,
body.sidebar-resizing * {
cursor: col-resize !important;
pointer-events: none;
transition: none !important;
}
</style>
@@ -43,6 +43,8 @@ export default {
align-items: center;
width: 100%;
box-sizing: border-box;
max-width: 1000px;
margin: auto;
}
.button {
-82
View File
@@ -1,82 +0,0 @@
.share {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: flex-start;
}
@media (max-width: 800px) {
.share {
display: block;
}
}
.share__box {
box-shadow: rgba(0, 0, 0, 0.06) 0px 1px 3px, rgba(0, 0, 0, 0.12) 0px 1px 2px;
background: #fff;
border-radius: 1em;
margin: 5px;
overflow: hidden;
}
.share__box__header {
padding: 1em;
text-align: center;
}
.share__box__icon i {
font-size: 10em;
color: #40c4ff;
}
.share__box__center {
text-align: center;
}
.share__box__info {
flex: 1 1 18em;
}
.share__box__element {
padding: 1em;
border-top: 1px solid rgba(0, 0, 0, 0.1);
word-break: break-all;
}
.share__box__element .button {
display: inline-block;
}
.share__box__element .button i {
display: block;
margin-bottom: 4px;
}
.share__box__items {
text-align: left;
flex: 10 0 25em;
}
.share__box__items #listingView.list .listing-item {
cursor: pointer;
border-left: 0;
border-right: 0;
border-bottom: 0;
border-top: 1px solid rgba(0, 0, 0, 0.1);
}
.share__box__items #listingView.list .listing-item .name {
width: 50%;
}
.share__box__items #listingView.list .listing-item .modified {
width: 25%;
}
.share__wrong__password {
background: var(--red);
color: #fff;
padding: .5em;
text-align: center;
animation: .2s opac forwards;
}
+2
View File
@@ -143,6 +143,7 @@
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
user-select: none;
}
.material-symbols-outlined {
@@ -158,4 +159,5 @@
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
user-select: none;
}
-6
View File
@@ -15,12 +15,6 @@ body.halloween-theme,
background-color: black !important;
}
/* Tiled Halloween pattern background for scroll wrapper */
.scroll-wrapper.halloween-theme {
background-image: url("/static/img/halloween-pattern.svg") !important;
background-repeat: repeat !important;
}
#login.halloween-theme {
position: relative;
overflow: hidden;
-6
View File
@@ -467,9 +467,3 @@ body.rtl .listing-items {
width: 100%;
transition: grid-template-columns 0.25s ease-out;
}
/* Account space when sidebar is active - this give the columns a cleaner look */
#main.moveWithSidebar .listing-items.icons .folder-items,
#main.moveWithSidebar .listing-items.icons .file-items {
grid-template-columns: repeat(auto-fill, minmax(calc(var(--icons-view-cell-size, 100px) * 0.9), 1fr));
}
+1 -1
View File
@@ -1,4 +1,4 @@
@media (max-width: 800px) {
@media (max-width: 768px) {
#listingView .header .name,
.list .listing-item .name,
.compact .listing-item .name {
+1 -1
View File
@@ -147,7 +147,7 @@
color: #fff;
}
@media (min-width: 800px) {
@media (min-width: 768px) {
#previewer header #dropdown .action i {
color: #fff;
}
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff. Show more