Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78397fc415 | ||
|
|
5d01e1b1b8 | ||
|
|
3c0632d682 | ||
|
|
85bde31ab8 | ||
|
|
fc42dab33a | ||
|
|
1e66561366 | ||
|
|
5a2e1f38b3 | ||
|
|
c2232f436a | ||
|
|
b0b900c7ed | ||
|
|
a8c6d05833 | ||
|
|
0c23cc8324 | ||
|
|
fe02b9422b | ||
|
|
ad2aed47bc | ||
|
|
5b13673fff | ||
|
+4 |
5c369404e8 | ||
|
+4 |
af8cde651b | ||
|
+4 |
9e95977366 | ||
|
|
063012477e | ||
|
|
ad04c6833c | ||
|
|
45e8da383b | ||
|
+4 |
4167d3e420 | ||
|
|
23f9d98dde | ||
|
+4 |
13162e20e5 | ||
|
+4 |
d2214cc01c | ||
|
+4 |
b7835de1c9 | ||
|
|
a806476686 | ||
|
|
0296216787 | ||
|
|
84ec5c177d | ||
|
+4 |
e72c47dc5e | ||
|
|
eb47df7172 |
No files matched your search
@@ -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**:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -710,6 +710,7 @@ func GenerateYaml() {
|
||||
}
|
||||
|
||||
setupLogging()
|
||||
setupFs()
|
||||
setupAuth(true)
|
||||
|
||||
// Save original paths before setupSources modifies them (for YAML generation)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,10 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
)
|
||||
|
||||
const (
|
||||
LargeFileSizeThreshold = 75 * 1024 * 1024 // 75MB
|
||||
)
|
||||
|
||||
var AllFiletypeOptions = []string{
|
||||
"image",
|
||||
"audio",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 949 B After Width: | Height: | Size: 949 B |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 546 B |
|
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 |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
@@ -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 |
|
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"
|
||||
}
|
||||
@@ -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 |
@@ -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 }}",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 & 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(/&/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 & in URL, try to fix it
|
||||
if (actualSrc && actualSrc.includes('&')) {
|
||||
const fixedSrc = actualSrc.replace(/&/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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -62,7 +62,7 @@ export default {
|
||||
if (!state.shareInfo?.isPasswordProtected) {
|
||||
return true
|
||||
}
|
||||
return state.share.passwordValid
|
||||
return state.shareInfo.passwordValid
|
||||
},
|
||||
shareInfo() {
|
||||
return state.shareInfo;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
@media (max-width: 800px) {
|
||||
@media (max-width: 768px) {
|
||||
#listingView .header .name,
|
||||
.list .listing-item .name,
|
||||
.compact .listing-item .name {
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
@media (min-width: 768px) {
|
||||
#previewer header #dropdown .action i {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||