Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edaa8c536d | ||
|
|
52d57017f2 | ||
|
|
1dd219ad18 | ||
|
|
e584c30225 | ||
|
|
801c029b03 | ||
|
|
bf46e977c4 | ||
|
|
c02a994880 | ||
|
|
2cf352876c | ||
|
|
fb2c97d05a | ||
|
|
0bd58c14ee | ||
|
|
09376139f1 | ||
|
|
59495b7f2f | ||
|
|
d258333f26 | ||
|
|
fe57dd5edc | ||
|
|
b96636279e | ||
|
|
3025442ff8 | ||
|
|
715a044af1 | ||
|
|
a28d515b90 | ||
|
+4 |
990d53fa95 | ||
|
|
f0068d0765 | ||
|
|
8fd13a40bc | ||
|
|
941841fca1 | ||
|
|
0235546a8f | ||
|
|
66482cd1d5 | ||
|
|
d26189968f | ||
|
|
eb518bac56 | ||
|
|
d82e7f2986 | ||
|
|
7cd3f8374e | ||
|
|
4e0f0453f3 | ||
|
|
98246b2981 | ||
|
|
d2c07ffd3c | ||
|
|
43cbadbeb8 | ||
|
|
25dc426522 | ||
|
|
27dbd509b2 | ||
|
|
3716c4e4c6 | ||
|
|
7aabdadf3e | ||
|
|
264de1ce88 | ||
|
|
4aae24cf11 | ||
|
|
0968c44d22 | ||
|
|
a15fe4eaa5 | ||
|
|
a433a8a8d4 | ||
|
|
b5219ae6b6 | ||
|
|
d9f91c7c65 | ||
|
|
4a85746850 | ||
|
|
3ae8aede25 | ||
|
|
3990890721 | ||
|
|
726116d831 | ||
|
|
8274f2c715 | ||
|
|
32969d0777 | ||
|
|
8f25186136 | ||
|
|
e18a73c97a | ||
|
|
e5cb88ba5d | ||
|
|
50b9ee8b31 | ||
|
|
a0e266fadf | ||
|
|
377ae33e1b | ||
|
|
e14f2d7a4b | ||
|
|
fac091f1b3 | ||
|
|
5330d6b093 | ||
|
|
692f99251d | ||
|
|
31e70519c1 | ||
|
|
0b88a90048 | ||
|
|
79d7f07943 | ||
|
|
b562edaaeb | ||
|
|
33ede042da | ||
|
|
036a0ed6c8 | ||
|
|
1842ff561b | ||
|
|
aed9f7c135 | ||
|
|
c6600a0665 | ||
|
|
3e3d3decc6 | ||
|
|
c58b298eb5 | ||
|
|
98ba8095db | ||
|
|
68fb573059 | ||
|
|
5747c40dca | ||
|
|
598bd366db | ||
|
|
e3be3300dc | ||
|
|
416aa75446 | ||
|
|
cfb3b9e869 | ||
|
|
166f1bf232 | ||
|
|
451f9ac69f | ||
|
|
b2fa1780ed | ||
|
|
9255bc174b | ||
|
|
a3a074d7b6 | ||
|
|
aefe16b508 | ||
|
|
728ee72e2a | ||
|
|
e94075de6c | ||
|
|
c56bafb4e9 | ||
|
|
a6e045cc85 | ||
|
|
690685459e | ||
|
|
6f6244b2fb | ||
|
|
05fcd31909 | ||
|
|
a4107a734a | ||
|
|
991be79521 | ||
|
|
8755e82780 | ||
|
|
d1b3a045af | ||
|
|
b5b365c6a7 | ||
|
|
c33a876033 | ||
|
|
42778b92bb | ||
|
|
ff3b66b79d | ||
|
|
daedeef78d | ||
|
|
edba1a80ef | ||
|
|
77fb6bdabc | ||
|
|
97c37caa1c | ||
|
|
02b28284ca | ||
|
|
6a489c6f7c | ||
|
|
ba443398ca | ||
|
|
17ff68c00d | ||
|
|
cb71dd8940 | ||
|
|
764155d612 | ||
|
|
9013152d9d | ||
|
|
27bc000cda | ||
|
|
8f4e420e73 | ||
|
|
e4d8a5cf20 | ||
|
|
fa8ceb50e6 | ||
|
|
c8073db4e6 | ||
|
|
23fac83e7e | ||
|
|
f36ca31c7f | ||
|
|
c0466e96fc | ||
|
|
dc753fe326 | ||
|
|
c39a1937b4 | ||
|
|
5d38b1e66a | ||
|
|
e5e93b4b70 | ||
|
|
eaedbbe05e | ||
|
|
c6d5f32bd0 | ||
|
|
ee022bc4a9 | ||
|
|
daabefa6c3 | ||
|
|
86aebde566 | ||
|
|
a3b0fc0038 | ||
|
|
695364c2df | ||
|
|
58a01ff986 | ||
|
|
3298111560 | ||
|
|
3ca9f54de0 | ||
|
|
97d16d1663 |
No files matched your search
@@ -2,85 +2,6 @@
|
||||
|
||||
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.6-beta
|
||||
|
||||
**Security**:
|
||||
- resolves CVE https://github.com/gtsteffaniak/filebrowser/security/advisories/GHSA-8vrh-3pm2-v4v6
|
||||
|
||||
## v1.2.5-beta
|
||||
|
||||
**Notes**:
|
||||
- Dependency Updates for frontend and backend packages
|
||||
- Upgrade to go 1.26
|
||||
- file list prompt interaction behavior, single-click always selects, double-click navigates. (#1911)
|
||||
|
||||
**BugFixes**:
|
||||
- .ssa and .ass subtitle files get their styling removed during conversion (#1933)
|
||||
- custom icons issue
|
||||
- new database message for noauth (#1935)
|
||||
|
||||
## v1.2.4-beta
|
||||
|
||||
**Notes**:
|
||||
- Changed `/api/media/subtitles` api endpoint to better support subtitles.
|
||||
- Correcting some errors in French language (#1947)
|
||||
- add Dutch `nl.json` for frontend i18n -- thanks @Stephan-P (#1957)
|
||||
- share banner and icon images automatically serve a scaled down 1024x1024
|
||||
|
||||
**BugFixes**:
|
||||
- Can't access directories with space (and possibly other special characters) when browsing a public link share (#1956)
|
||||
- External ASS subtitles are not usable in web preview (#1933)
|
||||
- Filename blocking rename/move (#1950)
|
||||
- enforcedOtp: true blocks all user settings updates with HTTP 400 (#1962)
|
||||
- Share favicon URL not showing up and Share description causing endless loading (#1911)
|
||||
- folder sizes not updated for changes to filesystem outside of filebrowser (#1974)
|
||||
|
||||
## v1.2.3-beta
|
||||
|
||||
**Notes**:
|
||||
- Removed upload api behavior to assume paths ending in "/" are folders, strictly uses isDir query param
|
||||
- renamed public upload api query param from `targetPath` to `path`. see swagger docs.
|
||||
|
||||
**BugFixes**:
|
||||
- fix stuck motion preview popup window issue
|
||||
- Chunked uploads ignore User Scope and are saved to Source Root (#1894)
|
||||
- share delete with user scope issue
|
||||
- fix favicon pwa icon, having the PWA respect the favicon/loginIcon (#1899)
|
||||
- delete prompt icons show image previews
|
||||
|
||||
## 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
|
||||
|
||||
@@ -88,7 +9,8 @@ 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**:
|
||||
|
||||
@@ -6,6 +6,7 @@ WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add --no-cache gcc musl-dev upx
|
||||
ENV CGO_ENABLED=1
|
||||
ENV GOEXPERIMENT=greenteagc
|
||||
RUN go build -tags mupdf,musl -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
|
||||
@@ -26,7 +27,6 @@ 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
|
||||
|
||||
@@ -4,6 +4,7 @@ ARG REVISION
|
||||
WORKDIR /app
|
||||
COPY ./backend ./
|
||||
RUN apk update && apk add upx
|
||||
ENV GOEXPERIMENT=greenteagc
|
||||
RUN go build -ldflags="-w -s \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
|
||||
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
|
||||
@@ -22,7 +23,6 @@ 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,9 +10,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// Cache interface for caching operations
|
||||
@@ -32,61 +29,9 @@ type FileCache struct {
|
||||
}
|
||||
}
|
||||
|
||||
// NewFileCache creates a new FileCache
|
||||
func NewFileCache(dir string) (*FileCache, error) {
|
||||
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
|
||||
return &FileCache{dir: dir}, nil
|
||||
}
|
||||
|
||||
func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
|
||||
@@ -95,11 +40,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), fileutils.PermDir); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(fileName), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(fileName, value, fileutils.PermFile); err != nil {
|
||||
if err := os.WriteFile(fileName, value, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -20,9 +19,6 @@ 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)
|
||||
@@ -32,20 +28,19 @@ 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, "diskcache", cachedFilePath), key, value)
|
||||
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, value)
|
||||
|
||||
// update existing key
|
||||
err = cache.Store(ctx, key, []byte(newValue))
|
||||
require.NoError(t, err)
|
||||
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, newValue)
|
||||
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, newValue)
|
||||
|
||||
// delete key
|
||||
err = cache.Delete(ctx, key)
|
||||
require.NoError(t, err)
|
||||
exists := fileExists(filepath.Join(cacheDir, "diskcache", cachedFilePath))
|
||||
exists := fileExists(filepath.Join(cacheDir, cachedFilePath))
|
||||
require.False(t, exists)
|
||||
}
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: "test",
|
||||
Path: "/mock/path",
|
||||
}, true, false) // true for mock mode, false for isNewDb
|
||||
}, true) // true for mock mode
|
||||
|
||||
// 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, false) // true for mock mode, false for isNewDb
|
||||
}, true) // true for mock mode
|
||||
|
||||
// Get the index and set up mock data
|
||||
idx := indexing.GetIndex("test")
|
||||
|
||||
@@ -2,6 +2,7 @@ package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -10,6 +11,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
@@ -36,18 +39,6 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
|
||||
metadataCount++
|
||||
}
|
||||
}
|
||||
// Process files concurrently using goroutines
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex // Protects processedCount
|
||||
|
||||
// Refresh directory to update folder sizes
|
||||
wg.Go(func() {
|
||||
indexPath := idx.MakeIndexPath(response.Path, true)
|
||||
err := idx.RefreshDirectory(indexPath, false)
|
||||
if err != nil {
|
||||
logger.Debugf("Failed to refresh directory for folder size update: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Set hasMetadata flag if there are files with potential metadata
|
||||
if metadataCount > 0 {
|
||||
@@ -61,6 +52,9 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
|
||||
// Create a single shared FFmpegService instance for all files to coordinate concurrency
|
||||
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
|
||||
if sharedFFmpegService != nil {
|
||||
// Process files concurrently using goroutines
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex // Protects processedCount
|
||||
|
||||
for i := range response.Files {
|
||||
fileItem := &response.Files[i]
|
||||
@@ -94,10 +88,10 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
wg.Wait()
|
||||
}
|
||||
}
|
||||
// Wait for all goroutines to complete
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping)
|
||||
@@ -219,16 +213,17 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
}
|
||||
|
||||
// Handle subtitles if requested
|
||||
parentPath := filepath.Dir(info.Path)
|
||||
parentInfo, exists := idx.GetMetadataInfo(parentPath, true, false)
|
||||
if exists {
|
||||
info.GetSubtitles(parentInfo)
|
||||
}
|
||||
if opts.ExtractEmbeddedSubtitles {
|
||||
subtitles := ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
|
||||
info.Subtitles = append(info.Subtitles, subtitles...)
|
||||
parentPath := filepath.Dir(info.Path)
|
||||
parentInfo, exists := idx.GetReducedMetadata(parentPath, true)
|
||||
if exists {
|
||||
info.DetectSubtitles(parentInfo)
|
||||
err := info.LoadSubtitleContent()
|
||||
if err != nil {
|
||||
logger.Debug("failed to load subtitle content: " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -243,14 +238,23 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
} else {
|
||||
// Copy metadata to ExtendedFileInfo
|
||||
info.Metadata = extItem.Metadata
|
||||
info.HasMetadata = true
|
||||
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
|
||||
info.HasPreview = extItem.Metadata != nil && extItem.Metadata.AlbumArt != ""
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -331,11 +335,11 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract album art if available (stored as raw bytes, auto-encoded to base64 in JSON)
|
||||
// Extract album art and encode as base64 with strict size limits
|
||||
if picture := m.Picture(); picture != nil && picture.Data != nil {
|
||||
// Size limit to prevent memory issues (max 5MB)
|
||||
// More aggressive size limit to prevent memory issues (max 5MB)
|
||||
if len(picture.Data) <= 5*1024*1024 {
|
||||
item.Metadata.AlbumArt = picture.Data
|
||||
item.Metadata.AlbumArt = base64.StdEncoding.EncodeToString(picture.Data)
|
||||
} else {
|
||||
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, len(picture.Data))
|
||||
}
|
||||
@@ -742,30 +746,149 @@ 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) {
|
||||
// First check if it's a text file using the consolidated validation logic
|
||||
isText, err := utils.IsTextFile(realPath)
|
||||
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 "", err
|
||||
}
|
||||
if !isText {
|
||||
// Not a text file, return empty string (no error, just not text)
|
||||
return "", nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// It's a text file, read and return the content
|
||||
// Read header
|
||||
headerBytes := make([]byte, headerSize)
|
||||
n, err := f.Read(headerBytes)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
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)
|
||||
content, err := os.ReadFile(realPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Handle empty file (return specific marker string)
|
||||
// Handle empty file (original logic - returns specific string)
|
||||
if len(content) == 0 {
|
||||
return "empty-file-x6OlSil", nil
|
||||
}
|
||||
|
||||
return string(content), 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
|
||||
}
|
||||
|
||||
func IsNamedPipe(mode os.FileMode) bool {
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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,17 +67,15 @@ 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.
|
||||
secretToStore, nonce, err = encryptSecret(secretText, encryptionKey)
|
||||
secretText, 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, secretToStore+"||"+nonce)
|
||||
// Use the original base32 secret in the OTP URL, not the encrypted version
|
||||
TotpCache.Set(user.Username, secretText+"||"+nonce)
|
||||
url := fmt.Sprintf("otpauth://totp/%v?secret=%v", "FileBrowser Quantum: "+user.Username, secretText)
|
||||
return url, nil
|
||||
}
|
||||
@@ -183,13 +181,12 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
|
||||
Algorithm: TOTPAlgorithm,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("error during TOTP validation for user %s: %v", user.Username, err)
|
||||
logger.Errorf("error during TOTP validation: %v", err)
|
||||
}
|
||||
if !valid {
|
||||
logger.Warningf("Invalid TOTP code for user %s", user.Username)
|
||||
return fmt.Errorf("invalid OTP token")
|
||||
}
|
||||
if totpSecret != "" || !user.OtpEnabled {
|
||||
if totpSecret != "" {
|
||||
user.TOTPSecret = totpSecret // The encrypted or plaintext secret
|
||||
user.TOTPNonce = totpNonce // The nonce if encrypted, or empty if plaintext
|
||||
user.OtpEnabled = true // Enable OTP for the user
|
||||
|
||||
@@ -85,23 +85,6 @@ 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:])
|
||||
@@ -110,6 +93,13 @@ 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,7 +3,6 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
@@ -19,7 +18,6 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/icons"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/preview"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
|
||||
@@ -118,7 +116,7 @@ func StartFilebrowser() {
|
||||
}
|
||||
|
||||
// Initialize shared index database before starting HTTP service
|
||||
isNewDb, err := indexing.InitializeIndexDB()
|
||||
wasRecreated, err := indexing.InitializeIndexDB()
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to initialize index database: %v", err)
|
||||
}
|
||||
@@ -126,15 +124,19 @@ func StartFilebrowser() {
|
||||
// Set indexing storage for persistence
|
||||
if store != nil && store.Indexing != nil {
|
||||
indexing.SetIndexingStorage(store.Indexing)
|
||||
if isNewDb {
|
||||
|
||||
// If the index database was recreated (fresh or corrupted), reset all complexities
|
||||
if wasRecreated {
|
||||
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, isNewDb)
|
||||
go indexing.Initialize(source, false)
|
||||
}
|
||||
validateUserInfo(!dbExists)
|
||||
validateOfficeIntegration()
|
||||
@@ -182,32 +184,14 @@ 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)
|
||||
}
|
||||
|
||||
// Start preview service
|
||||
|
||||
// setup disk cache
|
||||
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
|
||||
if err != nil {
|
||||
logger.Fatalf("Error starting preview service: %v", err)
|
||||
}
|
||||
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
|
||||
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
|
||||
|
||||
// Generate PWA icons after preview service is initialized
|
||||
if err := icons.GeneratePWAIcons(); err != nil {
|
||||
logger.Warningf("Failed to generate PWA icons: %v", err)
|
||||
}
|
||||
|
||||
fbhttp.StartHttp(ctx, store, shutdownComplete)
|
||||
return nil
|
||||
}
|
||||
@@ -278,8 +278,6 @@ func setupFrontend(generate bool) {
|
||||
}
|
||||
// Load custom favicon if configured
|
||||
loadCustomFavicon()
|
||||
// Generate PWA icons from custom favicon if needed
|
||||
generatePWAIcons()
|
||||
}
|
||||
|
||||
func setupMedia() {
|
||||
@@ -728,7 +726,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, allowedFormats []string) (absolutePath string, err error) {
|
||||
func validateCustomImage(configPath, imageName string, maxSize int64, allowedFormats []string) (absolutePath string, err error) {
|
||||
// Get absolute path
|
||||
absolutePath, err = filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
@@ -736,11 +734,16 @@ func validateCustomImage(configPath, imageName string, allowedFormats []string)
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
_, err = os.Stat(absolutePath)
|
||||
stat, 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
|
||||
@@ -759,7 +762,8 @@ func validateCustomImage(configPath, imageName string, allowedFormats []string)
|
||||
|
||||
func loadCustomFavicon() {
|
||||
const imageName = "favicon"
|
||||
allowedFormats := []string{".ico", ".png", ".svg", ".jpg", ".jpeg", ".gif", ".webp"}
|
||||
const maxSize = 1024 * 1024 // 1MB
|
||||
allowedFormats := []string{".ico", ".png", ".svg"}
|
||||
|
||||
// Set default embedded favicon path
|
||||
Env.FaviconEmbeddedPath = "img/icons/favicon.svg"
|
||||
@@ -772,7 +776,7 @@ func loadCustomFavicon() {
|
||||
}
|
||||
|
||||
// Validate custom favicon
|
||||
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, allowedFormats)
|
||||
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, maxSize, allowedFormats)
|
||||
if err != nil {
|
||||
logger.Warningf("Custom favicon validation failed: %v, using default", err)
|
||||
Config.Frontend.Favicon = ""
|
||||
@@ -788,29 +792,18 @@ 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 - just use favicon.svg (light/dark handled by CSS)
|
||||
Env.LoginIconEmbeddedPath = "img/icons/favicon.svg"
|
||||
// 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
|
||||
}
|
||||
|
||||
// Check if a custom login icon path is configured
|
||||
if Config.Frontend.LoginIcon == "" {
|
||||
@@ -820,7 +813,7 @@ func loadLoginIcon() {
|
||||
}
|
||||
|
||||
// Validate custom login icon
|
||||
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, allowedFormats)
|
||||
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, maxSize, allowedFormats)
|
||||
if err != nil {
|
||||
logger.Warningf("Custom login icon validation failed: %v, using default", err)
|
||||
Env.LoginIconPath = Env.LoginIconEmbeddedPath
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/tools/go/packages"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -112,93 +111,74 @@ func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, De
|
||||
return comments, secrets, deprecated, nil
|
||||
}
|
||||
|
||||
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields.
|
||||
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields
|
||||
func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
|
||||
absDir, err := filepath.Abs(dir)
|
||||
fset := token.NewFileSet()
|
||||
pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
var files []*ast.File
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedSyntax | packages.NeedFiles,
|
||||
Dir: absDir,
|
||||
ParseFile: func(fset *token.FileSet, filename string, src []byte) (*ast.File, error) {
|
||||
return parser.ParseFile(fset, filename, src, parser.ParseComments)
|
||||
},
|
||||
}
|
||||
pkgs, err := packages.Load(cfg, ".")
|
||||
if err == nil && len(pkgs) > 0 {
|
||||
for _, pkg := range pkgs {
|
||||
files = append(files, pkg.Syntax...)
|
||||
}
|
||||
}
|
||||
if len(files) == 0 {
|
||||
// Fallback for directories outside a module (e.g. test temp dirs): parse .go files directly
|
||||
files, err = parseDirGoFiles(absDir)
|
||||
if err != nil || len(files) == 0 {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
comments := make(CommentsMap)
|
||||
secrets := make(SecretFieldsMap)
|
||||
deprecated := make(DeprecatedFieldsMap)
|
||||
|
||||
for _, file := range files {
|
||||
for _, decl := range file.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.TYPE {
|
||||
continue
|
||||
}
|
||||
for _, spec := range gen.Specs {
|
||||
ts, ok := spec.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
for _, pkg := range pkgs {
|
||||
for _, file := range pkg.Files {
|
||||
for _, decl := range file.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.TYPE {
|
||||
continue
|
||||
}
|
||||
st, ok := ts.Type.(*ast.StructType)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typeName := ts.Name.Name
|
||||
commentMap := make(map[string]string)
|
||||
secretMap := make(map[string]bool)
|
||||
deprecatedMap := make(map[string]bool)
|
||||
comments[typeName] = commentMap
|
||||
secrets[typeName] = secretMap
|
||||
deprecated[typeName] = deprecatedMap
|
||||
|
||||
for _, field := range st.Fields.List {
|
||||
if len(field.Names) == 0 {
|
||||
for _, spec := range gen.Specs {
|
||||
ts, ok := spec.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name := field.Names[0].Name
|
||||
var parts []string
|
||||
var fullComment string
|
||||
|
||||
if field.Doc != nil {
|
||||
docText := strings.TrimSpace(field.Doc.Text())
|
||||
parts = append(parts, docText)
|
||||
fullComment += docText + " "
|
||||
}
|
||||
if field.Comment != nil {
|
||||
commentText := strings.TrimSpace(field.Comment.Text())
|
||||
parts = append(parts, commentText)
|
||||
fullComment += commentText
|
||||
st, ok := ts.Type.(*ast.StructType)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typeName := ts.Name.Name
|
||||
commentMap := make(map[string]string)
|
||||
secretMap := make(map[string]bool)
|
||||
deprecatedMap := make(map[string]bool)
|
||||
comments[typeName] = commentMap
|
||||
secrets[typeName] = secretMap
|
||||
deprecated[typeName] = deprecatedMap
|
||||
|
||||
if len(parts) > 0 {
|
||||
commentMap[name] = strings.Join(parts, " : ")
|
||||
}
|
||||
for _, field := range st.Fields.List {
|
||||
if len(field.Names) == 0 {
|
||||
continue
|
||||
}
|
||||
name := field.Names[0].Name
|
||||
var parts []string
|
||||
var fullComment string
|
||||
|
||||
// Check if field should be treated as secret
|
||||
if strings.Contains(strings.ToLower(fullComment), "secret:") {
|
||||
secretMap[name] = true
|
||||
}
|
||||
if field.Doc != nil {
|
||||
docText := strings.TrimSpace(field.Doc.Text())
|
||||
parts = append(parts, docText)
|
||||
fullComment += docText + " "
|
||||
}
|
||||
if field.Comment != nil {
|
||||
commentText := strings.TrimSpace(field.Comment.Text())
|
||||
parts = append(parts, commentText)
|
||||
fullComment += commentText
|
||||
}
|
||||
|
||||
// Check if field should be treated as deprecated
|
||||
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
|
||||
deprecatedMap[name] = true
|
||||
if len(parts) > 0 {
|
||||
commentMap[name] = strings.Join(parts, " : ")
|
||||
}
|
||||
|
||||
// Check if field should be treated as secret
|
||||
if strings.Contains(strings.ToLower(fullComment), "secret:") {
|
||||
secretMap[name] = true
|
||||
}
|
||||
|
||||
// Check if field should be treated as deprecated
|
||||
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
|
||||
deprecatedMap[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,29 +187,6 @@ func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, Deprecate
|
||||
return comments, secrets, deprecated, nil
|
||||
}
|
||||
|
||||
// parseDirGoFiles parses all .go files in dir and returns their ASTs. Used when the directory
|
||||
// is not inside a Go module (e.g. test temp dirs) so packages.Load would fail.
|
||||
func parseDirGoFiles(dir string) ([]*ast.File, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
var files []*ast.File
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// PathToTypeField represents a mapping from YAML path to Go type and field info
|
||||
type PathToTypeField struct {
|
||||
TypeName string
|
||||
@@ -753,7 +710,6 @@ func GenerateYaml() {
|
||||
}
|
||||
|
||||
setupLogging()
|
||||
setupFs()
|
||||
setupAuth(true)
|
||||
|
||||
// Save original paths before setupSources modifies them (for YAML generation)
|
||||
|
||||
@@ -36,10 +36,6 @@ 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 {
|
||||
@@ -269,10 +265,9 @@ 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
|
||||
OIDCLoginButtonText string `json:"oidcLoginButtonText"` // text to display on the OIDC login button
|
||||
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
|
||||
}
|
||||
|
||||
type StylingConfig struct {
|
||||
|
||||
@@ -63,18 +63,12 @@ type FileOptions struct {
|
||||
// SanitizeUserPath prevents path traversal attacks by cleaning and validating user input.
|
||||
// Rule 1: Do Not Use User Input in File Paths (without validation)
|
||||
func SanitizeUserPath(userPath string) (string, error) {
|
||||
// Clean resolves . and .. segments
|
||||
clean := filepath.Clean(userPath)
|
||||
|
||||
// Split the path into segments to check for path traversal attempts
|
||||
// We check if ".." appears as a complete path segment (not just in a filename)
|
||||
segments := strings.Split(clean, string(filepath.Separator))
|
||||
|
||||
for _, segment := range segments {
|
||||
// Check if any segment is exactly ".." (path traversal attempt)
|
||||
// This catches any ".." that filepath.Clean couldn't resolve (i.e., escape attempts)
|
||||
if segment == ".." {
|
||||
return "", fmt.Errorf("invalid path: path traversal detected")
|
||||
}
|
||||
// Reject absolute paths or attempts to escape scope
|
||||
if strings.HasPrefix(clean, "..") {
|
||||
return "", fmt.Errorf("invalid path")
|
||||
}
|
||||
|
||||
return clean, nil
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
)
|
||||
|
||||
type SubtitleTrack struct {
|
||||
Name string `json:"name"` // filename for external, or descriptive name for embedded
|
||||
Language string `json:"language"` // language code
|
||||
Title string `json:"title,omitempty"` // title/description
|
||||
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
|
||||
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
|
||||
Embedded bool `json:"embedded"` // true for embedded subtitles, false for external files
|
||||
}
|
||||
|
||||
func GetSubtitleSidecarContent(subtitlePath string) (string, error) {
|
||||
// validate the size is not too large
|
||||
info, err := os.Stat(subtitlePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Size() > 1024*1024*50 { // 50MB
|
||||
return "", errors.New("subtitle file is too large")
|
||||
}
|
||||
|
||||
// First check if it's a text file using the consolidated validation logic
|
||||
isText, err := IsTextFile(subtitlePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !isText {
|
||||
// Not a text file, return empty string (no error, just not text)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// read the file content
|
||||
content, err := os.ReadFile(subtitlePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
@@ -1,65 +1,148 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// 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:
|
||||
// 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:
|
||||
// - Valid UTF-8 encoding
|
||||
// - Not full of null bytes (which indicate binary data)
|
||||
// - Limited null bytes
|
||||
// - Limited non-printable characters
|
||||
func IsTextFile(realPath string) (bool, error) {
|
||||
const sampleSize = 8192 // Read first 8KB to check
|
||||
const maxNullByteRatio = 0.05 // Reject if more than 5% null bytes
|
||||
// Read sample from file
|
||||
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)
|
||||
content, err := os.ReadFile(realPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Empty files are considered text
|
||||
// Handle empty file (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++
|
||||
}
|
||||
}
|
||||
|
||||
if nullCount > 0 {
|
||||
nullRatio := float64(nullCount) / float64(len(sample))
|
||||
if nullRatio > maxNullByteRatio {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
stringContent := string(content)
|
||||
|
||||
// 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) {
|
||||
// 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 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
|
||||
}
|
||||
@@ -247,6 +247,7 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
|
||||
continue
|
||||
}
|
||||
|
||||
oldFullPath := l.Path
|
||||
l.Source = newSource
|
||||
l.Path = newPath
|
||||
|
||||
@@ -263,8 +264,14 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
|
||||
s.shareCache[l.Hash] = l
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
logger.Info("share updated", "hash", l.Hash, "fromPath", oldFullPath, "toPath", l.Path)
|
||||
updated++
|
||||
}
|
||||
|
||||
if updated == 0 {
|
||||
logger.Warning("no matching shares to update for provided source/path")
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,9 @@ 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"
|
||||
)
|
||||
@@ -63,24 +61,9 @@ 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 is new (true) or reused (false)
|
||||
// Returns the IndexDB and a boolean indicating if the database was recreated (true) or reused (false)
|
||||
func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int, disableReuse bool) (*IndexDB, bool, error) {
|
||||
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
|
||||
}
|
||||
}
|
||||
wasRecreated := disableReuse // If disableReuse is true, we're creating fresh
|
||||
|
||||
idxDB, err := createIndexDB(name, journalMode, "", batchSize, cacheSizeMB, disableReuse)
|
||||
if err != nil {
|
||||
@@ -92,8 +75,7 @@ func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int,
|
||||
if !disableReuse {
|
||||
if err := idxDB.checkIntegrityAndRecreateIfNeeded(); err != nil {
|
||||
// Database was recreated due to corruption
|
||||
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
|
||||
wasRecreated = true
|
||||
idxDB.Close()
|
||||
|
||||
// Recreate the database connection
|
||||
@@ -109,7 +91,7 @@ func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int,
|
||||
return nil, false, err
|
||||
}
|
||||
go idxDB.startPeriodicCleanup()
|
||||
return idxDB, isNewDb, nil
|
||||
return idxDB, wasRecreated, nil
|
||||
}
|
||||
|
||||
// checkIntegrityAndRecreateIfNeeded checks database integrity.
|
||||
@@ -541,7 +523,7 @@ func (db *IndexDB) ExplainQueryPlan(query string, args ...interface{}) (string,
|
||||
if err := rows.Scan(&id, &parent, ¬used, &detail); err != nil {
|
||||
return "", err
|
||||
}
|
||||
fmt.Fprintf(&plan, "%d|%d|%s\n", id, parent, detail)
|
||||
plan.WriteString(fmt.Sprintf("%d|%d|%s\n", id, parent, detail))
|
||||
}
|
||||
return plan.String(), rows.Err()
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
|
||||
|
||||
passwordUser := existingUser.LoginMethod == users.LoginMethodPassword
|
||||
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
|
||||
if passwordUser && enforcedOtp && !existingUser.OtpEnabled {
|
||||
if passwordUser && enforcedOtp && !user.OtpEnabled {
|
||||
return errors.ErrNoTotpConfigured
|
||||
}
|
||||
if user.LoginMethod == "" {
|
||||
|
||||
@@ -3,12 +3,11 @@ package ffmpeg
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
)
|
||||
|
||||
var (
|
||||
MediaCache = cache.NewCache[[]utils.SubtitleTrack](24 * time.Hour) // subtitles get cached for 24 hours
|
||||
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // subtitle content gets cached for 24 hours
|
||||
MetadataCache = cache.NewCache[float64](24 * time.Hour) // media duration gets cached for 24 hours
|
||||
MediaCache = cache.NewCache[[]SubtitleTrack](24 * time.Hour) // subtitles get cached for 24 hours
|
||||
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // subtitle content gets cached for 24 hours
|
||||
MetadataCache = cache.NewCache[float64](24 * time.Hour) // media duration gets cached for 24 hours
|
||||
)
|
||||
@@ -10,10 +10,20 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// SubtitleTrack represents a subtitle track (embedded or external file)
|
||||
type SubtitleTrack struct {
|
||||
Name string `json:"name"` // filename for external, or descriptive name for embedded
|
||||
Language string `json:"language,omitempty"` // language code
|
||||
Title string `json:"title,omitempty"` // title/description
|
||||
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
|
||||
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
|
||||
Content string `json:"content,omitempty"` // subtitle content
|
||||
IsFile bool `json:"isFile"` // true for external files, false for embedded streams
|
||||
}
|
||||
|
||||
// FFProbeOutput represents the JSON output from ffprobe
|
||||
type FFProbeOutput struct {
|
||||
Streams []struct {
|
||||
@@ -26,7 +36,7 @@ type FFProbeOutput struct {
|
||||
}
|
||||
|
||||
// DetectAllSubtitles finds both embedded and external subtitle tracks
|
||||
func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) []utils.SubtitleTrack {
|
||||
func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) []SubtitleTrack {
|
||||
key := "all_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
|
||||
|
||||
// Check cache first
|
||||
@@ -34,7 +44,7 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
|
||||
return cached
|
||||
}
|
||||
|
||||
var allSubtitles []utils.SubtitleTrack
|
||||
var allSubtitles []SubtitleTrack
|
||||
|
||||
// First, get embedded subtitles
|
||||
embeddedSubs := detectEmbeddedSubtitles(videoPath)
|
||||
@@ -50,25 +60,9 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
|
||||
return allSubtitles
|
||||
}
|
||||
|
||||
// DetectEmbeddedSubtitles detects embedded subtitle streams using ffprobe.
|
||||
// This is the public API that can be called from other packages.
|
||||
// Returns empty array if ffprobe is not available or fails.
|
||||
func DetectEmbeddedSubtitles(videoPath string, modtime time.Time) []utils.SubtitleTrack {
|
||||
// Check cache first
|
||||
key := "embedded_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
|
||||
if cached, ok := MediaCache.Get(key); ok {
|
||||
return cached
|
||||
}
|
||||
// Detect embedded subtitles
|
||||
subtitles := detectEmbeddedSubtitles(videoPath)
|
||||
// Cache the results
|
||||
MediaCache.Set(key, subtitles)
|
||||
return subtitles
|
||||
}
|
||||
|
||||
// detectEmbeddedSubtitles uses ffprobe to find embedded subtitle tracks
|
||||
// Always runs ffprobe - results are cached for performance
|
||||
func detectEmbeddedSubtitles(realPath string) []utils.SubtitleTrack {
|
||||
func detectEmbeddedSubtitles(realPath string) []SubtitleTrack {
|
||||
cmd := exec.Command("ffprobe",
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
@@ -88,13 +82,13 @@ func detectEmbeddedSubtitles(realPath string) []utils.SubtitleTrack {
|
||||
return nil
|
||||
}
|
||||
|
||||
var subtitles []utils.SubtitleTrack
|
||||
var subtitles []SubtitleTrack
|
||||
for _, stream := range probeOutput.Streams {
|
||||
if stream.CodecType == "subtitle" {
|
||||
track := utils.SubtitleTrack{
|
||||
Index: &stream.Index,
|
||||
Codec: stream.CodecName,
|
||||
Embedded: true, // This is an embedded subtitle
|
||||
track := SubtitleTrack{
|
||||
Index: &stream.Index,
|
||||
Codec: stream.CodecName,
|
||||
IsFile: false, // This is an embedded subtitle
|
||||
}
|
||||
|
||||
// Extract language and title from tags
|
||||
@@ -123,8 +117,8 @@ func detectEmbeddedSubtitles(realPath string) []utils.SubtitleTrack {
|
||||
}
|
||||
|
||||
// detectExternalSubtitles finds external subtitle files in the same directory
|
||||
func detectExternalSubtitles(videoPath string, parentDir string) []utils.SubtitleTrack {
|
||||
var subtitles []utils.SubtitleTrack
|
||||
func detectExternalSubtitles(videoPath string, parentDir string) []SubtitleTrack {
|
||||
var subtitles []SubtitleTrack
|
||||
|
||||
// Get the base name of the video (without extension)
|
||||
videoBaseName := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
|
||||
@@ -137,9 +131,9 @@ func detectExternalSubtitles(videoPath string, parentDir string) []utils.Subtitl
|
||||
subtitlePath := filepath.Join(parentDir, videoBaseName+ext)
|
||||
if _, err := os.Stat(subtitlePath); err == nil {
|
||||
// File exists
|
||||
track := utils.SubtitleTrack{
|
||||
Name: filepath.Base(subtitlePath),
|
||||
Embedded: false, // This is an external file
|
||||
track := SubtitleTrack{
|
||||
Name: filepath.Base(subtitlePath),
|
||||
IsFile: true, // This is an external file
|
||||
}
|
||||
|
||||
// Try to infer language from filename patterns like "video.en.srt"
|
||||
@@ -176,11 +170,120 @@ func ExtractSubtitleContent(videoPath string, streamIndex int) (string, error) {
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
// LoadSubtitleFile loads a subtitle file and returns its raw content
|
||||
func LoadSubtitleFile(subtitlePath string) (string, error) {
|
||||
// LoadAndConvertSubtitleFile loads a subtitle file and converts it to WebVTT format
|
||||
func LoadAndConvertSubtitleFile(subtitlePath string) (string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(subtitlePath))
|
||||
|
||||
if ext == ".vtt" {
|
||||
// Already WebVTT, just read it
|
||||
content, err := os.ReadFile(subtitlePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
if ext == ".srt" {
|
||||
// Convert SRT to WebVTT using ffmpeg
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-i", subtitlePath,
|
||||
"-c:s", "webvtt",
|
||||
"-f", "webvtt",
|
||||
"-") // output to stdout
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to convert SRT to WebVTT: %v", err)
|
||||
}
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
// For other formats, try to read as plain text and hope for the best
|
||||
// This is a fallback - ideally you'd want format-specific converters
|
||||
content, err := os.ReadFile(subtitlePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(content), nil
|
||||
|
||||
// Basic conversion wrapper for non-VTT formats
|
||||
vttHeader := "WEBVTT\n\n"
|
||||
return vttHeader + string(content), nil
|
||||
}
|
||||
|
||||
// ExtractSingleSubtitle extracts content for a specific subtitle track by array index
|
||||
func ExtractSingleSubtitle(videoPath string, parentDir string, trackIndex int, modtime time.Time) (SubtitleTrack, error) {
|
||||
// Get all subtitle tracks
|
||||
allTracks := DetectAllSubtitles(videoPath, parentDir, modtime)
|
||||
|
||||
if trackIndex >= len(allTracks) {
|
||||
return SubtitleTrack{}, fmt.Errorf("subtitle track %d not found (only %d tracks available)", trackIndex, len(allTracks))
|
||||
}
|
||||
|
||||
track := allTracks[trackIndex]
|
||||
|
||||
// Load content based on type
|
||||
if track.IsFile {
|
||||
// Load external subtitle file
|
||||
subtitlePath := filepath.Join(parentDir, track.Name)
|
||||
content, err := LoadAndConvertSubtitleFile(subtitlePath)
|
||||
if err != nil {
|
||||
return SubtitleTrack{}, fmt.Errorf("failed to load external subtitle: %v", err)
|
||||
}
|
||||
track.Content = content
|
||||
} else {
|
||||
// Extract embedded subtitle content
|
||||
if track.Index == nil {
|
||||
return SubtitleTrack{}, fmt.Errorf("embedded subtitle track has no stream index")
|
||||
}
|
||||
content, err := ExtractSubtitleContent(videoPath, *track.Index)
|
||||
if err != nil {
|
||||
return SubtitleTrack{}, fmt.Errorf("failed to extract embedded subtitle: %v", err)
|
||||
}
|
||||
track.Content = content
|
||||
}
|
||||
|
||||
return track, nil
|
||||
}
|
||||
|
||||
// LoadAllSubtitleContent loads the actual content for all detected subtitle tracks
|
||||
func LoadAllSubtitleContent(videoPath string, subtitles []SubtitleTrack, modtime time.Time) error {
|
||||
for idx := range subtitles {
|
||||
subtitle := &subtitles[idx]
|
||||
|
||||
// Check if content is already cached
|
||||
contentKey := fmt.Sprintf("subtitle_content:%s:%d:%s", videoPath, idx, modtime.Format(time.RFC3339))
|
||||
if cached, ok := SubtitleContentCache.Get(contentKey); ok {
|
||||
subtitle.Content = cached
|
||||
continue
|
||||
}
|
||||
|
||||
var content string
|
||||
var err error
|
||||
|
||||
if subtitle.IsFile {
|
||||
// Load external subtitle file content and convert to WebVTT
|
||||
subtitlePath := filepath.Join(filepath.Dir(videoPath), subtitle.Name)
|
||||
content, err = LoadAndConvertSubtitleFile(subtitlePath)
|
||||
if err != nil {
|
||||
logger.Debug("failed to read/convert subtitle file " + subtitlePath + ": " + err.Error())
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// Load embedded subtitle content
|
||||
if subtitle.Index == nil {
|
||||
logger.Debug("embedded subtitle has no stream index")
|
||||
continue
|
||||
}
|
||||
content, err = ExtractSubtitleContent(videoPath, *subtitle.Index)
|
||||
if err != nil {
|
||||
logger.Debug("failed to extract embedded subtitle: " + err.Error())
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
subtitle.Content = content
|
||||
// Cache the content for future requests
|
||||
SubtitleContentCache.Set(contentKey, content)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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.7.0
|
||||
github.com/coreos/go-systemd/v22 v22.6.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
|
||||
@@ -16,22 +16,21 @@ require (
|
||||
github.com/gtsteffaniak/go-cache v1.1.0
|
||||
github.com/gtsteffaniak/go-logger v1.0.2
|
||||
github.com/kovidgoyal/imaging v1.8.19
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/swaggo/http-swagger v1.3.4
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/image v0.36.0
|
||||
golang.org/x/mod v0.33.0
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
golang.org/x/sys v0.41.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/image v0.35.0
|
||||
golang.org/x/mod v0.32.0
|
||||
golang.org/x/net v0.49.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/sys v0.40.0
|
||||
golang.org/x/time v0.14.0
|
||||
golang.org/x/tools v0.42.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.46.1
|
||||
modernc.org/sqlite v1.44.3
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -53,17 +52,16 @@ require (
|
||||
github.com/Djarvur/go-err113 v0.1.1 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/MirrexOne/unqueryvet v1.5.3 // indirect
|
||||
github.com/MirrexOne/unqueryvet v1.4.0 // indirect
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
|
||||
github.com/air-verse/air v1.64.5 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
|
||||
github.com/air-verse/air v1.64.2 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.21.1 // indirect
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
|
||||
github.com/alexkohler/prealloc v1.0.2 // indirect
|
||||
github.com/alexkohler/prealloc v1.0.1 // indirect
|
||||
github.com/alfatraining/structtag v1.0.0 // indirect
|
||||
github.com/alingse/asasalint v0.0.11 // indirect
|
||||
github.com/alingse/nilnesserr v0.2.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/ashanbrown/forbidigo/v2 v2.3.0 // indirect
|
||||
github.com/ashanbrown/makezero/v2 v2.1.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
@@ -73,7 +71,7 @@ require (
|
||||
github.com/bkielbasa/cyclop v1.2.3 // indirect
|
||||
github.com/blizzy78/varnamelen v0.8.0 // indirect
|
||||
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
|
||||
github.com/bombsimon/wsl/v5 v5.6.0 // indirect
|
||||
github.com/bombsimon/wsl/v5 v5.3.0 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/breml/bidichk v0.3.3 // indirect
|
||||
github.com/breml/errchkjson v0.4.1 // indirect
|
||||
@@ -85,7 +83,7 @@ require (
|
||||
github.com/charithe/durationcheck v0.0.11 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.8.0 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/ckaznocha/intrange v0.3.1 // indirect
|
||||
@@ -99,15 +97,15 @@ require (
|
||||
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.0-alpha.4 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/ettle/strcase v0.2.0 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fatih/structtag v1.2.0 // indirect
|
||||
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.13 // indirect
|
||||
github.com/ghostiam/protogetter v0.3.20 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // 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
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
@@ -130,21 +128,21 @@ require (
|
||||
github.com/go-toolsmith/astp v1.1.0 // indirect
|
||||
github.com/go-toolsmith/strparse v1.1.0 // indirect
|
||||
github.com/go-toolsmith/typep v1.1.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
|
||||
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-20260129164528-943061e2742c // indirect
|
||||
github.com/golang/geo v0.0.0-20260120070133-792bb8583fbb // 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
|
||||
github.com/golangci/go-printf-func-name v0.1.1 // indirect
|
||||
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.10.1 // indirect
|
||||
github.com/golangci/golines v0.15.0 // indirect
|
||||
github.com/golangci/misspell v0.8.0 // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.8.0 // indirect
|
||||
github.com/golangci/golines v0.14.0 // indirect
|
||||
github.com/golangci/misspell v0.7.0 // indirect
|
||||
github.com/golangci/plugin-module-register v0.1.2 // indirect
|
||||
github.com/golangci/revgrep v0.8.0 // indirect
|
||||
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
|
||||
@@ -166,7 +164,7 @@ require (
|
||||
github.com/jjti/go-spancheck v0.6.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/julz/importas v0.2.0 // indirect
|
||||
github.com/jupiterrider/ffi v0.6.0 // indirect
|
||||
github.com/jupiterrider/ffi v0.5.1 // indirect
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
|
||||
github.com/kisielk/errcheck v1.9.0 // indirect
|
||||
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
|
||||
@@ -196,7 +194,7 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
|
||||
github.com/mgechev/revive v1.14.0 // indirect
|
||||
github.com/mgechev/revive v1.13.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
|
||||
github.com/moricho/tparallel v0.3.2 // indirect
|
||||
@@ -205,7 +203,7 @@ require (
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/nishanths/exhaustive v0.12.0 // indirect
|
||||
github.com/nishanths/predeclared v0.2.2 // indirect
|
||||
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
|
||||
github.com/nunnatsa/ginkgolinter v0.21.2 // indirect
|
||||
github.com/pelletier/go-toml v1.9.5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
@@ -230,8 +228,8 @@ require (
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
|
||||
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
|
||||
github.com/securego/gosec/v2 v2.23.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/securego/gosec/v2 v2.22.11 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/sivchari/containedctx v1.0.3 // indirect
|
||||
github.com/sonatard/noctx v0.4.0 // indirect
|
||||
github.com/sourcegraph/go-diff v0.7.0 // indirect
|
||||
@@ -264,21 +262,23 @@ require (
|
||||
gitlab.com/bosi/decorder v0.4.2 // indirect
|
||||
go-simpler.org/musttag v0.14.0 // indirect
|
||||
go-simpler.org/sloglint v0.11.1 // indirect
|
||||
go.augendre.info/arangolint v0.4.0 // indirect
|
||||
go.augendre.info/arangolint v0.3.1 // indirect
|
||||
go.augendre.info/fatcontext v0.9.0 // indirect
|
||||
go.etcd.io/bbolt v1.4.3 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
honnef.co/go/tools v0.7.0 // indirect
|
||||
modernc.org/libc v1.68.0 // indirect
|
||||
honnef.co/go/tools v0.6.1 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
mvdan.cc/gofumpt v0.9.2 // indirect
|
||||
|
||||
@@ -73,18 +73,18 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/MirrexOne/unqueryvet v1.5.3 h1:LpT3rsH+IY3cQddWF9bg4C7jsbASdGnrOSofY8IPEiw=
|
||||
github.com/MirrexOne/unqueryvet v1.5.3/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
|
||||
github.com/MirrexOne/unqueryvet v1.4.0 h1:6KAkqqW2KUnkl9Z0VuTphC3IXRPoFqEkJEtyxxHj5eQ=
|
||||
github.com/MirrexOne/unqueryvet v1.4.0/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
|
||||
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
|
||||
github.com/air-verse/air v1.64.5 h1:+gs/NgTzYYe+gGPyfHy3XxpJReQWC1pIsiKIg0LgNt4=
|
||||
github.com/air-verse/air v1.64.5/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
|
||||
github.com/air-verse/air v1.64.2 h1:H49xKRa4r3uFUycmCCA+JectVuZLNMPGdypLFpoHVcY=
|
||||
github.com/air-verse/air v1.64.2/go.mod h1:SOwv08NTvKextanAY819AK0FK6XzQO5/tC6s1R+rk8o=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
||||
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||
github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
|
||||
github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1 h1:u9aUvbGINJxLVXiFvHUlPEaD7VDULsrxJb4Aq31NLkU=
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1/go.mod h1:A8TSiN3UPRw3laIgWEUOHHLPa6/r9MtoigdlP5h3K/E=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
@@ -96,16 +96,14 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF
|
||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ=
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q=
|
||||
github.com/alexkohler/prealloc v1.0.2 h1:MPo8cIkGkZytq7WNH9UHv3DIX1mPz1RatPXnZb0zHWQ=
|
||||
github.com/alexkohler/prealloc v1.0.2/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
|
||||
github.com/alexkohler/prealloc v1.0.1 h1:A9P1haqowqUxWvU9nk6tQ7YktXIHf+LQM9wPRhuteEE=
|
||||
github.com/alexkohler/prealloc v1.0.1/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
|
||||
github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc=
|
||||
github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus=
|
||||
github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw=
|
||||
github.com/alingse/asasalint v0.0.11/go.mod h1:nCaoMhw7a9kSJObvQyVzNTPBDbNpdocqrSP7t/cW5+I=
|
||||
github.com/alingse/nilnesserr v0.2.0 h1:raLem5KG7EFVb4UIDAXgrv3N2JIaffeKNtcEXkEWd/w=
|
||||
github.com/alingse/nilnesserr v0.2.0/go.mod h1:1xJPrXonEtX7wyTq8Dytns5P2hNzoWymVUIaKm4HNFg=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtSjAnSzRucrJz+3iGEFt+ysraELS81M=
|
||||
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
|
||||
@@ -156,8 +154,8 @@ github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ
|
||||
github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
|
||||
github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ=
|
||||
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
|
||||
github.com/bombsimon/wsl/v5 v5.6.0 h1:4z+/sBqC5vUmSp1O0mS+czxwH9+LKXtCWtHH9rZGQL8=
|
||||
github.com/bombsimon/wsl/v5 v5.6.0/go.mod h1:Uqt2EfrMj2NV8UGoN1f1Y3m0NpUVCsUdrNCdet+8LvU=
|
||||
github.com/bombsimon/wsl/v5 v5.3.0 h1:nZWREJFL6U3vgW/B1lfDOigl+tEF6qgs6dGGbFeR0UM=
|
||||
github.com/bombsimon/wsl/v5 v5.3.0/go.mod h1:Gp8lD04z27wm3FANIUPZycXp+8huVsn0oxc+n4qfV9I=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
|
||||
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
@@ -184,8 +182,8 @@ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4p
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
@@ -201,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.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
||||
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/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=
|
||||
@@ -244,8 +242,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.10.0-alpha.4 h1:JzPbdf+cqbyT9sZtP4xnqelwUXwf7LvD8xKS6+ofTds=
|
||||
github.com/ebitengine/purego v0.10.0-alpha.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
@@ -267,16 +265,16 @@ 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.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
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/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=
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0=
|
||||
github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
|
||||
github.com/ghostiam/protogetter v0.3.18 h1:yEpghRGtP9PjKvVXtEzGpYfQj1Wl/ZehAfU6fr62Lfo=
|
||||
github.com/ghostiam/protogetter v0.3.18/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
|
||||
github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog=
|
||||
github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
@@ -357,8 +355,8 @@ github.com/go-toolsmith/strparse v1.1.0 h1:GAioeZUK9TGxnLS+qfdqNbA4z0SSm5zVNtCQi
|
||||
github.com/go-toolsmith/strparse v1.1.0/go.mod h1:7ksGy58fsaQkGQlY8WVoBFNyEPMGuJin1rfoPS4lBSQ=
|
||||
github.com/go-toolsmith/typep v1.1.0 h1:fIRYDyF+JywLfqzyhdiHzRop/GQDxxNhLGQ6gFUNHus=
|
||||
github.com/go-toolsmith/typep v1.1.0/go.mod h1:fVIw+7zjdsMxDA3ITWnH1yOiw1rnTQKCsF/sk2H/qig=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
|
||||
github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
|
||||
@@ -367,8 +365,8 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM=
|
||||
github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo=
|
||||
github.com/godoc-lint/godoc-lint v0.11.1 h1:z9as8Qjiy6miRIa3VRymTa+Gt2RLnGICVikcvlUVOaA=
|
||||
github.com/godoc-lint/godoc-lint v0.11.1/go.mod h1:BAqayheFSuZrEAqCRxgw9MyvsM+S/hZwJbU1s/ejRj8=
|
||||
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
|
||||
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
@@ -393,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-20260129164528-943061e2742c h1:ysO2h2Odnl1AJM1I2Lm/fa6JvO0pECMSt2CwBaa+ITo=
|
||||
github.com/golang/geo v0.0.0-20260129164528-943061e2742c/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
|
||||
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/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=
|
||||
@@ -434,12 +432,12 @@ github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarog
|
||||
github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
|
||||
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE=
|
||||
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
|
||||
github.com/golangci/golangci-lint/v2 v2.10.1 h1:flhw5Px6ojbLyEFzXvJn5B2HEdkkRlkhE1SnmCbQBiE=
|
||||
github.com/golangci/golangci-lint/v2 v2.10.1/go.mod h1:dBsrOk6zj0vDhlTv+IiJGqkDokR24IVTS7W3EVfPTQY=
|
||||
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
|
||||
github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
|
||||
github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
|
||||
github.com/golangci/misspell v0.8.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
|
||||
github.com/golangci/golangci-lint/v2 v2.8.0 h1:wJnr3hJWY3eVzOUcfwbDc2qbi2RDEpvLmQeNFaPSNYA=
|
||||
github.com/golangci/golangci-lint/v2 v2.8.0/go.mod h1:xl+HafQ9xoP8rzw0z5AwnO5kynxtb80e8u02Ej/47RI=
|
||||
github.com/golangci/golines v0.14.0 h1:xt9d3RKBjhasA3qpoXs99J2xN2t6eBlpLHt0TrgyyXc=
|
||||
github.com/golangci/golines v0.14.0/go.mod h1:gf555vPG2Ia7mmy2mzmhVQbVjuK8Orw0maR1G4vVAAQ=
|
||||
github.com/golangci/misspell v0.7.0 h1:4GOHr/T1lTW0hhR4tgaaV1WS/lJ+ncvYCoFKmqJsj0c=
|
||||
github.com/golangci/misspell v0.7.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
|
||||
github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg=
|
||||
github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
|
||||
github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
|
||||
@@ -473,8 +471,8 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf
|
||||
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
|
||||
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
|
||||
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -545,8 +543,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
|
||||
github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
|
||||
github.com/jupiterrider/ffi v0.6.0 h1:UX378KcZvH5c8qgLi9KL/bL82SZTHdRspZ+jj7bvBng=
|
||||
github.com/jupiterrider/ffi v0.6.0/go.mod h1:PqZ5Go6X9by8CIXgfprxfMPYmn8oT5m2O7AA56s64bY=
|
||||
github.com/jupiterrider/ffi v0.5.1 h1:l7ANXU+Ex33LilVa283HNaf/sTzCrrht7D05k6T6nlc=
|
||||
github.com/jupiterrider/ffi v0.5.1/go.mod h1:x7xdNKo8h0AmLuXfswDUBxUsd2OqUP4ekC8sCnsmbvo=
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
|
||||
github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
|
||||
@@ -624,12 +622,12 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/mgechev/revive v1.14.0 h1:CC2Ulb3kV7JFYt+izwORoS3VT/+Plb8BvslI/l1yZsc=
|
||||
github.com/mgechev/revive v1.14.0/go.mod h1:MvnujelCZBZCaoDv5B3foPo6WWgULSSFxvfxp7GsPfo=
|
||||
github.com/mgechev/revive v1.13.0 h1:yFbEVliCVKRXY8UgwEO7EOYNopvjb1BFbmYqm9hZjBM=
|
||||
github.com/mgechev/revive v1.13.0/go.mod h1:efJfeBVCX2JUumNQ7dtOLDja+QKj9mYGgEZA7rt5u+0=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
@@ -661,8 +659,8 @@ github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhK
|
||||
github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
|
||||
github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
|
||||
github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
|
||||
github.com/nunnatsa/ginkgolinter v0.23.0 h1:x3o4DGYOWbBMP/VdNQKgSj+25aJKx2Pe6lHr8gBcgf8=
|
||||
github.com/nunnatsa/ginkgolinter v0.23.0/go.mod h1:9qN1+0akwXEccwV1CAcCDfcoBlWXHB+ML9884pL4SZ4=
|
||||
github.com/nunnatsa/ginkgolinter v0.21.2 h1:khzWfm2/Br8ZemX8QM1pl72LwM+rMeW6VUbQ4rzh0Po=
|
||||
github.com/nunnatsa/ginkgolinter v0.21.2/go.mod h1:GItSI5fw7mCGLPmkvGYrr1kEetZe7B593jcyOpyabsY=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||
@@ -673,10 +671,10 @@ github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
|
||||
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
|
||||
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
|
||||
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
|
||||
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
||||
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||
github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
|
||||
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
|
||||
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
|
||||
github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
|
||||
github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
|
||||
github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
|
||||
@@ -700,6 +698,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
|
||||
@@ -759,8 +759,8 @@ github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tM
|
||||
github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
|
||||
github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ=
|
||||
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
|
||||
github.com/securego/gosec/v2 v2.23.0 h1:h4TtF64qFzvnkqvsHC/knT7YC5fqyOCItlVR8+ptEBo=
|
||||
github.com/securego/gosec/v2 v2.23.0/go.mod h1:qRHEgXLFuYUDkI2T7W7NJAmOkxVhkR0x9xyHOIcMNZ0=
|
||||
github.com/securego/gosec/v2 v2.22.11 h1:tW+weM/hCM/GX3iaCV91d5I6hqaRT2TPsFM1+USPXwg=
|
||||
github.com/securego/gosec/v2 v2.22.11/go.mod h1:KE4MW/eH0GLWztkbt4/7XpyH0zJBBnu7sYB4l6Wn7Mw=
|
||||
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
|
||||
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
|
||||
@@ -769,8 +769,8 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+Wwfd0XE=
|
||||
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
|
||||
github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o=
|
||||
@@ -802,6 +802,7 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
|
||||
@@ -852,8 +853,6 @@ github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pH
|
||||
github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yagipy/maintidx v1.0.0 h1:h5NvIsCz+nRDapQ0exNv4aJ0yXSI0420omVANTv3GJM=
|
||||
github.com/yagipy/maintidx v1.0.0/go.mod h1:0qNf/I/CCZXSMhsRsrEPDZ+DkekpKLXAJfsTACwgXLk=
|
||||
github.com/yeya24/promlinter v0.3.0 h1:JVDbMp08lVCP7Y6NP3qHroGAO6z2yGKQtS5JsjqtoFs=
|
||||
@@ -879,8 +878,8 @@ go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo=
|
||||
go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE=
|
||||
go-simpler.org/sloglint v0.11.1 h1:xRbPepLT/MHPTCA6TS/wNfZrDzkGvCCqUv4Bdwc3H7s=
|
||||
go-simpler.org/sloglint v0.11.1/go.mod h1:2PowwiCOK8mjiF+0KGifVOT8ZsCNiFzvfyJeJOIt8MQ=
|
||||
go.augendre.info/arangolint v0.4.0 h1:xSCZjRoS93nXazBSg5d0OGCi9APPLNMmmLrC995tR50=
|
||||
go.augendre.info/arangolint v0.4.0/go.mod h1:l+f/b4plABuFISuKnTGD4RioXiCCgghv2xqst/xOvAA=
|
||||
go.augendre.info/arangolint v0.3.1 h1:n2E6p8f+zfXSFLa2e2WqFPp4bfvcuRdd50y6cT65pSo=
|
||||
go.augendre.info/arangolint v0.3.1/go.mod h1:6ZKzEzIZuBQwoSvlKT+qpUfIbBfFCE5gbAoTg0/117g=
|
||||
go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE=
|
||||
go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw=
|
||||
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||
@@ -891,6 +890,8 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
@@ -908,8 +909,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -920,16 +921,16 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
|
||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
|
||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
|
||||
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk=
|
||||
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
|
||||
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 h1:HDjDiATsGqvuqvkDvgJjD1IgPrVekcSXVVE21JwvzGE=
|
||||
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
|
||||
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
@@ -955,8 +956,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -999,16 +1000,16 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -1070,6 +1071,7 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20211105183446-c75c47738b0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -1078,8 +1080,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -1096,8 +1098,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -1153,8 +1155,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
|
||||
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
|
||||
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
|
||||
@@ -1260,6 +1262,7 @@ gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
@@ -1269,22 +1272,22 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
|
||||
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
|
||||
honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
|
||||
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
|
||||
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
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.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||
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=
|
||||
@@ -1293,8 +1296,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
|
||||
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
libError "errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -397,6 +398,10 @@ func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
|
||||
}
|
||||
|
||||
password := r.Header.Get("X-SHARE-PASSWORD")
|
||||
password, err := url.QueryUnescape(password)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(l.PasswordHash), []byte(password)); err != nil {
|
||||
if libError.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||
return http.StatusUnauthorized, nil
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -393,10 +394,13 @@ 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)
|
||||
adjustedPath := strings.TrimPrefix(fileInfo.Path, opts.combinedPath)
|
||||
if adjustedPath == "" {
|
||||
adjustedPath = "/"
|
||||
}
|
||||
|
||||
resultGroup = append(resultGroup, &indexing.SearchResult{
|
||||
Path: adjustedPath,
|
||||
Source: opts.source,
|
||||
Type: fileInfo.Type,
|
||||
Size: fileInfo.Size,
|
||||
Modified: fileInfo.ModTime.Format(time.RFC3339),
|
||||
@@ -555,10 +559,7 @@ func mergeGroupsByChecksum(groups []duplicateGroupWithChecksums) []duplicateGrou
|
||||
|
||||
func prepDuplicatesOptions(r *http.Request, d *requestContext) (*duplicatesOptions, error) {
|
||||
source := r.URL.Query().Get("source")
|
||||
scope, err := utils.SanitizeUserPath(r.URL.Query().Get("scope"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid scope: %v", err)
|
||||
}
|
||||
scope := r.URL.Query().Get("scope")
|
||||
|
||||
minSizeMbStr := r.URL.Query().Get("minSizeMb")
|
||||
// First pass uses filename matching for speed, then checksums verify final groups
|
||||
@@ -567,14 +568,19 @@ func prepDuplicatesOptions(r *http.Request, d *requestContext) (*duplicatesOptio
|
||||
// Default minimum size: 1MB
|
||||
minSizeMb := int64(1)
|
||||
if minSizeMbStr != "" {
|
||||
if _, err = fmt.Sscanf(minSizeMbStr, "%d", &minSizeMb); err != nil {
|
||||
if _, err := fmt.Sscanf(minSizeMbStr, "%d", &minSizeMb); err != nil {
|
||||
return nil, fmt.Errorf("invalid minSizeMb parameter: %w", err)
|
||||
}
|
||||
}
|
||||
// Convert MB to bytes
|
||||
minSize := minSizeMb * 1024 * 1024
|
||||
|
||||
searchScope := strings.TrimPrefix(scope, ".")
|
||||
unencodedScope, err := url.PathUnescape(scope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
|
||||
searchScope := strings.TrimPrefix(unencodedScope, ".")
|
||||
|
||||
index := indexing.GetIndex(source)
|
||||
if index == nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -130,18 +131,23 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
path := r.URL.Query().Get("path")
|
||||
encodedPath := 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
|
||||
path, err = utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
|
||||
// Parse lines parameter
|
||||
lines := 10 // default
|
||||
@@ -276,23 +282,25 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to read file content")
|
||||
}
|
||||
|
||||
path := r.URL.Query().Get("path")
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
linesStr := r.URL.Query().Get("lines")
|
||||
intervalStr := r.URL.Query().Get("interval")
|
||||
|
||||
var err error
|
||||
// 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")
|
||||
}
|
||||
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
path = cleanPath
|
||||
|
||||
// Parse lines parameter
|
||||
lines := 10 // default
|
||||
if linesStr != "" {
|
||||
|
||||
@@ -15,7 +15,6 @@ 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"
|
||||
@@ -28,9 +27,14 @@ import (
|
||||
//go:embed embed/*
|
||||
var assets embed.FS
|
||||
|
||||
// GetEmbeddedAssets returns the embedded assets filesystem
|
||||
func GetEmbeddedAssets() embed.FS {
|
||||
return assets
|
||||
// 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)
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -52,10 +56,20 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
}()
|
||||
}
|
||||
|
||||
// Get the asset filesystem from fileutils
|
||||
assetFs = fileutils.GetAssetFS()
|
||||
if assetFs == nil {
|
||||
logger.Fatal("Asset filesystem not initialized. Call fileutils.InitAssetFS first.")
|
||||
// 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")}
|
||||
}
|
||||
|
||||
// In development mode, we want to reload the templates on each request.
|
||||
@@ -74,9 +88,6 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
devMode: settings.Env.IsDevMode,
|
||||
}
|
||||
|
||||
// Initialize cached PWA manifest
|
||||
InitializePWAManifest()
|
||||
|
||||
router := http.NewServeMux()
|
||||
// API group routing
|
||||
api := http.NewServeMux()
|
||||
@@ -198,10 +209,14 @@ 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 route
|
||||
// Standard browser favicon and manifest routes
|
||||
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,39 +4,46 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
)
|
||||
|
||||
// subtitlesHandler handles subtitle requests for both external files and embedded streams
|
||||
// @Summary Get subtitle content
|
||||
// @Description Returns raw subtitle content from external files or embedded streams
|
||||
// subtitlesHandler handles subtitle extraction requests
|
||||
// @Summary Extract embedded subtitles
|
||||
// @Description Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content
|
||||
// @Tags Subtitles
|
||||
// @Accept json
|
||||
// @Produce text/plain
|
||||
// @Produce text/vtt
|
||||
// @Param path query string true "Index path to the video file"
|
||||
// @Param source query string true "Source name for the desired source"
|
||||
// @Param name query string true "Subtitle track name (filename for external, descriptive name for embedded)"
|
||||
// @Param embedded query bool false "Whether this is an embedded stream (true) or external file (false), defaults to false"
|
||||
// @Success 200 {string} string "Raw subtitle content in original format"
|
||||
// @Param index query int false "Stream index for embedded subtitle extraction, defaults to 0"
|
||||
// @Success 200 {string} string "Raw WebVTT subtitle content"
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 404 {object} map[string]string "Resource not found"
|
||||
// @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) {
|
||||
path := r.URL.Query().Get("path")
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
name := r.URL.Query().Get("name")
|
||||
embedded := r.URL.Query().Get("embedded") == "true"
|
||||
indexParam := r.URL.Query().Get("index")
|
||||
|
||||
if name == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("name parameter is required")
|
||||
if indexParam == "" {
|
||||
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)
|
||||
@@ -48,54 +55,28 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if idx == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
|
||||
}
|
||||
|
||||
realPath, _, err := idx.GetRealPath(userscope, path)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
|
||||
parentDir := filepath.Dir(realPath)
|
||||
var content string
|
||||
|
||||
if !embedded {
|
||||
content, err = utils.GetSubtitleSidecarContent(filepath.Join(parentDir, name))
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to get subtitle sidecar content: %v", err)
|
||||
}
|
||||
} else {
|
||||
// For embedded subtitles, we need to find the stream index by name
|
||||
// Get file modification time for caching
|
||||
fileInfo, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to stat file: %v", err)
|
||||
}
|
||||
|
||||
// Detect embedded subtitles
|
||||
embeddedSubs := ffmpeg.DetectEmbeddedSubtitles(realPath, fileInfo.ModTime())
|
||||
|
||||
// Find the subtitle track by name
|
||||
var streamIndex *int
|
||||
for _, sub := range embeddedSubs {
|
||||
if sub.Name == name {
|
||||
streamIndex = sub.Index
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if streamIndex == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("embedded subtitle track '%s' not found", name)
|
||||
}
|
||||
|
||||
content, err = ffmpeg.ExtractSubtitleContent(realPath, *streamIndex)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to extract embedded subtitle: %v", err)
|
||||
}
|
||||
metadata, exists := idx.GetMetadataInfo(userscope, true, false)
|
||||
if !exists {
|
||||
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
|
||||
}
|
||||
|
||||
// Return raw content with appropriate content type
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
index, err := strconv.Atoi(indexParam)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid index parameter: %v", err)
|
||||
}
|
||||
parentDir := filepath.Dir(realPath)
|
||||
subtitle, err := ffmpeg.ExtractSingleSubtitle(realPath, parentDir, index, metadata.ModTime)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to extract subtitle: %v", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", "inline")
|
||||
w.Header().Set("Cache-Control", "private")
|
||||
http.ServeContent(w, r, name, time.Now(), bytes.NewReader([]byte(content)))
|
||||
http.ServeContent(w, r, fmt.Sprintf("%s-%d.vtt", subtitle.Name, index),
|
||||
time.Now(), bytes.NewReader([]byte(subtitle.Content)))
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -25,7 +26,6 @@ import (
|
||||
|
||||
type requestContext struct {
|
||||
user *users.User
|
||||
shareUser *users.User
|
||||
fileInfo iteminfo.ExtendedFileInfo
|
||||
token string
|
||||
share *share.Link
|
||||
@@ -51,7 +51,12 @@ type handleFunc func(w http.ResponseWriter, r *http.Request, data *requestContex
|
||||
func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
return withOrWithoutUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
|
||||
hash := r.URL.Query().Get("hash")
|
||||
path := r.URL.Query().Get("path")
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
// Decode the URL-encoded path - use PathUnescape to preserve + as literal character
|
||||
path, err := url.PathUnescape(encodedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
|
||||
// Get the file link by hash
|
||||
link, err := store.Share.GetByHash(hash)
|
||||
@@ -91,6 +96,7 @@ 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"
|
||||
@@ -98,42 +104,37 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
if link.DisableFileViewer || reachedDownloadsLimit {
|
||||
getContent = false
|
||||
}
|
||||
data.shareUser, err = store.Users.Get(link.UserID)
|
||||
shareCreatedByUser, err := store.Users.Get(link.UserID)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("user for share no longer exists")
|
||||
}
|
||||
// get user scope path from share
|
||||
userScope, err := data.shareUser.GetScopeForSourceName(source.Name)
|
||||
userScope, err := shareCreatedByUser.GetScopeForSourceName(source.Name)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
// so trim user scope from link.Path
|
||||
pathWithoutUserScope := utils.JoinPathAsUnix("/", strings.TrimPrefix(link.Path, userScope), path)
|
||||
if !strings.HasSuffix(pathWithoutUserScope, "/") {
|
||||
pathWithoutUserScope = pathWithoutUserScope + "/"
|
||||
}
|
||||
data.IndexPath = pathWithoutUserScope
|
||||
if r.Method == "POST" && strings.Contains(r.URL.Path, "/resources") {
|
||||
// no need to fetch file info for uploads
|
||||
return fn(w, r, data)
|
||||
userScopedPath := utils.JoinPathAsUnix("/", strings.TrimPrefix(link.Path, userScope), path)
|
||||
if !strings.HasSuffix(userScopedPath, "/") {
|
||||
userScopedPath = userScopedPath + "/"
|
||||
}
|
||||
data.IndexPath = userScopedPath
|
||||
file, err := FileInfoFasterFunc(utils.FileOptions{
|
||||
Path: pathWithoutUserScope,
|
||||
Path: userScopedPath,
|
||||
Source: source.Name,
|
||||
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,
|
||||
}, store.Access, data.shareUser)
|
||||
}, store.Access, shareCreatedByUser)
|
||||
if err != nil {
|
||||
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, path, err)
|
||||
return errToStatus(err), fmt.Errorf("error fetching share from server")
|
||||
}
|
||||
file.Token = link.Token
|
||||
file.Source = link.Hash
|
||||
file.Source = ""
|
||||
file.Hash = link.Hash
|
||||
if !link.EnableOnlyOffice || link.DisableFileViewer || reachedDownloadsLimit {
|
||||
file.OnlyOfficeId = ""
|
||||
|
||||
@@ -80,14 +80,6 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
logger.Errorf("OnlyOffice callback missing required parameters: source=%s, path=%s", source, providedPath)
|
||||
return http.StatusBadRequest, errors.New("missing required parameters: path + source/hash are required")
|
||||
}
|
||||
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(providedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
providedPath = cleanPath
|
||||
|
||||
themeMode := utils.Ternary(d.user.DarkMode, "dark", "light")
|
||||
var sourceInfo *settings.Source
|
||||
var ok bool
|
||||
@@ -110,8 +102,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
// Build file info based on whether this is a share or regular request
|
||||
// Regular user request
|
||||
logger.Debugf("OnlyOffice user request: request path=%s", path)
|
||||
var fileInfo *iteminfo.ExtendedFileInfo
|
||||
fileInfo, err = files.FileInfoFaster(utils.FileOptions{
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Path: path,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
@@ -345,15 +336,6 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
logger.Errorf("OnlyOffice callback missing required parameters: source=%s, path=%s", source, path)
|
||||
return returnOnlyOfficeError(w, r, 400, "missing required parameters: path + source/hash are required")
|
||||
}
|
||||
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
logger.Errorf("OnlyOffice callback: invalid path: %v", err)
|
||||
return returnOnlyOfficeError(w, r, 400, "invalid path")
|
||||
}
|
||||
path = cleanPath
|
||||
|
||||
var sourceInfo *settings.Source
|
||||
var ok bool
|
||||
if d.fileInfo.Hash != "" {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/preview"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
@@ -86,6 +87,10 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
}
|
||||
|
||||
func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.ExtendedFileInfo) (int, error) {
|
||||
idx := indexing.GetIndex(file.Source)
|
||||
if idx == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source not found: %s", file.Source)
|
||||
}
|
||||
fd, err := os.Open(file.RealPath)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
@@ -157,7 +162,7 @@ func getDirectoryPreview(r *http.Request, d *requestContext) (*iteminfo.Extended
|
||||
|
||||
func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
previewSize := r.URL.Query().Get("size")
|
||||
if !(previewSize == "large" || previewSize == "original" || previewSize == "xlarge") {
|
||||
if !(previewSize == "large" || previewSize == "original") {
|
||||
previewSize = "small"
|
||||
}
|
||||
if !d.fileInfo.HasPreview {
|
||||
@@ -178,13 +183,9 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
isImage := strings.HasPrefix(d.fileInfo.Type, "image")
|
||||
ext := strings.ToLower(filepath.Ext(d.fileInfo.Name))
|
||||
resizable := iteminfo.ResizableImageTypes[ext]
|
||||
|
||||
// If image is under 500KB, serve the original instead of generating a preview
|
||||
const maxSizeForOriginal = 500 * 1024 // 500KB
|
||||
if (!resizable || config.Server.DisableResize || d.fileInfo.Size < maxSizeForOriginal) && isImage {
|
||||
if (!resizable || config.Server.DisableResize) && isImage {
|
||||
return rawFileHandler(w, r, d.fileInfo)
|
||||
}
|
||||
|
||||
seekPercentage := 0
|
||||
percentage := r.URL.Query().Get("atPercentage")
|
||||
if percentage != "" {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
@@ -12,7 +13,6 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/preview"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
|
||||
@@ -77,8 +77,8 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
}
|
||||
|
||||
// Get all "file" parameter values (supports repeated params)
|
||||
files := r.URL.Query()["file"]
|
||||
if len(files) == 0 {
|
||||
encodedFiles := r.URL.Query()["file"]
|
||||
if len(encodedFiles) == 0 {
|
||||
return http.StatusBadRequest, fmt.Errorf("no files specified")
|
||||
}
|
||||
|
||||
@@ -89,17 +89,16 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
}
|
||||
actualSourceName := sourceInfo.Name
|
||||
|
||||
// Process each file path
|
||||
// Decode and process each file path
|
||||
fileList := []string{}
|
||||
for _, file := range files {
|
||||
// Rule 1: Validate each file path to prevent path traversal
|
||||
cleanFile, err := utils.SanitizeUserPath(file)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid file path: %v", err)
|
||||
for _, encodedFile := range encodedFiles {
|
||||
// Decode the URL-encoded path - use PathUnescape to preserve + as literal character
|
||||
file, decodeErr := url.PathUnescape(encodedFile)
|
||||
if decodeErr != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", decodeErr)
|
||||
}
|
||||
|
||||
// Join the share path with the requested path
|
||||
filePath := utils.JoinPathAsUnix(d.share.Path, cleanFile)
|
||||
filePath := utils.JoinPathAsUnix(d.share.Path, file)
|
||||
fileList = append(fileList, filePath)
|
||||
}
|
||||
|
||||
@@ -108,8 +107,8 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if err == errors.ErrDownloadNotAllowed {
|
||||
return http.StatusForbidden, errors.ErrDownloadNotAllowed
|
||||
}
|
||||
logger.Errorf("public share handler: error processing filelist: %v with error %v", files, err)
|
||||
return status, fmt.Errorf("error processing filelist: %v", files)
|
||||
logger.Errorf("public share handler: error processing filelist: %v with error %v", encodedFiles, err)
|
||||
return status, fmt.Errorf("error processing filelist: %v", encodedFiles)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
@@ -144,7 +143,7 @@ func publicGetResourceHandler(w http.ResponseWriter, r *http.Request, d *request
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param hash query string true "Share hash for authentication"
|
||||
// @Param path query string true "path within the share to upload to. Must be relative to share root."
|
||||
// @Param targetPath query string true "Target path within the share to upload to. Must be relative to share root."
|
||||
// @Param override query bool false "If true, overwrite existing files/folders. Defaults to false."
|
||||
// @Param action query string false "Upload action: 'override' to replace files, 'rename' to auto-rename"
|
||||
// @Param file formData file true "File to upload"
|
||||
@@ -160,15 +159,21 @@ func publicUploadHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
if d.share.ShareType != "upload" && !d.share.AllowCreate {
|
||||
return http.StatusForbidden, fmt.Errorf("uploading is disabled for this share")
|
||||
}
|
||||
|
||||
if !d.share.AllowReplacements && r.URL.Query().Get("action") == "override" {
|
||||
return http.StatusForbidden, fmt.Errorf("cannot overwrite files for this share")
|
||||
}
|
||||
// Go automatically decodes query params
|
||||
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)
|
||||
}
|
||||
fullPath := filepath.Join(d.share.Path, path)
|
||||
source := config.Server.SourceMap[d.share.Source].Name
|
||||
// adjust query params to match resourcePostHandler
|
||||
q := r.URL.Query()
|
||||
q.Set("source", source)
|
||||
q.Set("path", d.IndexPath)
|
||||
q.Set("path", fullPath)
|
||||
r.URL.RawQuery = q.Encode()
|
||||
status, err := resourcePostHandler(w, r, d)
|
||||
if err != nil {
|
||||
@@ -218,6 +223,8 @@ 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)
|
||||
@@ -255,16 +262,14 @@ 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")
|
||||
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(path)
|
||||
// Decode the URL-encoded path
|
||||
path, err = url.QueryUnescape(path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
|
||||
resolvedPath := utils.JoinPathAsUnix(d.share.Path, cleanPath)
|
||||
resolvedPath := utils.JoinPathAsUnix(d.share.Path, path)
|
||||
err = files.WriteFile(source, resolvedPath, r.Body)
|
||||
// hide the error
|
||||
if err != nil {
|
||||
@@ -274,26 +279,44 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
// deprecated -- see publicBulkDeleteHandler
|
||||
func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
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)
|
||||
}
|
||||
indexPath := utils.JoinPathAsUnix(d.share.Path, path)
|
||||
source, err := d.share.GetSourceName()
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source not available")
|
||||
}
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: d.IndexPath,
|
||||
Source: d.share.Source,
|
||||
}, store.Access, d.shareUser)
|
||||
Path: indexPath,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
ShowHidden: d.share.ShowHidden,
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("resource not available")
|
||||
}
|
||||
err = files.DeleteFiles(d.share.Source, fileInfo.RealPath, fileInfo.Type == "directory")
|
||||
|
||||
// delete thumbnails
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
|
||||
err = files.DeleteFiles(source, fileInfo.RealPath, fileInfo.Type == "directory")
|
||||
if err != nil {
|
||||
logger.Errorf("public delete handler: error deleting resource with error %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("an error occured while deleting the resource")
|
||||
}
|
||||
// delete thumbnails
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
@@ -315,10 +338,10 @@ func publicBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestC
|
||||
if !d.share.AllowDelete {
|
||||
return http.StatusForbidden, fmt.Errorf("delete is not allowed for this share")
|
||||
}
|
||||
|
||||
// hide the error
|
||||
status, err := resourceBulkDeleteHandler(w, r, d)
|
||||
if err != nil {
|
||||
logger.Errorf("public bulk delete handler: error deleting resources with error %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("an error occurred while processing the request")
|
||||
}
|
||||
return status, nil
|
||||
@@ -393,15 +416,15 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
return status, err
|
||||
}
|
||||
|
||||
// getShareImage serves banner or favicon files for shares as resizable previews
|
||||
// @Summary Get share image (banner or favicon) as preview
|
||||
// @Description Returns a resizable preview (large size) for the banner or favicon file of a share
|
||||
// getShareImage serves banner or favicon files for shares
|
||||
// @Summary Get share image (banner or favicon)
|
||||
// @Description Serves the banner or favicon file for a share
|
||||
// @Tags Public Shares
|
||||
// @Produce image/jpeg
|
||||
// @Produce octet-stream
|
||||
// @Param hash query string true "Share hash"
|
||||
// @Param banner query bool false "Request banner file"
|
||||
// @Param favicon query bool false "Request favicon file"
|
||||
// @Success 200 {file} file "Preview image content (JPEG)"
|
||||
// @Success 200 {file} binary "Image file content"
|
||||
// @Failure 400 {object} map[string]string "Invalid request"
|
||||
// @Failure 403 {object} map[string]string "Permission denied"
|
||||
// @Failure 404 {object} map[string]string "Asset not found"
|
||||
@@ -446,24 +469,9 @@ func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid file type, must be image")
|
||||
}
|
||||
|
||||
// Set file info in request context for preview generation
|
||||
d.fileInfo = *fileInfo
|
||||
q := r.URL.Query()
|
||||
if isBanner {
|
||||
q.Set("size", "xlarge")
|
||||
} else {
|
||||
q.Set("size", "small")
|
||||
}
|
||||
r.URL.RawQuery = q.Encode()
|
||||
|
||||
// Use the preview helper to generate and serve a resized preview
|
||||
status, err := previewHelperFunc(w, r, d)
|
||||
if err != nil {
|
||||
logger.Errorf("error generating preview for share asset: source=%v path=%v error=%v", sourceName, assetPath, err)
|
||||
return http.StatusNotFound, fmt.Errorf("preview not available for this asset")
|
||||
}
|
||||
|
||||
return status, err
|
||||
// Serve the file
|
||||
http.ServeFile(w, r, fileInfo.RealPath)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func getShareImagePartsHelper(share *share.Link, isBanner bool) (string, string, error) {
|
||||
@@ -491,43 +499,3 @@ func getShareImagePartsHelper(share *share.Link, isBanner bool) (string, string,
|
||||
|
||||
return sourceName, assetPath, nil
|
||||
}
|
||||
|
||||
// shareInfoHandler retrieves share information by hash.
|
||||
// @Summary Get share information by hash
|
||||
// @Description Returns information about a share link based on its hash. This endpoint is publicly accessible and can be used with or without authentication.
|
||||
// @Tags Shares
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param hash query string true "Hash of the share link"
|
||||
// @Success 200 {object} share.CommonShare "Share information"
|
||||
// @Failure 404 {object} map[string]string "Share hash not found"
|
||||
// @Router /public/api/shareinfo [get]
|
||||
func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
hash := r.URL.Query().Get("hash")
|
||||
// Get the file link by hash (need full Link to get Token)
|
||||
shareLink, err := store.Share.GetByHash(hash)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("share hash not found")
|
||||
}
|
||||
commonShare := shareLink.CommonShare
|
||||
commonShare.ShareURL = getShareURL(r, hash, false, "")
|
||||
_, _, err = getShareImagePartsHelper(shareLink, true)
|
||||
if err == nil {
|
||||
commonShare.BannerUrl = fmt.Sprintf("%spublic/api/share/image?banner=true&hash=%s", config.Server.BaseURL, hash)
|
||||
}
|
||||
_, _, err = getShareImagePartsHelper(shareLink, false)
|
||||
if err == nil {
|
||||
commonShare.FaviconUrl = fmt.Sprintf("%spublic/api/share/image?favicon=true&hash=%s", config.Server.BaseURL, hash)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -113,16 +113,6 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
|
||||
func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
source := r.URL.Query().Get("source")
|
||||
fileList := r.URL.Query()["file"]
|
||||
|
||||
// Rule 1: Validate all user-provided file paths to prevent path traversal
|
||||
for i, filePath := range fileList {
|
||||
cleanPath, err := utils.SanitizeUserPath(filePath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid file path: %v", err)
|
||||
}
|
||||
fileList[i] = cleanPath
|
||||
}
|
||||
|
||||
return rawFilesHandler(w, r, d, source, fileList)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
@@ -70,8 +71,17 @@ 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) {
|
||||
path := r.URL.Query().Get("path")
|
||||
encodedPath := 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{
|
||||
@@ -126,9 +136,20 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to delete")
|
||||
}
|
||||
|
||||
path := r.URL.Query().Get("path")
|
||||
// TODO source := r.URL.Query().Get("source")
|
||||
encodedPath := 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")
|
||||
}
|
||||
@@ -203,10 +224,6 @@ type MoveCopyResponse struct {
|
||||
// @Failure 500 {object} map[string]string "Internal server error - all deletions failed"
|
||||
// @Router /api/resources/bulk/delete [post]
|
||||
func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
filePermUser := d.user
|
||||
if d.share != nil {
|
||||
filePermUser = d.shareUser
|
||||
}
|
||||
// Check permissions - either user delete permission or share delete permission
|
||||
if d.share == nil {
|
||||
if !d.user.Permissions.Delete {
|
||||
@@ -252,29 +269,18 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
}
|
||||
|
||||
if d.share != nil {
|
||||
indexPath := utils.JoinPathAsUnix(d.share.Path, item.Path)
|
||||
source, err := d.share.GetSourceName()
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source not available")
|
||||
}
|
||||
// get user scope path from share
|
||||
userScope, err := d.shareUser.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
Path: item.Path,
|
||||
Message: "user does not have access",
|
||||
})
|
||||
continue
|
||||
}
|
||||
withoutUserScope := strings.TrimPrefix(d.share.Path, userScope)
|
||||
indexPath := utils.JoinPathAsUnix(withoutUserScope, item.Path)
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: indexPath,
|
||||
Source: source,
|
||||
ShowHidden: true,
|
||||
}, store.Access, filePermUser)
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("resource not available")
|
||||
}
|
||||
@@ -304,7 +310,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
}
|
||||
|
||||
// Check user scope for this source
|
||||
_, err := filePermUser.GetScopeForSourceName(item.Source)
|
||||
_, err := d.user.GetScopeForSourceName(item.Source)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
@@ -330,7 +336,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
Path: idx.MakeIndexPath(item.Path, false),
|
||||
Source: item.Source,
|
||||
ShowHidden: true,
|
||||
}, store.Access, filePermUser)
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
@@ -369,7 +375,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
// @Tags Resources
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param path query string true "url encoded destination path where to place the files inside the destination source"
|
||||
// @Param path query string true "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory"
|
||||
// @Param source query string true "Name for the desired filebrowser destination source name, default is used if not provided"
|
||||
// @Param override query bool false "Override existing file if true"
|
||||
// @Param isDir query bool false "Explicitly specify if the resource is a directory"
|
||||
@@ -381,23 +387,35 @@ 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")
|
||||
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
path = cleanPath
|
||||
|
||||
if d.share == nil && !d.user.Permissions.Create {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
|
||||
}
|
||||
filePermUser := d.user
|
||||
var err error
|
||||
accessStore := store.Access
|
||||
// if share is not nil, then set accessStore to nil
|
||||
if d.share != nil {
|
||||
filePermUser = d.shareUser
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
isDir := r.URL.Query().Get("isDir") == "true"
|
||||
|
||||
// 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, "/")
|
||||
fileOpts := utils.FileOptions{
|
||||
Path: path,
|
||||
Source: source,
|
||||
@@ -409,21 +427,10 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
logger.Debugf("source %s not found", source)
|
||||
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
|
||||
}
|
||||
|
||||
userscope, err := filePermUser.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
logger.Debugf("error getting scope from source name: %v", err)
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
userscope = strings.TrimRight(userscope, "/")
|
||||
|
||||
fullIndexPath := utils.JoinPathAsUnix(userscope, path)
|
||||
|
||||
// get scoped path
|
||||
realPath, _, _ := idx.GetRealPath(fullIndexPath)
|
||||
realPath, _, _ := idx.GetRealPath(path)
|
||||
|
||||
// Check access control for the target path
|
||||
if !store.Access.Permitted(idx.Path, path, filePermUser.Username) {
|
||||
if accessStore != nil && !accessStore.Permitted(idx.Path, path, d.user.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
|
||||
}
|
||||
|
||||
@@ -442,6 +449,14 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
|
||||
// Directories creation on POST.
|
||||
if isDir {
|
||||
// Get user scope to resolve full index path for directory creation
|
||||
var userScope string
|
||||
userScope, err = d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
|
||||
|
||||
// Create a new FileOptions with the full index path
|
||||
dirOpts := fileOpts
|
||||
dirOpts.Path = fullIndexPath
|
||||
@@ -487,7 +502,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
}
|
||||
|
||||
var fileInfo *iteminfo.ExtendedFileInfo
|
||||
fileInfo, err = files.FileInfoFaster(fileOpts, store.Access, filePermUser)
|
||||
fileInfo, err = files.FileInfoFaster(fileOpts, accessStore, d.user)
|
||||
if err == nil { // File exists
|
||||
if r.URL.Query().Get("override") != "true" {
|
||||
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
|
||||
@@ -537,16 +552,23 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// close file before moving
|
||||
outFile.Close()
|
||||
// Move the completed file from the temp location to the final destination
|
||||
err = files.MoveResource(false, source, source, tempFilePath, realPath, store.Share, store.Access)
|
||||
err = fileutils.MoveFile(tempFilePath, realPath)
|
||||
if err != nil {
|
||||
logger.Debugf("could not move file from %v to %v: %v", tempFilePath, realPath, err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("could not move file from chunked folder to destination: %v", err)
|
||||
}
|
||||
// Refresh index with user scope
|
||||
userScope, scopeErr := d.user.GetScopeForSourceName(source)
|
||||
if scopeErr == nil {
|
||||
fullIndexPath := utils.JoinPathAsUnix(userScope, fileOpts.Path)
|
||||
go files.RefreshIndex(source, fullIndexPath, false, false) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(fileOpts, store.Access, filePermUser)
|
||||
fileInfo, err := files.FileInfoFaster(fileOpts, accessStore, d.user)
|
||||
if err == nil { // File exists
|
||||
if r.URL.Query().Get("override") != "true" {
|
||||
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
|
||||
@@ -556,6 +578,13 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
}
|
||||
|
||||
// Get user scope to resolve full index path for write operation
|
||||
userScope, err := d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
|
||||
|
||||
err = files.WriteFile(fileOpts.Source, fullIndexPath, r.Body)
|
||||
if err != nil {
|
||||
logger.Debugf("error writing file: %v", err)
|
||||
@@ -580,15 +609,20 @@ 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")
|
||||
path := r.URL.Query().Get("path")
|
||||
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(path)
|
||||
var err error
|
||||
// decode url encoded source name
|
||||
source, err = url.QueryUnescape(source)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
|
||||
}
|
||||
path = cleanPath
|
||||
|
||||
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
|
||||
@@ -656,7 +690,6 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
|
||||
// Process each item
|
||||
for _, item := range req.Items {
|
||||
|
||||
// Validate all fields are provided
|
||||
if item.FromSource == "" || item.FromPath == "" || item.ToSource == "" || item.ToPath == "" {
|
||||
item.Message = "fromSource, fromPath, toSource, and toPath are required"
|
||||
@@ -669,26 +702,26 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
response.Failed = append(response.Failed, item)
|
||||
continue
|
||||
}
|
||||
cleanFromPath, err := utils.SanitizeUserPath(item.FromPath)
|
||||
if err != nil {
|
||||
item.Message = fmt.Sprintf("invalid fromPath: %v", err)
|
||||
|
||||
// Check for root path modifications
|
||||
if item.ToPath == "/" || item.FromPath == "/" {
|
||||
item.Message = "cannot modify root directory"
|
||||
if d.share != nil {
|
||||
response.Failed = append(response.Failed, MoveCopyItem{
|
||||
Message: item.Message,
|
||||
})
|
||||
continue
|
||||
}
|
||||
response.Failed = append(response.Failed, item)
|
||||
continue
|
||||
}
|
||||
cleanToPath, err := utils.SanitizeUserPath(item.ToPath)
|
||||
if err != nil {
|
||||
item.Message = fmt.Sprintf("invalid toPath: %v", err)
|
||||
response.Failed = append(response.Failed, item)
|
||||
continue
|
||||
}
|
||||
item.FromPath = cleanFromPath
|
||||
item.ToPath = cleanToPath
|
||||
|
||||
// Get user scopes for both sources
|
||||
// For shares, paths are already absolute, so use empty scope
|
||||
userscopeSrc := ""
|
||||
userscopeDst := ""
|
||||
if d.share == nil {
|
||||
var err error
|
||||
userscopeSrc, err = d.user.GetScopeForSourceName(item.FromSource)
|
||||
if err != nil {
|
||||
item.Message = "source not available"
|
||||
@@ -734,11 +767,6 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
// Build full index paths for access control
|
||||
fullSrcIndexPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
|
||||
fullDstIndexPath := utils.JoinPathAsUnix(userscopeDst, item.ToPath)
|
||||
if fullDstIndexPath == "/" || fullSrcIndexPath == "/" {
|
||||
item.Message = "source or destination is the root or unautharized directory"
|
||||
response.Failed = append(response.Failed, item)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check access control for both source and destination paths
|
||||
if !store.Access.Permitted(srcIdx.Path, fullSrcIndexPath, d.user.Username) {
|
||||
@@ -942,8 +970,10 @@ func patchAction(ctx context.Context, params patchActionParams) error {
|
||||
}
|
||||
|
||||
func inspectIndex(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Query().Get("path")
|
||||
encodedPath := 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,6 +3,7 @@ package http
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -120,13 +121,8 @@ 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") // Go automatically decodes query params
|
||||
scope := r.URL.Query().Get("scope")
|
||||
largest := r.URL.Query().Get("largest") == "true"
|
||||
cleanScope, err := utils.SanitizeUserPath(scope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid scope: %v", err)
|
||||
}
|
||||
scope = cleanScope
|
||||
|
||||
var sources []string
|
||||
if sourcesParam != "" {
|
||||
@@ -145,10 +141,14 @@ 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(scope, ".")
|
||||
searchScope := strings.TrimPrefix(unencodedScope, ".")
|
||||
|
||||
// If multiple sources, always use user scope (ignore searchScope)
|
||||
if len(sources) > 1 {
|
||||
|
||||
@@ -116,8 +116,13 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/share [get]
|
||||
func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
path := r.URL.Query().Get("path")
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
sourceName := r.URL.Query().Get("source")
|
||||
// Decode the URL-encoded path
|
||||
path, err := url.PathUnescape(encodedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
sourceInfo, ok := config.Server.NameToSource[sourceName] // backend source is path
|
||||
if !ok {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source name: %s", sourceName)
|
||||
@@ -358,14 +363,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
providedPath := body.Path
|
||||
|
||||
// Rule 1: Validate user-provided path to prevent path traversal
|
||||
cleanPath, err := utils.SanitizeUserPath(providedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
|
||||
body.Path = utils.JoinPathAsUnix(userscope, cleanPath)
|
||||
body.Path = utils.JoinPathAsUnix(userscope, providedPath)
|
||||
body.Path = utils.AddTrailingSlashIfNotExists(body.Path)
|
||||
// validate path exists as file or folder
|
||||
_, _, err = idx.GetRealPath(body.Path)
|
||||
@@ -422,17 +420,23 @@ type DirectDownloadResponse struct {
|
||||
// @Router /api/share/direct [get]
|
||||
func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// Extract query parameters
|
||||
path := r.URL.Query().Get("path")
|
||||
encodedPath := 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 path == "" || source == "" {
|
||||
if encodedPath == "" || 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 {
|
||||
@@ -592,6 +596,39 @@ func getShareURL(r *http.Request, hash string, isDirectDownload bool, token stri
|
||||
return shareURL
|
||||
}
|
||||
|
||||
// shareInfoHandler retrieves share information by hash.
|
||||
// @Summary Get share information by hash
|
||||
// @Description Returns information about a share link based on its hash. This endpoint is publicly accessible and can be used with or without authentication.
|
||||
// @Tags Shares
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param hash query string true "Hash of the share link"
|
||||
// @Success 200 {object} share.CommonShare "Share information"
|
||||
// @Failure 404 {object} map[string]string "Share hash not found"
|
||||
// @Router /public/api/shareinfo [get]
|
||||
func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
hash := r.URL.Query().Get("hash")
|
||||
// Get the file link by hash (need full Link to get Token)
|
||||
shareLink, err := store.Share.GetByHash(hash)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("share hash not found")
|
||||
}
|
||||
commonShare := shareLink.CommonShare
|
||||
commonShare.DownloadURL = getShareURL(r, hash, true, shareLink.Token)
|
||||
commonShare.ShareURL = getShareURL(r, hash, false, shareLink.Token)
|
||||
_, _, err = getShareImagePartsHelper(shareLink, true)
|
||||
if err == nil {
|
||||
commonShare.BannerUrl = fmt.Sprintf("%spublic/api/share/image?banner=true&hash=%s", config.Server.BaseURL, hash)
|
||||
}
|
||||
_, _, err = getShareImagePartsHelper(shareLink, false)
|
||||
if err == nil {
|
||||
commonShare.FaviconUrl = fmt.Sprintf("%spublic/api/share/image?favicon=true&hash=%s", config.Server.BaseURL, hash)
|
||||
}
|
||||
commonShare.Source = ""
|
||||
commonShare.Path = ""
|
||||
return renderJSON(w, r, commonShare)
|
||||
}
|
||||
|
||||
func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, err error) {
|
||||
if body.Password == "" {
|
||||
return nil, 0, nil
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
@@ -16,7 +15,6 @@ import (
|
||||
)
|
||||
|
||||
var templateRenderer *TemplateRenderer
|
||||
var cachedManifest PWAManifest // generated at startup
|
||||
|
||||
type TemplateRenderer struct {
|
||||
templates *template.Template
|
||||
@@ -75,16 +73,11 @@ 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 + "/pwa-icon-512.png" // Use largest generated icon for best quality
|
||||
banner := staticURL + "/img/icons/android-chrome-512x512.png"
|
||||
disableSidebar := false
|
||||
|
||||
// Use custom favicon if configured and validated, otherwise fall back to default
|
||||
// Determine the correct favicon extension based on type
|
||||
faviconExt := ".svg" // Default to SVG
|
||||
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) != ".svg" {
|
||||
faviconExt = ".png"
|
||||
}
|
||||
favicon := staticURL + "/favicon" + faviconExt
|
||||
favicon := staticURL + "/favicon"
|
||||
shareHash := ""
|
||||
data := make(map[string]interface{})
|
||||
disableNavButtons := settings.Config.Frontend.DisableNavButtons
|
||||
@@ -111,7 +104,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
if d.share.DisableSidebar {
|
||||
disableSidebar = true
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(d.share.Banner), "http") {
|
||||
if strings.HasPrefix(d.share.Banner, "http") {
|
||||
banner = d.share.Banner
|
||||
} else {
|
||||
_, _, err := getShareImagePartsHelper(d.share, true)
|
||||
@@ -119,7 +112,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
banner = fmt.Sprintf("%s%spublic/api/share/image?banner=true&hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, d.share.Hash)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(d.share.Favicon), "http") {
|
||||
if strings.HasPrefix(d.share.Favicon, "http") {
|
||||
favicon = d.share.Favicon
|
||||
} else {
|
||||
_, _, err := getShareImagePartsHelper(d.share, false)
|
||||
@@ -134,17 +127,18 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
|
||||
// Load loading spinners CSS from static files
|
||||
loadingSpinnersCSS := ""
|
||||
cssPath := "css/loadingSpinners.css"
|
||||
cssPath := "static/css/loadingSpinners.css"
|
||||
cssContent, err := fs.ReadFile(assetFs, cssPath)
|
||||
if err == nil {
|
||||
loadingSpinnersCSS = string(cssContent)
|
||||
}
|
||||
|
||||
// Determine OpenGraph image: use banner if set, otherwise use largest available icon (512x512)
|
||||
// Determine OpenGraph image: use banner if set, otherwise use dedicated OG image or fallback to app icon
|
||||
ogImage := banner
|
||||
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"
|
||||
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"
|
||||
}
|
||||
|
||||
// Construct the full URL for the current request
|
||||
@@ -161,23 +155,6 @@ 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,
|
||||
@@ -189,17 +166,13 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"favicon": favicon,
|
||||
"loginIcon": loginIcon,
|
||||
"color": defaultThemeColor,
|
||||
"winIcon": staticURL + "/mstile-256x256.png",
|
||||
"appIcon": staticURL + "/apple-touch-icon.png",
|
||||
"winIcon": staticURL + "/img/icons/mstile-144x144.png",
|
||||
"appIcon": staticURL + "/img/icons/android-chrome-512x512.png",
|
||||
"description": description,
|
||||
"loadingSpinnersCSS": loadingSpinnersCSS,
|
||||
"banner": banner,
|
||||
"image": ogImage,
|
||||
"url": fullURL,
|
||||
"pwaIcon192": pwaIcon192,
|
||||
"pwaIcon256": pwaIcon256,
|
||||
"pwaIcon512": pwaIcon512,
|
||||
"manifestURL": staticURL + "/site.webmanifest",
|
||||
}
|
||||
// variables consumed by frontend as json
|
||||
data["globalVars"] = map[string]interface{}{
|
||||
@@ -229,7 +202,6 @@ 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
|
||||
@@ -277,16 +249,6 @@ func setContentType(w http.ResponseWriter, path string) {
|
||||
}
|
||||
}
|
||||
|
||||
// manifestHandler serves the cached PWA manifest
|
||||
func manifestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/manifest+json")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour
|
||||
|
||||
if err := json.NewEncoder(w).Encode(cachedManifest); err != nil {
|
||||
http.Error(w, "Failed to serve manifest", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// staticAssetHandler serves static assets exactly as the frontend build produces them
|
||||
func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
const maxAge = 86400 // 1 day
|
||||
@@ -302,47 +264,18 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Handle special routes that need path mapping
|
||||
var assetPath string
|
||||
switch path {
|
||||
case "site.webmanifest":
|
||||
manifestHandler(w, r)
|
||||
return
|
||||
case "favicon.svg":
|
||||
// For custom SVG favicons, serve the SVG directly
|
||||
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
|
||||
case "favicon.svg", "favicon":
|
||||
// Handle custom favicon from filesystem
|
||||
if settings.Env.FaviconIsCustom {
|
||||
http.ServeFile(w, r, settings.Env.FaviconPath)
|
||||
return
|
||||
}
|
||||
// Use embedded default SVG
|
||||
// Use embedded default
|
||||
assetPath = settings.Env.FaviconEmbeddedPath
|
||||
case "favicon.png":
|
||||
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) != ".svg" {
|
||||
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-512.png")
|
||||
if _, err := os.Stat(iconPath); err == nil {
|
||||
http.ServeFile(w, r, iconPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Fall back to embedded default favicon.png
|
||||
assetPath = "img/icons/favicon.png"
|
||||
case "favicon-32x32.png",
|
||||
"pwa-icon-192.png", "pwa-icon-256.png", "pwa-icon-512.png",
|
||||
"apple-touch-icon.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 "mstile-256x256.png":
|
||||
// Windows tile - redirect to pwa-icon-256.png (they're identical)
|
||||
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-256.png")
|
||||
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 "manifest.json":
|
||||
assetPath = "img/icons/manifest.json"
|
||||
case "site.webmanifest":
|
||||
assetPath = "img/icons/site.webmanifest"
|
||||
case "loginIcon":
|
||||
// Handle custom login icon from filesystem
|
||||
if settings.Env.LoginIconIsCustom {
|
||||
@@ -395,87 +328,3 @@ func indexHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
|
||||
}
|
||||
return handleWithStaticData(w, r, d, "index.html", "text/html")
|
||||
}
|
||||
|
||||
// PWAManifest represents the web app manifest structure
|
||||
type PWAManifest struct {
|
||||
Name string `json:"name"`
|
||||
ShortName string `json:"short_name"`
|
||||
Icons []PWAIcon `json:"icons"`
|
||||
StartURL string `json:"start_url"`
|
||||
Display string `json:"display"`
|
||||
BackgroundColor string `json:"background_color"`
|
||||
ThemeColor string `json:"theme_color"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// PWAIcon represents an icon in the web app manifest
|
||||
type PWAIcon struct {
|
||||
Src string `json:"src"`
|
||||
Sizes string `json:"sizes"`
|
||||
Type string `json:"type"`
|
||||
Purpose string `json:"purpose"`
|
||||
}
|
||||
|
||||
// generatePWAManifest creates the PWA manifest structure
|
||||
func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwaIcon256, pwaIcon512 string) PWAManifest {
|
||||
shortName := name
|
||||
if len(name) > 12 {
|
||||
shortName = name[:12]
|
||||
}
|
||||
|
||||
return PWAManifest{
|
||||
Name: name,
|
||||
ShortName: shortName,
|
||||
StartURL: baseURL,
|
||||
Display: "standalone",
|
||||
BackgroundColor: "#ffffff",
|
||||
ThemeColor: themeColor,
|
||||
Description: description,
|
||||
Icons: []PWAIcon{
|
||||
{
|
||||
Src: pwaIcon192,
|
||||
Sizes: "192x192",
|
||||
Type: "image/png",
|
||||
Purpose: "any",
|
||||
},
|
||||
{
|
||||
Src: pwaIcon256,
|
||||
Sizes: "256x256",
|
||||
Type: "image/png",
|
||||
Purpose: "any maskable",
|
||||
},
|
||||
{
|
||||
Src: pwaIcon512,
|
||||
Sizes: "512x512",
|
||||
Type: "image/png",
|
||||
Purpose: "any",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// InitializePWAManifest generates and caches the PWA manifest at startup
|
||||
func InitializePWAManifest() {
|
||||
staticURL := config.Server.BaseURL + "public/static"
|
||||
title := config.Frontend.Name
|
||||
description := config.Frontend.Description
|
||||
defaultThemeColor := "#455a64"
|
||||
|
||||
// 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 settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
|
||||
favicon := staticURL + "/favicon"
|
||||
pwaIcon192 = favicon
|
||||
pwaIcon256 = favicon
|
||||
pwaIcon512 = favicon
|
||||
} else if settings.Env.FaviconIsCustom {
|
||||
pwaIcon192 = staticURL + "/pwa-icon-192.png"
|
||||
pwaIcon256 = staticURL + "/pwa-icon-256.png"
|
||||
pwaIcon512 = staticURL + "/pwa-icon-512.png"
|
||||
}
|
||||
|
||||
cachedManifest = generatePWAManifest(title, description, config.Server.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
|
||||
}
|
||||
@@ -21,18 +21,8 @@ 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) {
|
||||
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)
|
||||
logger.Debug("Generating OTP for user:", d.user.Username)
|
||||
url, err := auth.GenerateOtpForUser(d.user, store.Users)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error generating OTP secret: %w", err)
|
||||
}
|
||||
@@ -61,21 +51,7 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if code == "" {
|
||||
return http.StatusUnauthorized, fmt.Errorf("code is required")
|
||||
}
|
||||
|
||||
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)
|
||||
err := auth.VerifyTotpCode(d.user, code, store.Users)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, fmt.Errorf("invalid OTP token")
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
|
||||
@@ -92,83 +91,6 @@ 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.
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
package icons
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/preview"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/kovidgoyal/imaging"
|
||||
)
|
||||
|
||||
// sourceType represents the type of favicon source
|
||||
type sourceType int
|
||||
|
||||
const (
|
||||
sourceCustomSVG sourceType = iota
|
||||
sourceCustomRaster
|
||||
sourceDefault
|
||||
)
|
||||
|
||||
// iconSize defines an icon size to generate
|
||||
type iconSize struct {
|
||||
size int
|
||||
name string
|
||||
env *string
|
||||
}
|
||||
|
||||
// determineSourceType determines which type of favicon source we have
|
||||
func determineSourceType(faviconPath string) sourceType {
|
||||
if !settings.Env.FaviconIsCustom {
|
||||
return sourceDefault
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(faviconPath))
|
||||
switch ext {
|
||||
case ".svg":
|
||||
return sourceCustomSVG
|
||||
default:
|
||||
return sourceCustomRaster
|
||||
}
|
||||
}
|
||||
|
||||
// loadDefaultFavicon loads the embedded default favicon PNG
|
||||
func loadDefaultFavicon() ([]byte, error) {
|
||||
assetFs := fileutils.GetAssetFS()
|
||||
if assetFs == nil {
|
||||
return nil, errors.New("asset filesystem not initialized")
|
||||
}
|
||||
|
||||
pngPath := "img/icons/favicon.png"
|
||||
sourceData, err := fs.ReadFile(assetFs, pngPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read default favicon PNG: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug("Loaded default embedded favicon")
|
||||
return sourceData, nil
|
||||
}
|
||||
|
||||
// findRasterCompanion looks for a raster companion file for an SVG favicon
|
||||
// Returns the companion data and path, or error if not found
|
||||
func findRasterCompanion(svgPath string) ([]byte, string, error) {
|
||||
basePath := svgPath[:len(svgPath)-len(filepath.Ext(svgPath))]
|
||||
rasterExts := []string{".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||
|
||||
for _, ext := range rasterExts {
|
||||
companionPath := basePath + ext
|
||||
if _, err := os.Stat(companionPath); err == nil {
|
||||
data, readErr := os.ReadFile(companionPath)
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
return data, companionPath, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, "", fmt.Errorf("no raster companion found at %s.{png,jpg,webp,gif}", basePath)
|
||||
}
|
||||
|
||||
// convertRasterToPNG decodes any raster format and re-encodes as PNG
|
||||
func convertRasterToPNG(rasterData []byte, sourceExt string) ([]byte, error) {
|
||||
img, err := imaging.Decode(bytes.NewReader(rasterData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := imaging.Encode(&buf, img, imaging.PNG); err != nil {
|
||||
return nil, fmt.Errorf("failed to convert to PNG: %w", err)
|
||||
}
|
||||
|
||||
logger.Debugf("Converted %s to PNG for icon generation", sourceExt)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// loadFaviconSource loads and prepares the favicon source data as PNG
|
||||
// Returns PNG data ready for resizing into icon sizes
|
||||
func loadFaviconSource() ([]byte, error) {
|
||||
sourceType := determineSourceType(settings.Env.FaviconPath)
|
||||
|
||||
switch sourceType {
|
||||
case sourceCustomSVG:
|
||||
logger.Debugf("Processing custom SVG favicon: %s", settings.Env.FaviconPath)
|
||||
return handleSVGFavicon(settings.Env.FaviconPath)
|
||||
|
||||
case sourceCustomRaster:
|
||||
logger.Debugf("Processing custom favicon: %s", settings.Env.FaviconPath)
|
||||
return handleRasterFavicon(settings.Env.FaviconPath)
|
||||
|
||||
case sourceDefault:
|
||||
logger.Debug("Using default embedded favicon")
|
||||
return loadDefaultFavicon()
|
||||
|
||||
default:
|
||||
return nil, errors.New("unknown icon source type")
|
||||
}
|
||||
}
|
||||
|
||||
// handleSVGFavicon processes an SVG favicon by finding its raster companion
|
||||
func handleSVGFavicon(svgPath string) ([]byte, error) {
|
||||
logger.Debug("Looking for companion for SVG favicon")
|
||||
|
||||
companionData, companionPath, err := findRasterCompanion(svgPath)
|
||||
if err != nil {
|
||||
logger.Warningf("SVG favicon has no companion: %v", err)
|
||||
logger.Warning("Falling back to default embedded favicon")
|
||||
logger.Warning("For best Apple/iOS compatibility, provide a PNG/JPG companion alongside your SVG")
|
||||
return loadDefaultFavicon()
|
||||
}
|
||||
|
||||
logger.Debugf("Found companion: %s", companionPath)
|
||||
return convertRasterToPNG(companionData, filepath.Ext(companionPath))
|
||||
}
|
||||
|
||||
// handleRasterFavicon processes a raster favicon (PNG, JPEG, GIF, WebP, etc.)
|
||||
func handleRasterFavicon(faviconPath string) ([]byte, error) {
|
||||
sourceData, err := os.ReadFile(faviconPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read favicon: %w", err)
|
||||
}
|
||||
|
||||
return convertRasterToPNG(sourceData, filepath.Ext(faviconPath))
|
||||
}
|
||||
|
||||
// getIconSizesToGenerate returns all icon sizes that need to be generated
|
||||
func getIconSizesToGenerate() []iconSize {
|
||||
return []iconSize{
|
||||
// Browser favicon (32x32 PNG for Safari/older browsers)
|
||||
{32, "favicon-32x32.png", nil},
|
||||
// PWA icons (PNG required for Apple PWA compatibility)
|
||||
{192, "pwa-icon-192.png", &settings.Env.PWAIcon192},
|
||||
{256, "pwa-icon-256.png", &settings.Env.PWAIcon256}, // Also serves as Windows tile
|
||||
{512, "pwa-icon-512.png", &settings.Env.PWAIcon512},
|
||||
// Platform-specific icons
|
||||
{180, "apple-touch-icon.png", nil}, // iOS home screen (required for Apple)
|
||||
}
|
||||
}
|
||||
|
||||
// generateIconSizes generates all required icon sizes from source PNG data
|
||||
func generateIconSizes(sourceData []byte) error {
|
||||
previewService := preview.GetService()
|
||||
if previewService == nil {
|
||||
return errors.New("preview service not initialized")
|
||||
}
|
||||
|
||||
iconSizes := getIconSizesToGenerate()
|
||||
allSuccess := true
|
||||
generatedCount := 0
|
||||
|
||||
for _, icon := range iconSizes {
|
||||
if err := generateSingleIcon(previewService, sourceData, icon); err != nil {
|
||||
logger.Warningf("Failed to generate %s: %v", icon.name, err)
|
||||
allSuccess = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Update environment variable if specified
|
||||
if icon.env != nil {
|
||||
*icon.env = filepath.Join("icons", icon.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))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateSingleIcon generates a single icon file at the specified size
|
||||
func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon iconSize) error {
|
||||
outputPath := filepath.Join(settings.Env.PWAIconsDir, icon.name)
|
||||
|
||||
// Create output file
|
||||
outFile, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
// Resize image using the preview service's Resize method
|
||||
err = previewService.Resize(
|
||||
bytes.NewReader(sourceData),
|
||||
outFile,
|
||||
preview.ResizeOptions{
|
||||
Width: icon.size,
|
||||
Height: icon.size,
|
||||
ResizeMode: preview.ResizeModeFill,
|
||||
Quality: preview.QualityHigh,
|
||||
Format: preview.FormatPng,
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
return fmt.Errorf("resize failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GeneratePWAIcons generates all PWA and platform icon sizes from favicon source
|
||||
// All icons are generated as PNG for maximum compatibility (Apple devices, PWA, older browsers)
|
||||
func GeneratePWAIcons() error {
|
||||
logger.Info("Generating PWA icons...")
|
||||
|
||||
// Load and prepare source data as PNG
|
||||
sourceData, err := loadFaviconSource()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load favicon source: %w", err)
|
||||
}
|
||||
|
||||
// Generate all icon sizes
|
||||
if err := generateIconSizes(sourceData); err != nil {
|
||||
return fmt.Errorf("failed to generate icon sizes: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -331,14 +331,18 @@ func InitializeIndexDB() (bool, error) {
|
||||
batchSize := settings.Config.Server.IndexSqlConfig.BatchSize
|
||||
cacheSizeMB := settings.Config.Server.IndexSqlConfig.CacheSizeMB
|
||||
disableReuse := settings.Config.Server.IndexSqlConfig.DisableReuse
|
||||
var isNewDb bool
|
||||
indexDB, isNewDb, err = dbsql.NewIndexDB("all", journalMode, batchSize, cacheSizeMB, disableReuse)
|
||||
var wasRecreated bool
|
||||
indexDB, wasRecreated, err = dbsql.NewIndexDB("all", journalMode, batchSize, cacheSizeMB, disableReuse)
|
||||
if err != nil {
|
||||
logger.Fatalf("failed to initialize index database: %v", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
return isNewDb, nil
|
||||
if wasRecreated {
|
||||
logger.Warningf("Index database was recreated, all index complexities will be reset to 0")
|
||||
}
|
||||
|
||||
return wasRecreated, nil
|
||||
}
|
||||
|
||||
// GetIndexDB returns the shared index database.
|
||||
@@ -370,7 +374,7 @@ func SetIndexingStorage(storage *dbindex.Storage) {
|
||||
indexingStorage = storage
|
||||
}
|
||||
|
||||
func Initialize(source *settings.Source, mock bool, isNewDb bool) {
|
||||
func Initialize(source *settings.Source, mock bool) {
|
||||
indexesMutex.Lock()
|
||||
// Use shared database - all sources are differentiated by the source column
|
||||
if indexDB == nil {
|
||||
@@ -399,8 +403,7 @@ func Initialize(source *settings.Source, mock bool, isNewDb bool) {
|
||||
if !newIndex.Config.DisableIndexing {
|
||||
logger.Infof("initializing index: [%v]", newIndex.Name)
|
||||
// Start multi-scanner system (each scanner will do its own initial scan)
|
||||
// Pass isNewDb flag so setupMultiScanner knows whether to load persisted complexity
|
||||
go newIndex.setupMultiScanner(isNewDb)
|
||||
go newIndex.setupIndexingScanners()
|
||||
} else {
|
||||
newIndex.Status = "ready"
|
||||
logger.Debug("indexing disabled for source: " + newIndex.Name)
|
||||
@@ -1292,19 +1295,11 @@ 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":
|
||||
@@ -1654,17 +1649,7 @@ func (idx *Index) Save() error {
|
||||
Scanners: scanners,
|
||||
}
|
||||
|
||||
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
|
||||
return indexingStorage.Save(info)
|
||||
}
|
||||
|
||||
// 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.createScanner(dirPath)
|
||||
newScanner := s.idx.createChildScanner(dirPath)
|
||||
|
||||
s.idx.mu.Lock()
|
||||
s.idx.scanners[dirPath] = newScanner
|
||||
@@ -507,7 +507,6 @@ 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,11 +179,16 @@ func (idx *Index) PostScan() error {
|
||||
logger.Errorf("Failed to shrink memory: %v", err)
|
||||
}
|
||||
|
||||
// Persist index and scanner information (SSE event will be sent by Save())
|
||||
// Persist index and scanner information
|
||||
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
|
||||
@@ -197,6 +202,7 @@ 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)
|
||||
@@ -218,8 +224,7 @@ func (idx *Index) SendSourceUpdateEvent() error {
|
||||
}
|
||||
|
||||
// setupMultiScanner creates and starts the multi-scanner system
|
||||
// isNewDb: if true, skip loading persisted complexity values (database is new or recreated)
|
||||
func (idx *Index) setupMultiScanner(isNewDb bool) {
|
||||
func (idx *Index) setupMultiScanner() {
|
||||
// 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)
|
||||
@@ -241,7 +246,7 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
|
||||
|
||||
// Load persisted scanner info if available
|
||||
var persistedScanners map[string]*indexingdb.PersistedScannerInfo
|
||||
if indexingStorage != nil && !isNewDb {
|
||||
if indexingStorage != nil {
|
||||
info, err := indexingStorage.GetByPath(idx.Path)
|
||||
if err == nil && info != nil {
|
||||
persistedScanners = info.Scanners
|
||||
@@ -249,7 +254,8 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
|
||||
}
|
||||
|
||||
// Create and start root scanner
|
||||
rootScanner := idx.createScanner("/")
|
||||
rootScanner := idx.createRootScanner()
|
||||
// Restore persisted stats for root scanner if available
|
||||
if persistedScanners != nil {
|
||||
if rootInfo, ok := persistedScanners["/"]; ok {
|
||||
rootScanner.complexity = rootInfo.Complexity
|
||||
@@ -271,9 +277,9 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
|
||||
|
||||
// Create child scanner for each top-level directory
|
||||
for _, dirPath := range topLevelDirs {
|
||||
childScanner := idx.createScanner(dirPath)
|
||||
childScanner := idx.createChildScanner(dirPath)
|
||||
|
||||
// Restore persisted stats for child scanner if available (and DB is not new)
|
||||
// Restore persisted stats for child scanner if available
|
||||
if persistedScanners != nil {
|
||||
if childInfo, ok := persistedScanners[dirPath]; ok {
|
||||
childScanner.complexity = childInfo.Complexity
|
||||
@@ -296,8 +302,20 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
|
||||
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) createScanner(dirPath string) *Scanner {
|
||||
func (idx *Index) createChildScanner(dirPath string) *Scanner {
|
||||
return &Scanner{
|
||||
scanPath: dirPath,
|
||||
idx: idx,
|
||||
@@ -308,6 +326,12 @@ func (idx *Index) createScanner(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, false) // mock=true, isNewDb=false
|
||||
}, true)
|
||||
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.createScanner("/")
|
||||
scanner2 := idx.createScanner("/test1/")
|
||||
scanner3 := idx.createScanner("/test2/")
|
||||
scanner1 := idx.createRootScanner()
|
||||
scanner2 := idx.createChildScanner("/test1/")
|
||||
scanner3 := idx.createChildScanner("/test2/")
|
||||
|
||||
idx.scanners["/"] = scanner1
|
||||
idx.scanners["/test1/"] = scanner2
|
||||
|
||||
@@ -9,10 +9,6 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
)
|
||||
|
||||
const (
|
||||
LargeFileSizeThreshold = 75 * 1024 * 1024 // 75MB
|
||||
)
|
||||
|
||||
var AllFiletypeOptions = []string{
|
||||
"image",
|
||||
"audio",
|
||||
|
||||
@@ -3,7 +3,7 @@ package iteminfo
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
)
|
||||
|
||||
type ItemInfo struct {
|
||||
@@ -40,21 +40,21 @@ type MediaMetadata struct {
|
||||
Genre string `json:"genre,omitempty"` // music/video genre
|
||||
Track int `json:"track,omitempty"` // track number
|
||||
Duration int `json:"duration,omitempty"` // duration in seconds
|
||||
AlbumArt []byte `json:"albumArt,omitempty"` // album art image data (automatically base64-encoded in JSON)
|
||||
AlbumArt string `json:"albumArt,omitempty"` // base64 encoded album art / video thumbnail
|
||||
}
|
||||
|
||||
// for efficiency, a response will be a pointer to the data
|
||||
// extra calculated fields can be added here
|
||||
type ExtendedFileInfo struct {
|
||||
FileInfo
|
||||
Content string `json:"content,omitempty"` // text content of a file, if requested
|
||||
Subtitles []utils.SubtitleTrack `json:"subtitles,omitempty"` // subtitles for video files
|
||||
Metadata *MediaMetadata `json:"metadata,omitempty"` // media metadata for audio/video files (includes duration)
|
||||
Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file
|
||||
Token string `json:"token,omitempty"` // token for the file -- used for sharing
|
||||
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
|
||||
Source string `json:"source,omitempty"` // associated index source for the file
|
||||
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
|
||||
HasMetadata bool `json:"hasMetadata"` // whether the file or folder has metadata
|
||||
RealPath string `json:"-"`
|
||||
Content string `json:"content,omitempty"` // text content of a file, if requested
|
||||
Subtitles []ffmpeg.SubtitleTrack `json:"subtitles,omitempty"` // subtitles for video files
|
||||
Metadata *MediaMetadata `json:"metadata,omitempty"` // media metadata for audio/video files (includes duration)
|
||||
Checksums map[string]string `json:"checksums,omitempty"` // checksums for the file
|
||||
Token string `json:"token,omitempty"` // token for the file -- used for sharing
|
||||
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
|
||||
Source string `json:"source,omitempty"` // associated index source for the file
|
||||
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
|
||||
HasMetadata bool `json:"hasMetadata"` // whether the file or folder has metadata
|
||||
RealPath string `json:"-"`
|
||||
}
|
||||
@@ -8,9 +8,19 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
type SubtitleTrack struct {
|
||||
Name string `json:"name"` // filename for external, or descriptive name for embedded
|
||||
Language string `json:"language,omitempty"` // language code
|
||||
Title string `json:"title,omitempty"` // title/description
|
||||
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
|
||||
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
|
||||
IsFile bool `json:"isFile"` // true for external files, false for embedded
|
||||
}
|
||||
|
||||
type FFProbeOutput struct {
|
||||
Streams []struct {
|
||||
Index int `json:"index"`
|
||||
@@ -21,55 +31,20 @@ type FFProbeOutput struct {
|
||||
} `json:"streams"`
|
||||
}
|
||||
|
||||
// GetSubtitles detects external subtitle files for video files.
|
||||
// Embedded subtitles should be detected by ffmpeg and passed as embeddedSubs parameter.
|
||||
func (i *ExtendedFileInfo) GetSubtitles(parentInfo *FileInfo) {
|
||||
// detects subtitles for video files.
|
||||
func (i *ExtendedFileInfo) DetectSubtitles(parentInfo *FileInfo) {
|
||||
if !strings.HasPrefix(i.Type, "video") {
|
||||
logger.Debug("subtitles are not supported for this file : " + i.Name)
|
||||
return
|
||||
}
|
||||
ext := filepath.Ext(i.Name)
|
||||
baseWithoutExt := strings.TrimSuffix(i.Name, ext)
|
||||
// Use unified subtitle detection that finds both embedded and external
|
||||
parentDir := filepath.Dir(i.RealPath)
|
||||
i.Subtitles = ffmpeg.DetectAllSubtitles(i.RealPath, parentDir, i.ModTime)
|
||||
}
|
||||
|
||||
// Collect external subtitle files
|
||||
var externalSubs []utils.SubtitleTrack
|
||||
if parentInfo != nil && parentInfo.Files != nil {
|
||||
for _, f := range parentInfo.Files {
|
||||
fileExt := filepath.Ext(f.Name)
|
||||
fileBase := strings.TrimSuffix(f.Name, fileExt)
|
||||
|
||||
// Check if this file has the same base name and a subtitle extension
|
||||
if fileBase == baseWithoutExt && SubtitleExts[strings.ToLower(fileExt)] {
|
||||
track := utils.SubtitleTrack{
|
||||
Name: f.Name,
|
||||
Embedded: false,
|
||||
}
|
||||
|
||||
// Try to infer language from filename patterns like "video.en.srt"
|
||||
parts := strings.Split(fileBase, ".")
|
||||
if len(parts) > 1 {
|
||||
lastPart := parts[len(parts)-1]
|
||||
if len(lastPart) == 2 || len(lastPart) == 3 {
|
||||
track.Language = lastPart
|
||||
}
|
||||
}
|
||||
|
||||
externalSubs = append(externalSubs, track)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort external subtitles alphabetically for consistent ordering
|
||||
sort.Slice(externalSubs, func(i, j int) bool {
|
||||
return externalSubs[i].Name < externalSubs[j].Name
|
||||
})
|
||||
|
||||
i.Subtitles = append(i.Subtitles, externalSubs...)
|
||||
|
||||
// Sort all subtitles for consistent ordering
|
||||
sort.Slice(i.Subtitles, func(a, b int) bool {
|
||||
return strings.ToLower(i.Subtitles[a].Name) < strings.ToLower(i.Subtitles[b].Name)
|
||||
})
|
||||
// Note: Content is NOT loaded here. Frontend will call /api/media/subtitles for each track
|
||||
// LoadSubtitleContent loads the actual content for all detected subtitle tracks
|
||||
func (i *ExtendedFileInfo) LoadSubtitleContent() error {
|
||||
return ffmpeg.LoadAllSubtitleContent(i.RealPath, i.Subtitles, i.ModTime)
|
||||
}
|
||||
|
||||
func (info *FileInfo) SortItems() {
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestMain(m *testing.M) {
|
||||
}
|
||||
|
||||
func BenchmarkSearchAllIndexes(b *testing.B) {
|
||||
Initialize(&settings.Source{Name: "test", Path: "/srv"}, true, false) // mock=true, isNewDb=false
|
||||
Initialize(&settings.Source{Name: "test", Path: "/srv"}, true)
|
||||
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, false) // mock=true, isNewDb=false
|
||||
Initialize(source, true)
|
||||
idx := GetIndex("test")
|
||||
if idx == nil {
|
||||
t.Fatal("Failed to get test index")
|
||||
|
||||
@@ -6,10 +6,9 @@ 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 to match first retry level
|
||||
// Create 128KB buffers by default
|
||||
return bytes.NewBuffer(make([]byte, 0, 128*1024))
|
||||
},
|
||||
}
|
||||
@@ -28,9 +27,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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,7 +28,13 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
// Serialize access to the entire go-fitz operation block (required for CGO thread safety)
|
||||
// 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
|
||||
s.docGenMutex.Lock()
|
||||
defer s.docGenMutex.Unlock()
|
||||
|
||||
|
||||
@@ -15,7 +15,14 @@ func docEnabled() bool {
|
||||
}
|
||||
|
||||
func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int) ([]byte, error) {
|
||||
// Reference it to prevent unused field warning when building without mupdf
|
||||
_ = &s.docGenMutex
|
||||
// Acquire document semaphore
|
||||
if err := s.acquireDoc(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.releaseDoc()
|
||||
|
||||
s.docGenMutex.Lock()
|
||||
defer s.docGenMutex.Unlock()
|
||||
|
||||
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.ffmpegService == nil {
|
||||
if s.imageService == 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 FFmpeg service semaphore
|
||||
if err := s.ffmpegService.Acquire(ctx); err != nil {
|
||||
// Acquire image service semaphore
|
||||
if err := s.imageService.Acquire(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.ffmpegService.Release()
|
||||
defer s.imageService.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.ffmpegService.ConvertHEICToJPEG(ctx, filePath, width, height, quality)
|
||||
return s.imageService.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.ffmpegService == nil {
|
||||
if s.imageService == 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 FFmpeg service semaphore
|
||||
if err := s.ffmpegService.Acquire(ctx); err != nil {
|
||||
// Acquire image service semaphore
|
||||
if err := s.imageService.Acquire(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.ffmpegService.Release()
|
||||
defer s.imageService.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.ffmpegService.ConvertImageToJPEG(ctx, filePath, width, height, quality)
|
||||
return s.imageService.ConvertImageToJPEG(ctx, filePath, width, height, quality)
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
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"
|
||||
@@ -140,98 +143,334 @@ func (s *Service) FormatFromExtension(ext string) (Format, error) {
|
||||
}
|
||||
}
|
||||
|
||||
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 resizeConfig struct {
|
||||
format Format
|
||||
resizeMode ResizeMode
|
||||
quality Quality
|
||||
jpegQuality int // JPEG encoding quality (1-100)
|
||||
}
|
||||
|
||||
func (s *Service) Resize(in io.Reader, out io.Writer, opts ResizeOptions) error {
|
||||
return s.ResizeWithSize(in, out, 0, opts)
|
||||
type Option func(*resizeConfig)
|
||||
|
||||
func WithFormat(format Format) Option {
|
||||
return func(config *resizeConfig) {
|
||||
config.format = format
|
||||
}
|
||||
}
|
||||
|
||||
// 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 WithMode(mode ResizeMode) Option {
|
||||
return func(config *resizeConfig) {
|
||||
config.resizeMode = mode
|
||||
}
|
||||
if opts.Quality == 0 {
|
||||
opts.Quality = QualityMedium
|
||||
}
|
||||
|
||||
func WithQuality(quality Quality) Option {
|
||||
return func(config *resizeConfig) {
|
||||
config.quality = quality
|
||||
config.jpegQuality = quality.jpegQuality()
|
||||
}
|
||||
if opts.JpegQuality == 0 {
|
||||
opts.JpegQuality = opts.Quality.jpegQuality()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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()
|
||||
}
|
||||
|
||||
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 opts.JpegQuality == 0 {
|
||||
opts.JpegQuality = opts.Quality.jpegQuality()
|
||||
if config.jpegQuality == 0 {
|
||||
config.jpegQuality = config.quality.jpegQuality()
|
||||
}
|
||||
|
||||
// Try to use embedded EXIF thumbnail for JPEGs (only for low quality to keep it simple)
|
||||
if opts.Format == FormatJpeg && opts.Quality == QualityLow {
|
||||
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 {
|
||||
thm, newWrappedReader, errThm := getEmbeddedThumbnail(wrappedReader)
|
||||
wrappedReader = newWrappedReader
|
||||
if errThm == nil && len(thm) > 0 {
|
||||
_, err = out.Write(thm)
|
||||
if err == nil {
|
||||
return nil
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decode the image - try imaging library first, fall back to format-specific decoder if it fails
|
||||
img, err := imaging.Decode(wrappedReader, imaging.AutoOrientation(true))
|
||||
// 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)
|
||||
if err != nil {
|
||||
// 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 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))
|
||||
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 {
|
||||
// For other formats, try auto-detection
|
||||
img, _, err = image.Decode(wrappedReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode image: %w", err)
|
||||
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 {
|
||||
case ResizeModeFill:
|
||||
img = imaging.Fill(img, width, height, imaging.Center, resampleFilter)
|
||||
case ResizeModeFit:
|
||||
img = imaging.Fit(img, width, height, resampleFilter)
|
||||
default:
|
||||
img = imaging.Fit(img, width, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resize
|
||||
resampleFilter := opts.Quality.resampleFilter()
|
||||
switch opts.ResizeMode {
|
||||
case ResizeModeFill:
|
||||
img = imaging.Fill(img, opts.Width, opts.Height, imaging.Center, resampleFilter)
|
||||
case ResizeModeFit:
|
||||
fallthrough
|
||||
default:
|
||||
img = imaging.Fit(img, opts.Width, opts.Height, resampleFilter)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Encode
|
||||
if opts.Format == FormatJpeg {
|
||||
return jpeg.Encode(out, img, &jpeg.Options{Quality: opts.JpegQuality})
|
||||
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
|
||||
}
|
||||
return imaging.Encode(out, img, opts.Format.toImaging())
|
||||
}
|
||||
|
||||
func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
|
||||
@@ -261,18 +500,30 @@ func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
|
||||
_, _ = seeker.Seek(originalPos, io.SeekStart)
|
||||
wrappedReader = in
|
||||
} else {
|
||||
// Read error - cannot extract thumbnail, return error to fall back to full decode
|
||||
// Read error - fallback to reading all
|
||||
_, _ = seeker.Seek(originalPos, io.SeekStart)
|
||||
return nil, in, fmt.Errorf("failed to read header for thumbnail extraction: %w", readErr)
|
||||
allData, _ := io.ReadAll(in)
|
||||
headerBuf = allData
|
||||
wrappedReader = bytes.NewReader(allData)
|
||||
}
|
||||
} else {
|
||||
// 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")
|
||||
// Seek failed, read all
|
||||
allData, _ := io.ReadAll(in)
|
||||
headerBuf = allData
|
||||
wrappedReader = bytes.NewReader(allData)
|
||||
}
|
||||
} else {
|
||||
// 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")
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Try to find EXIF header
|
||||
|
||||
@@ -33,13 +33,17 @@ func TestMain(m *testing.M) {
|
||||
|
||||
func TestService_Resize(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
options ResizeOptions
|
||||
options []Option
|
||||
width int
|
||||
height int
|
||||
source func(t *testing.T) afero.File
|
||||
matcher func(t *testing.T, reader io.Reader)
|
||||
wantErr bool
|
||||
}{
|
||||
"fill upscale": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
|
||||
options: []Option{WithMode(ResizeModeFill)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 50, 20)
|
||||
@@ -47,7 +51,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"fill downscale": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
|
||||
options: []Option{WithMode(ResizeModeFill)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -55,7 +61,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"fit upscale": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFit},
|
||||
options: []Option{WithMode(ResizeModeFit)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 50, 20)
|
||||
@@ -63,7 +71,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(50, 20),
|
||||
},
|
||||
"fit downscale": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFit},
|
||||
options: []Option{WithMode(ResizeModeFit)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -71,7 +81,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 75),
|
||||
},
|
||||
"keep original format": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, Format: FormatPng},
|
||||
options: []Option{},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayPng(t, 200, 150)
|
||||
@@ -79,7 +91,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: formatMatcher(FormatPng),
|
||||
},
|
||||
"convert to jpeg": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, Format: FormatJpeg},
|
||||
options: []Option{WithFormat(FormatJpeg)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -87,7 +101,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: formatMatcher(FormatJpeg),
|
||||
},
|
||||
"convert to png": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, Format: FormatPng},
|
||||
options: []Option{WithFormat(FormatPng)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -95,7 +111,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: formatMatcher(FormatPng),
|
||||
},
|
||||
"convert to gif": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, Format: FormatGif},
|
||||
options: []Option{WithFormat(FormatGif)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -103,7 +121,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: formatMatcher(FormatGif),
|
||||
},
|
||||
"convert to tiff": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, Format: FormatTiff},
|
||||
options: []Option{WithFormat(FormatTiff)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -111,7 +131,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: formatMatcher(FormatTiff),
|
||||
},
|
||||
"convert to bmp": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, Format: FormatBmp},
|
||||
options: []Option{WithFormat(FormatBmp)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -119,7 +141,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: formatMatcher(FormatBmp),
|
||||
},
|
||||
"convert to unknown": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, Format: Format(-1)},
|
||||
options: []Option{WithFormat(Format(-1))},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -127,7 +151,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: formatMatcher(FormatJpeg),
|
||||
},
|
||||
"resize png": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
|
||||
options: []Option{WithMode(ResizeModeFill)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayPng(t, 200, 150)
|
||||
@@ -135,7 +161,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"resize gif": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
|
||||
options: []Option{WithMode(ResizeModeFill)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayGif(t, 200, 150)
|
||||
@@ -143,7 +171,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"resize tiff": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
|
||||
options: []Option{WithMode(ResizeModeFill)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayTiff(t, 200, 150)
|
||||
@@ -151,7 +181,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"resize bmp": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
|
||||
options: []Option{WithMode(ResizeModeFill)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayBmp(t, 200, 150)
|
||||
@@ -159,7 +191,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"resize with high quality": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityHigh},
|
||||
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityHigh)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -167,7 +201,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"resize with medium quality": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityMedium},
|
||||
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityMedium)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -175,7 +211,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"resize with low quality": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
|
||||
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -183,7 +221,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"resize with unknown quality": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: Quality(-1)},
|
||||
options: []Option{WithMode(ResizeModeFill), WithQuality(Quality(-1))},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -191,7 +231,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"get thumbnail from file with APP0 JFIF": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
|
||||
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return openFile(t, "testdata/gray-sample.jpg")
|
||||
@@ -199,7 +241,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(125, 128),
|
||||
},
|
||||
"get thumbnail from file without APP0 JFIF": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
|
||||
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return openFile(t, "testdata/20130612_142406.jpg")
|
||||
@@ -207,7 +251,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(320, 240),
|
||||
},
|
||||
"resize from file without IFD1 thumbnail": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
|
||||
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return openFile(t, "testdata/IMG_2578.JPG")
|
||||
@@ -215,7 +261,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"resize for higher quality levels": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityMedium},
|
||||
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityMedium)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return openFile(t, "testdata/gray-sample.jpg")
|
||||
@@ -223,7 +271,9 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"broken file": {
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFit},
|
||||
options: []Option{WithMode(ResizeModeFit)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
fs := afero.NewMemMapFs()
|
||||
@@ -247,7 +297,7 @@ func TestService_Resize(t *testing.T) {
|
||||
defer source.Close()
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
err := svc.Resize(source, buf, test.options)
|
||||
err := svc.Resize(source, test.width, test.height, buf, test.options...)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("GetMarketSpecs() error = %v, wantErr %v", err, test.wantErr)
|
||||
}
|
||||
@@ -448,58 +498,3 @@ 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)
|
||||
|
||||
// Get options for small preview
|
||||
options, err := getPreviewOptions("small")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create preview from reader
|
||||
out, err := svc.CreatePreview(bytes.NewReader(data), int64(len(data)), options)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, out)
|
||||
|
||||
// Same via another call for parity
|
||||
out2, err := svc.CreatePreview(bytes.NewReader(data), int64(len(data)), options)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, out, out2)
|
||||
}
|
||||
@@ -21,8 +21,11 @@ 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{}{
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -32,24 +32,19 @@ 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 (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
|
||||
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
|
||||
}
|
||||
|
||||
// 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 for FFmpeg operations
|
||||
// get round up half value of concurrencyLimit
|
||||
ffmpegConcurrencyLimit := (concurrencyLimit + 1) / 2
|
||||
|
||||
actualCacheDir := cacheDir
|
||||
@@ -79,81 +74,54 @@ func NewPreviewGenerator(concurrencyLimit int, cacheDir string) *Service {
|
||||
logger.Error(err)
|
||||
}
|
||||
|
||||
// Single FFmpeg service shared by video preview and HEIC/JPEG fallback
|
||||
ffmpegService := ffmpeg.NewFFmpegService(ffmpegConcurrencyLimit, settings.Config.Integrations.Media.Debug, filepath.Join(actualCacheDir, "heic"))
|
||||
videoService := ffmpeg.NewFFmpegService(ffmpegConcurrencyLimit, settings.Config.Integrations.Media.Debug, "")
|
||||
imageService := ffmpeg.NewFFmpegService(concurrencyLimit, settings.Config.Integrations.Media.Debug, filepath.Join(actualCacheDir, "heic"))
|
||||
|
||||
var imageSem, imageLargeSem chan struct{}
|
||||
// Create memory tracker for image processing
|
||||
// Limit to 500MB of concurrent image processing memory
|
||||
maxMemoryMB := 500
|
||||
memoryTracker := NewMemoryTracker(concurrencyLimit, maxMemoryMB)
|
||||
|
||||
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,
|
||||
ffmpegService: ffmpegService,
|
||||
imageSem: imageSem,
|
||||
imageLargeSem: imageLargeSem,
|
||||
docSemaphore: make(chan struct{}, 1), // must be 1 because cgo thread limit
|
||||
officeSem: make(chan struct{}, concurrencyLimit),
|
||||
videoService: videoService,
|
||||
imageService: imageService,
|
||||
memoryTracker: memoryTracker,
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Document semaphore methods
|
||||
func (s *Service) acquireDoc(ctx context.Context) error {
|
||||
select {
|
||||
case s.imageSem <- struct{}{}:
|
||||
case s.docSemaphore <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) releaseImageSem() {
|
||||
<-s.imageSem
|
||||
func (s *Service) releaseDoc() {
|
||||
<-s.docSemaphore
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
// Office semaphore methods
|
||||
func (s *Service) acquireOffice(ctx context.Context) error {
|
||||
select {
|
||||
case s.imageLargeSem <- struct{}{}:
|
||||
case s.officeSem <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
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 (s *Service) releaseOffice() {
|
||||
<-s.officeSem
|
||||
}
|
||||
|
||||
func StartPreviewGenerator(concurrencyLimit int, cacheDir string) error {
|
||||
@@ -164,11 +132,6 @@ func StartPreviewGenerator(concurrencyLimit int, cacheDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetService returns the preview service instance (can be nil if not started)
|
||||
func GetService() *Service {
|
||||
return service
|
||||
}
|
||||
|
||||
func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, url string, seekPercentage int) ([]byte, error) {
|
||||
if !file.HasPreview {
|
||||
return nil, ErrUnsupportedMedia
|
||||
@@ -181,10 +144,10 @@ func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, prev
|
||||
|
||||
// Generate fast cache key based on file metadata
|
||||
var cacheHash string
|
||||
if file.Metadata != nil && len(file.Metadata.AlbumArt) > 0 {
|
||||
if file.Metadata != nil && file.Metadata.AlbumArt != "" {
|
||||
// For audio with album art, hash the album art content
|
||||
hasher := md5.New()
|
||||
_, _ = hasher.Write(file.Metadata.AlbumArt)
|
||||
_, _ = hasher.Write([]byte(file.Metadata.AlbumArt))
|
||||
cacheHash = hex.EncodeToString(hasher.Sum(nil))
|
||||
} else {
|
||||
// For all other files, use fast metadata-based hash
|
||||
@@ -203,191 +166,6 @@ func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, prev
|
||||
return GeneratePreviewWithMD5(ctx, file, previewSize, url, seekPercentage, cacheHash)
|
||||
}
|
||||
|
||||
// filePreviewType represents the type of preview generation needed
|
||||
type filePreviewType int
|
||||
|
||||
const (
|
||||
previewTypeDocument filePreviewType = iota
|
||||
previewTypeOffice
|
||||
previewTypeHEIC
|
||||
previewTypeImage
|
||||
previewTypeVideo
|
||||
previewTypeAudio
|
||||
previewTypeUnsupported
|
||||
)
|
||||
|
||||
// determinePreviewType determines what type of preview generation is needed
|
||||
func determinePreviewType(file iteminfo.ExtendedFileInfo) filePreviewType {
|
||||
// Check document conversion first
|
||||
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
|
||||
return previewTypeDocument
|
||||
}
|
||||
|
||||
// Check office files
|
||||
if file.OnlyOfficeId != "" {
|
||||
return previewTypeOffice
|
||||
}
|
||||
|
||||
// Check HEIC with conversion enabled
|
||||
if strings.HasPrefix(file.Type, "image/heic") &&
|
||||
*settings.Config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview] {
|
||||
return previewTypeHEIC
|
||||
}
|
||||
|
||||
// Check by MIME type prefix
|
||||
switch {
|
||||
case strings.HasPrefix(file.Type, "image"):
|
||||
return previewTypeImage
|
||||
case strings.HasPrefix(file.Type, "video"):
|
||||
return previewTypeVideo
|
||||
case strings.HasPrefix(file.Type, "audio") && file.Metadata != nil && len(file.Metadata.AlbumArt) > 0:
|
||||
return previewTypeAudio
|
||||
default:
|
||||
return previewTypeUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
// generateRawPreview generates the initial preview image bytes based on file type
|
||||
func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int, hash string) ([]byte, error) {
|
||||
previewType := determinePreviewType(file)
|
||||
|
||||
switch previewType {
|
||||
case previewTypeDocument:
|
||||
return s.generateDocumentPreview(ctx, file, hash)
|
||||
|
||||
case previewTypeOffice:
|
||||
return s.generateOfficeFilePreview(ctx, file, officeUrl)
|
||||
|
||||
case previewTypeHEIC:
|
||||
return s.generateHEICPreview(ctx, file, previewSize)
|
||||
|
||||
case previewTypeImage:
|
||||
return s.generateImagePreview(ctx, file, previewSize)
|
||||
|
||||
case previewTypeVideo:
|
||||
return s.generateVideoPreviewBytes(ctx, file, seekPercentage)
|
||||
|
||||
case previewTypeAudio:
|
||||
if file.Metadata != nil && len(file.Metadata.AlbumArt) > 0 {
|
||||
return file.Metadata.AlbumArt, nil
|
||||
}
|
||||
return nil, nil
|
||||
case previewTypeUnsupported:
|
||||
ext := strings.ToLower(filepath.Ext(file.Name))
|
||||
return nil, fmt.Errorf("unsupported media type: %s", ext)
|
||||
|
||||
default:
|
||||
return nil, errors.New("unknown preview type")
|
||||
}
|
||||
}
|
||||
|
||||
// generateDocumentPreview generates preview for PDF and document files
|
||||
func (s *Service) generateDocumentPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, hash string) ([]byte, error) {
|
||||
tempFilePath := filepath.Join(s.cacheDir, "thumbnails", "docs", hash) + ".txt"
|
||||
imageBytes, err := s.GenerateImageFromDoc(ctx, file, tempFilePath, 0) // 0 for the first page
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create image for PDF file: %w", err)
|
||||
}
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
// generateOfficeFilePreview generates preview for Office files via OnlyOffice
|
||||
func (s *Service) generateOfficeFilePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, officeUrl string) ([]byte, error) {
|
||||
imageBytes, err := s.GenerateOfficePreview(ctx, filepath.Ext(file.Name), file.OnlyOfficeId, file.Name, officeUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create image for office file: %w", err)
|
||||
}
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
// generateHEICPreview generates preview for HEIC files using FFmpeg
|
||||
func (s *Service) generateHEICPreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize string) ([]byte, error) {
|
||||
imageBytes, err := s.convertHEICToJPEGWithFFmpeg(ctx, file.RealPath, previewSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to process HEIC image file: %w", err)
|
||||
}
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
// generateVideoPreviewBytes generates preview frame from video file
|
||||
func (s *Service) generateVideoPreviewBytes(ctx context.Context, file iteminfo.ExtendedFileInfo, seekPercentage int) ([]byte, error) {
|
||||
// Check if this video format is enabled for preview generation
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
||||
if !settings.CanConvertVideo(ext) {
|
||||
return nil, fmt.Errorf("video preview generation is disabled for .%s files in settings", ext)
|
||||
}
|
||||
|
||||
videoSeekPercentage := seekPercentage
|
||||
if videoSeekPercentage == 0 {
|
||||
videoSeekPercentage = 10
|
||||
}
|
||||
|
||||
imageBytes, err := s.GenerateVideoPreview(ctx, file.RealPath, videoSeekPercentage)
|
||||
if err != nil {
|
||||
// Don't log client cancellations as errors
|
||||
if ctx.Err() != context.Canceled {
|
||||
logger.Errorf("Video preview generation failed for '%s' (path: %s, seek: %d%%): %v",
|
||||
file.Name, file.RealPath, videoSeekPercentage, err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to create image for video file: %w", err)
|
||||
}
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
// generateImagePreview generates preview for regular image files
|
||||
func (s *Service) generateImagePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize string) ([]byte, error) {
|
||||
// Stream from file instead of os.ReadFile so we don't load every image fully into memory
|
||||
f, err := os.Open(file.RealPath)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to open image file '%s' (path: %s): %v", file.Name, file.RealPath, err)
|
||||
return nil, fmt.Errorf("failed to open image file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
options, err := getPreviewOptions(previewSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
imageBytes, err := s.CreatePreview(f, file.Size, options)
|
||||
if err != nil {
|
||||
// For JPEG files, try FFmpeg fallback if initial decode failed
|
||||
if strings.HasPrefix(file.Type, "image/jpeg") {
|
||||
return handleJPEGFallback(ctx, s, file, previewSize, 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))
|
||||
}
|
||||
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
// handleJPEGFallback attempts to use FFmpeg for problematic JPEG files
|
||||
// This is preview-specific logic for user files, not used for icon generation
|
||||
func handleJPEGFallback(ctx context.Context, s *Service, file iteminfo.ExtendedFileInfo, previewSize string, originalErr error) ([]byte, error) {
|
||||
enableJPEGFallback := *settings.Config.Integrations.Media.Convert.ImagePreview[settings.JPEGImagePreview]
|
||||
|
||||
if !enableJPEGFallback {
|
||||
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg conversion disabled in settings): %w", originalErr)
|
||||
}
|
||||
|
||||
if s.ffmpegService == nil {
|
||||
return nil, fmt.Errorf("failed to resize preview image (unsupported JPEG format, FFmpeg not available): %w", originalErr)
|
||||
}
|
||||
|
||||
logger.Debugf("JPEG decode failed for '%s', falling back to FFmpeg: %v", file.Name, originalErr)
|
||||
imageBytes, err := s.convertImageWithFFmpeg(ctx, file.RealPath, previewSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resize preview image with FFmpeg fallback: %w", err)
|
||||
}
|
||||
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int, fileMD5 string) ([]byte, error) {
|
||||
// Note: fileMD5 is actually a cache hash (metadata-based), not a true file content MD5
|
||||
// Validate that cache hash is not empty to prevent cache corruption
|
||||
@@ -402,61 +180,82 @@ 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 {
|
||||
if err := service.acquireImageLargeSem(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer service.releaseImageLargeSem()
|
||||
} else {
|
||||
if err := service.acquireImageSem(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer service.releaseImageSem()
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(file.Name))
|
||||
var (
|
||||
err error
|
||||
imageBytes []byte
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Generate hash for temp file paths
|
||||
// Generate thumbnail image from video
|
||||
hasher := md5.New()
|
||||
_, _ = hasher.Write([]byte(CacheKey(fileMD5, previewSize, seekPercentage)))
|
||||
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
// Generate raw preview based on file type
|
||||
imageBytes, err := service.generateRawPreview(ctx, file, previewSize, officeUrl, seekPercentage, hash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Check if HEIC conversion is enabled (guaranteed non-nil after defaults)
|
||||
convertHEIC := strings.HasPrefix(file.Type, "image/heic") && *settings.Config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview]
|
||||
|
||||
// For HEIC files, we've already done the resize/conversion, cache and return directly
|
||||
previewType := determinePreviewType(file)
|
||||
if previewType == previewTypeHEIC {
|
||||
// Generate an image from office document
|
||||
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
|
||||
tempFilePath := filepath.Join(service.cacheDir, "thumbnails", "docs", hash) + ".txt"
|
||||
imageBytes, err = service.GenerateImageFromDoc(ctx, file, tempFilePath, 0) // 0 for the first page
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create image for PDF file: %w", err)
|
||||
}
|
||||
} else if file.OnlyOfficeId != "" {
|
||||
imageBytes, err = service.GenerateOfficePreview(ctx, filepath.Ext(file.Name), file.OnlyOfficeId, file.Name, officeUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create image for office file: %w", err)
|
||||
}
|
||||
} else if convertHEIC {
|
||||
// HEIC files need FFmpeg conversion to JPEG with proper size/quality handling
|
||||
imageBytes, err = service.convertHEICToJPEGWithFFmpeg(ctx, file.RealPath, previewSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to process HEIC image file: %w", err)
|
||||
}
|
||||
// For HEIC files, we've already done the resize/conversion, so cache and return directly
|
||||
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
|
||||
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
|
||||
logger.Errorf("failed to cache HEIC image: %v", err)
|
||||
}
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
// For regular images that were already resized, cache and return
|
||||
if previewType == previewTypeImage {
|
||||
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
|
||||
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
|
||||
logger.Errorf("failed to cache image: %v", err)
|
||||
} 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)
|
||||
}
|
||||
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)), ".")
|
||||
if !settings.CanConvertVideo(ext) {
|
||||
return nil, fmt.Errorf("video preview generation is disabled for .%s files in settings", ext)
|
||||
}
|
||||
|
||||
videoSeekPercentage := seekPercentage
|
||||
if videoSeekPercentage == 0 {
|
||||
videoSeekPercentage = 10
|
||||
}
|
||||
imageBytes, err = service.GenerateVideoPreview(ctx, file.RealPath, videoSeekPercentage)
|
||||
if err != nil {
|
||||
// Don't log client cancellations as errors
|
||||
if ctx.Err() != context.Canceled {
|
||||
logger.Errorf("Video preview generation failed for '%s' (path: %s, seek: %d%%): %v",
|
||||
file.Name, file.RealPath, videoSeekPercentage, err)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to create image for video file: %w", err)
|
||||
}
|
||||
} else if strings.HasPrefix(file.Type, "audio") {
|
||||
// Extract album artwork from audio files
|
||||
if file.Metadata != nil && file.Metadata.AlbumArt != "" {
|
||||
imageBytes, err = base64.StdEncoding.DecodeString(file.Metadata.AlbumArt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode album artwork: %w", err)
|
||||
}
|
||||
} else {
|
||||
return nil, nil
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("unsupported media type: %s", ext)
|
||||
}
|
||||
|
||||
// Check if context was cancelled during processing
|
||||
@@ -464,47 +263,62 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
// Validate generated image size
|
||||
if len(imageBytes) < 100 {
|
||||
logger.Errorf("Generated image too small for '%s' (type: %s): %d bytes - likely an error occurred",
|
||||
file.Name, file.Type, len(imageBytes))
|
||||
return nil, fmt.Errorf("generated image is too small, likely an error occurred: %d bytes", len(imageBytes))
|
||||
}
|
||||
|
||||
// Resize if needed (for videos, audio, documents, office files)
|
||||
if previewSize != "original" {
|
||||
options, err := getPreviewOptions(previewSize)
|
||||
// resize image
|
||||
resizedBytes, err := service.CreatePreview(imageBytes, previewSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Check if this is an unsupported JPEG format (extended sequential, etc.)
|
||||
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")) {
|
||||
|
||||
resizedBytes, err := service.CreatePreview(bytes.NewReader(imageBytes), 0, options)
|
||||
if err != nil {
|
||||
// For JPEG files, try FFmpeg fallback if resize failed
|
||||
if strings.HasPrefix(file.Type, "image/jpeg") {
|
||||
resizedBytes, err = handleJPEGFallback(ctx, service, file, previewSize, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Check if JPEG FFmpeg conversion is enabled (guaranteed non-nil after defaults)
|
||||
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 {
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resize preview image with FFmpeg fallback: %w", err)
|
||||
}
|
||||
// Cache and return the FFmpeg-converted image
|
||||
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
|
||||
if err = service.fileCache.Store(ctx, cacheKey, resizedBytes); err != nil {
|
||||
logger.Errorf("failed to cache FFmpeg-converted image: %v", err)
|
||||
}
|
||||
return resizedBytes, nil
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to resize preview image: %w", err)
|
||||
// If FFmpeg fallback is disabled or not available, return the original error
|
||||
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 resize preview image: %w", err)
|
||||
}
|
||||
|
||||
// Cache and return resized image
|
||||
// Cache and return
|
||||
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
|
||||
if err := service.fileCache.Store(ctx, cacheKey, resizedBytes); err != nil {
|
||||
logger.Errorf("failed to cache resized image: %v", err)
|
||||
}
|
||||
return resizedBytes, nil
|
||||
} else {
|
||||
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
|
||||
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
|
||||
logger.Errorf("failed to cache original image: %v", err)
|
||||
}
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
// Cache and return original size
|
||||
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
|
||||
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
|
||||
logger.Errorf("failed to cache original image: %v", err)
|
||||
}
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
func GeneratePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int) ([]byte, error) {
|
||||
@@ -517,44 +331,31 @@ func GeneratePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previe
|
||||
return GeneratePreviewWithMD5(ctx, file, previewSize, officeUrl, seekPercentage, cacheHash)
|
||||
}
|
||||
|
||||
// getPreviewOptions returns resize options for the given preview size
|
||||
func getPreviewOptions(previewSize string) (ResizeOptions, error) {
|
||||
switch previewSize {
|
||||
case "xlarge":
|
||||
return ResizeOptions{
|
||||
Width: 1024,
|
||||
Height: 1024,
|
||||
ResizeMode: ResizeModeFit,
|
||||
Quality: QualityHigh,
|
||||
Format: FormatJpeg,
|
||||
}, nil
|
||||
case "large":
|
||||
return ResizeOptions{
|
||||
Width: 640,
|
||||
Height: 640,
|
||||
ResizeMode: ResizeModeFit,
|
||||
Quality: QualityHigh,
|
||||
Format: FormatJpeg,
|
||||
}, nil
|
||||
case "small":
|
||||
return ResizeOptions{
|
||||
Width: 256,
|
||||
Height: 256,
|
||||
ResizeMode: ResizeModeFit,
|
||||
Quality: QualityMedium,
|
||||
Format: FormatJpeg,
|
||||
}, nil
|
||||
default:
|
||||
return ResizeOptions{}, ErrUnsupportedFormat
|
||||
}
|
||||
}
|
||||
func (s *Service) CreatePreview(data []byte, previewSize string) ([]byte, error) {
|
||||
var (
|
||||
width int
|
||||
height int
|
||||
options []Option
|
||||
)
|
||||
|
||||
// CreatePreview resizes an image from a reader with the given options
|
||||
func (s *Service) CreatePreview(reader io.Reader, fileSize int64, options ResizeOptions) ([]byte, error) {
|
||||
switch previewSize {
|
||||
case "large":
|
||||
width, height = 640, 640
|
||||
options = []Option{WithMode(ResizeModeFit), WithQuality(QualityHigh), WithFormat(FormatJpeg)}
|
||||
case "small":
|
||||
width, height = 256, 256
|
||||
options = []Option{WithMode(ResizeModeFit), WithQuality(QualityMedium), WithFormat(FormatJpeg)}
|
||||
default:
|
||||
return nil, ErrUnsupportedFormat
|
||||
}
|
||||
|
||||
input := bytes.NewReader(data)
|
||||
output := &bytes.Buffer{}
|
||||
if err := s.ResizeWithSize(reader, output, fileSize, options); err != nil {
|
||||
|
||||
if err := s.Resize(input, width, height, output, 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.ffmpegService == nil {
|
||||
logger.Errorf("FFmpeg service not available for file '%s'", videoPath)
|
||||
return nil, fmt.Errorf("FFmpeg service not available")
|
||||
if s.videoService == nil {
|
||||
logger.Errorf("Video service not available for file '%s'", videoPath)
|
||||
return nil, fmt.Errorf("video 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.ffmpegService.GenerateVideoPreviewStreaming(ctx, videoPath, percentageSeek, &buf)
|
||||
err := s.videoService.GenerateVideoPreviewStreaming(ctx, videoPath, percentageSeek, &buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
previewBytes := buf.Bytes()
|
||||
if len(previewBytes) == 0 {
|
||||
logger.Errorf("FFmpeg service returned empty result for '%s'", videoPath)
|
||||
logger.Errorf("Video service returned empty result for '%s'", videoPath)
|
||||
return nil, fmt.Errorf("video preview generation returned empty result")
|
||||
}
|
||||
|
||||
|
||||
@@ -1105,17 +1105,17 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/media/subtitles": {
|
||||
"get": {
|
||||
"description": "Returns raw subtitle content from external files or embedded streams",
|
||||
"description": "Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"text/plain"
|
||||
"text/vtt"
|
||||
],
|
||||
"tags": [
|
||||
"Subtitles"
|
||||
],
|
||||
"summary": "Get subtitle content",
|
||||
"summary": "Extract embedded subtitles",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
@@ -1132,22 +1132,15 @@ const docTemplate = `{
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Subtitle track name (filename for external, descriptive name for embedded)",
|
||||
"name": "name",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Whether this is an embedded stream (true) or external file (false), defaults to false",
|
||||
"name": "embedded",
|
||||
"type": "integer",
|
||||
"description": "Stream index for embedded subtitle extraction, defaults to 0",
|
||||
"name": "index",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Raw subtitle content in original format",
|
||||
"description": "Raw WebVTT subtitle content",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -1571,7 +1564,7 @@ const docTemplate = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "url encoded destination path where to place the files inside the destination source",
|
||||
"description": "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
@@ -3070,8 +3063,8 @@ const docTemplate = `{
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "path within the share to upload to. Must be relative to share root.",
|
||||
"name": "path",
|
||||
"description": "Target path within the share to upload to. Must be relative to share root.",
|
||||
"name": "targetPath",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
@@ -3319,14 +3312,14 @@ const docTemplate = `{
|
||||
},
|
||||
"/public/api/share/image": {
|
||||
"get": {
|
||||
"description": "Returns a resizable preview (large size) for the banner or favicon file of a share",
|
||||
"description": "Serves the banner or favicon file for a share",
|
||||
"produces": [
|
||||
"image/jpeg"
|
||||
"application/octet-stream"
|
||||
],
|
||||
"tags": [
|
||||
"Public Shares"
|
||||
],
|
||||
"summary": "Get share image (banner or favicon) as preview",
|
||||
"summary": "Get share image (banner or favicon)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
@@ -3350,7 +3343,7 @@ const docTemplate = `{
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Preview image content (JPEG)",
|
||||
"description": "Image file content",
|
||||
"schema": {
|
||||
"type": "file"
|
||||
}
|
||||
@@ -4082,11 +4075,8 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"albumArt": {
|
||||
"description": "album art image data (automatically base64-encoded in JSON)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
"description": "base64 encoded album art / video thumbnail",
|
||||
"type": "string"
|
||||
},
|
||||
"artist": {
|
||||
"description": "track artist",
|
||||
@@ -4342,10 +4332,6 @@ const docTemplate = `{
|
||||
"description": "display name",
|
||||
"type": "string"
|
||||
},
|
||||
"oidcLoginButtonText": {
|
||||
"description": "text to display on the OIDC login button",
|
||||
"type": "string"
|
||||
},
|
||||
"styling": {
|
||||
"$ref": "#/definitions/settings.StylingConfig"
|
||||
}
|
||||
|
||||
@@ -1094,17 +1094,17 @@
|
||||
},
|
||||
"/api/media/subtitles": {
|
||||
"get": {
|
||||
"description": "Returns raw subtitle content from external files or embedded streams",
|
||||
"description": "Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"text/plain"
|
||||
"text/vtt"
|
||||
],
|
||||
"tags": [
|
||||
"Subtitles"
|
||||
],
|
||||
"summary": "Get subtitle content",
|
||||
"summary": "Extract embedded subtitles",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
@@ -1121,22 +1121,15 @@
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Subtitle track name (filename for external, descriptive name for embedded)",
|
||||
"name": "name",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Whether this is an embedded stream (true) or external file (false), defaults to false",
|
||||
"name": "embedded",
|
||||
"type": "integer",
|
||||
"description": "Stream index for embedded subtitle extraction, defaults to 0",
|
||||
"name": "index",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Raw subtitle content in original format",
|
||||
"description": "Raw WebVTT subtitle content",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -1560,7 +1553,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "url encoded destination path where to place the files inside the destination source",
|
||||
"description": "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
@@ -3059,8 +3052,8 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "path within the share to upload to. Must be relative to share root.",
|
||||
"name": "path",
|
||||
"description": "Target path within the share to upload to. Must be relative to share root.",
|
||||
"name": "targetPath",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
@@ -3308,14 +3301,14 @@
|
||||
},
|
||||
"/public/api/share/image": {
|
||||
"get": {
|
||||
"description": "Returns a resizable preview (large size) for the banner or favicon file of a share",
|
||||
"description": "Serves the banner or favicon file for a share",
|
||||
"produces": [
|
||||
"image/jpeg"
|
||||
"application/octet-stream"
|
||||
],
|
||||
"tags": [
|
||||
"Public Shares"
|
||||
],
|
||||
"summary": "Get share image (banner or favicon) as preview",
|
||||
"summary": "Get share image (banner or favicon)",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
@@ -3339,7 +3332,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Preview image content (JPEG)",
|
||||
"description": "Image file content",
|
||||
"schema": {
|
||||
"type": "file"
|
||||
}
|
||||
@@ -4071,11 +4064,8 @@
|
||||
"type": "string"
|
||||
},
|
||||
"albumArt": {
|
||||
"description": "album art image data (automatically base64-encoded in JSON)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
"description": "base64 encoded album art / video thumbnail",
|
||||
"type": "string"
|
||||
},
|
||||
"artist": {
|
||||
"description": "track artist",
|
||||
@@ -4331,10 +4321,6 @@
|
||||
"description": "display name",
|
||||
"type": "string"
|
||||
},
|
||||
"oidcLoginButtonText": {
|
||||
"description": "text to display on the OIDC login button",
|
||||
"type": "string"
|
||||
},
|
||||
"styling": {
|
||||
"$ref": "#/definitions/settings.StylingConfig"
|
||||
}
|
||||
|
||||
@@ -447,10 +447,8 @@ definitions:
|
||||
description: album name
|
||||
type: string
|
||||
albumArt:
|
||||
description: album art image data (automatically base64-encoded in JSON)
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
description: base64 encoded album art / video thumbnail
|
||||
type: string
|
||||
artist:
|
||||
description: track artist
|
||||
type: string
|
||||
@@ -657,9 +655,6 @@ 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
|
||||
@@ -2499,7 +2494,8 @@ paths:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns raw subtitle content from external files or embedded streams
|
||||
description: Extracts embedded subtitle content from video files by stream index
|
||||
and returns raw WebVTT content
|
||||
parameters:
|
||||
- description: Index path to the video file
|
||||
in: query
|
||||
@@ -2511,22 +2507,15 @@ paths:
|
||||
name: source
|
||||
required: true
|
||||
type: string
|
||||
- description: Subtitle track name (filename for external, descriptive name
|
||||
for embedded)
|
||||
- description: Stream index for embedded subtitle extraction, defaults to 0
|
||||
in: query
|
||||
name: name
|
||||
required: true
|
||||
type: string
|
||||
- description: Whether this is an embedded stream (true) or external file (false),
|
||||
defaults to false
|
||||
in: query
|
||||
name: embedded
|
||||
type: boolean
|
||||
name: index
|
||||
type: integer
|
||||
produces:
|
||||
- text/plain
|
||||
- text/vtt
|
||||
responses:
|
||||
"200":
|
||||
description: Raw subtitle content in original format
|
||||
description: Raw WebVTT subtitle content
|
||||
schema:
|
||||
type: string
|
||||
"400":
|
||||
@@ -2553,7 +2542,7 @@ paths:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Get subtitle content
|
||||
summary: Extract embedded subtitles
|
||||
tags:
|
||||
- Subtitles
|
||||
/api/preview:
|
||||
@@ -2863,7 +2852,7 @@ paths:
|
||||
Supports file uploads and directory creation.
|
||||
parameters:
|
||||
- description: url encoded destination path where to place the files inside
|
||||
the destination source
|
||||
the destination source, a directory must end in / to create a directory
|
||||
in: query
|
||||
name: path
|
||||
required: true
|
||||
@@ -3847,10 +3836,10 @@ paths:
|
||||
name: hash
|
||||
required: true
|
||||
type: string
|
||||
- description: path within the share to upload to. Must be relative to share
|
||||
root.
|
||||
- description: Target path within the share to upload to. Must be relative to
|
||||
share root.
|
||||
in: query
|
||||
name: path
|
||||
name: targetPath
|
||||
required: true
|
||||
type: string
|
||||
- description: If true, overwrite existing files/folders. Defaults to false.
|
||||
@@ -4026,8 +4015,7 @@ paths:
|
||||
- Public Shares
|
||||
/public/api/share/image:
|
||||
get:
|
||||
description: Returns a resizable preview (large size) for the banner or favicon
|
||||
file of a share
|
||||
description: Serves the banner or favicon file for a share
|
||||
parameters:
|
||||
- description: Share hash
|
||||
in: query
|
||||
@@ -4043,10 +4031,10 @@ paths:
|
||||
name: favicon
|
||||
type: boolean
|
||||
produces:
|
||||
- image/jpeg
|
||||
- application/octet-stream
|
||||
responses:
|
||||
"200":
|
||||
description: Preview image content (JPEG)
|
||||
description: Image file content
|
||||
schema:
|
||||
type: file
|
||||
"400":
|
||||
@@ -4067,7 +4055,7 @@ paths:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Get share image (banner or favicon) as preview
|
||||
summary: Get share image (banner or favicon)
|
||||
tags:
|
||||
- Public Shares
|
||||
/public/api/shareinfo:
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"dependencies": {
|
||||
"@onlyoffice/document-editor-vue": "^1.4.0",
|
||||
"ace-builds": "^1.24.2",
|
||||
"axios": "^1.13.5",
|
||||
"axios": "^1.7.9",
|
||||
"clipboard": "^2.0.4",
|
||||
"css-vars-ponyfill": "^2.4.3",
|
||||
"dompurify": "^3.2.4",
|
||||
@@ -41,9 +41,6 @@
|
||||
"vue-lazyload": "^3.0.0",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"overrides": {
|
||||
"minimatch": "^10.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
|
||||
"@intlify/unplugin-vue-i18n": "^4.0.0",
|
||||
|
||||
@@ -119,7 +119,6 @@ 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
|
||||
|
||||
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 546 B |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 969 B |
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
|
||||
|
After Width: | Height: | Size: 527 B |
@@ -0,0 +1,10 @@
|
||||
<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>
|
||||
|
||||
|
After Width: | Height: | Size: 415 B |
|
After Width: | Height: | Size: 9.1 KiB |
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 5.9 KiB |
@@ -17,7 +17,7 @@
|
||||
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/material-symbols-outlined.woff2" as="font" type="font/woff2" crossorigin>
|
||||
|
||||
<!-- Add to home screen for Android and modern mobile browsers -->
|
||||
<link rel="manifest" href="{{ .htmlVars.manifestURL }}" crossorigin="use-credentials">
|
||||
<link rel="manifest" id="manifestPlaceholder" crossorigin="use-credentials">
|
||||
<meta name="theme-color" content="{{ .htmlVars.color }}">
|
||||
|
||||
<!-- Add to home screen for Safari on iOS/iPadOS -->
|
||||
@@ -52,6 +52,38 @@
|
||||
<!-- Injected variables come from backend server -->
|
||||
<script>
|
||||
window.globalVars = {{ .globalVars }};
|
||||
var dynamicManifest = {
|
||||
"name": window.globalVars.name || 'FileBrowser Quantum',
|
||||
"short_name": window.globalVars.name || 'FileBrowser',
|
||||
"icons": [
|
||||
{
|
||||
"src": window.location.origin + "{{ .htmlVars.staticURL }}/img/icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"start_url": window.location.origin + "{{ .htmlVars.baseURL }}",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "{{ .htmlVars.color }}",
|
||||
"description": "{{ .htmlVars.description }}"
|
||||
}
|
||||
|
||||
const stringManifest = JSON.stringify(dynamicManifest);
|
||||
const blob = new Blob([stringManifest], { type: 'application/json' });
|
||||
const manifestURL = URL.createObjectURL(blob);
|
||||
document.querySelector('#manifestPlaceholder').setAttribute('href', manifestURL);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
|
Before Width: | Height: | Size: 949 B After Width: | Height: | Size: 949 B |
@@ -1,5 +1,5 @@
|
||||
import { fetchURL, adjustedData } from './utils'
|
||||
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
|
||||
import { getApiPath, doubleEncode, 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: path,
|
||||
source: source,
|
||||
path: doubleEncode(path),
|
||||
source: doubleEncode(source),
|
||||
...(content && { content: 'true' }),
|
||||
...(metadata && { metadata: 'true' })
|
||||
})
|
||||
@@ -32,6 +32,8 @@ async function resourceAction(source, path, method, content) {
|
||||
throw new Error('no source provided')
|
||||
}
|
||||
try {
|
||||
source = doubleEncode(source)
|
||||
path = doubleEncode(path)
|
||||
const apiPath = getApiPath('api/resources', { path, source })
|
||||
let opts = { method }
|
||||
if (content) {
|
||||
@@ -74,12 +76,15 @@ export async function bulkDelete(items) {
|
||||
},
|
||||
body: JSON.stringify(items),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// 200 = all succeeded, 207 = partial success (some succeeded, some failed)
|
||||
// Both are valid responses that should be returned, not thrown as errors
|
||||
if (response.status === 200 || response.status === 207) {
|
||||
return data
|
||||
}
|
||||
|
||||
// For other error status codes, throw an error
|
||||
const error = new Error(data.message || response.statusText)
|
||||
error.status = response.status
|
||||
@@ -105,12 +110,12 @@ export async function download(format, files, shareHash = "") {
|
||||
// Check if chunked download should be used (single file only)
|
||||
const downloadChunkSizeMb = state.user?.fileLoading?.downloadChunkSizeMb || 0
|
||||
const sizeThreshold = downloadChunkSizeMb * 1024 * 1024;
|
||||
|
||||
const useChunkedDownload =
|
||||
downloadChunkSizeMb > 0 &&
|
||||
files.length === 1 &&
|
||||
!files[0].isDir &&
|
||||
files[0].size &&
|
||||
|
||||
const useChunkedDownload =
|
||||
downloadChunkSizeMb > 0 &&
|
||||
files.length === 1 &&
|
||||
!files[0].isDir &&
|
||||
files[0].size &&
|
||||
files[0].size >= sizeThreshold
|
||||
|
||||
if (useChunkedDownload) {
|
||||
@@ -122,14 +127,14 @@ export async function download(format, files, shareHash = "") {
|
||||
if (format !== 'zip') {
|
||||
format = 'tar.gz'
|
||||
}
|
||||
|
||||
|
||||
// For non-share downloads, validate single source and build file list
|
||||
let source = null
|
||||
let filePaths = []
|
||||
|
||||
|
||||
if (shareHash) {
|
||||
// For shares, no source parameter needed, just paths
|
||||
filePaths = files.map(file => file.path)
|
||||
filePaths = files.map(file => encodeURIComponent(file.path))
|
||||
} else {
|
||||
// Validate all files are from the same source
|
||||
for (let file of files) {
|
||||
@@ -141,19 +146,19 @@ export async function download(format, files, shareHash = "") {
|
||||
} else if (source !== file.source) {
|
||||
throw new Error('All files must be from the same source for downloads')
|
||||
}
|
||||
filePaths.push(file.path)
|
||||
filePaths.push(encodeURIComponent(file.path))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const params = {
|
||||
file: filePaths, // Array of file paths - getApiPath will create repeated parameters
|
||||
algo: format,
|
||||
...(shareHash && { hash: shareHash }),
|
||||
...(!shareHash && source && { source: source }),
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token }),
|
||||
...(!shareHash && source && { source: encodeURIComponent(source) }),
|
||||
...(state.share.token && { token: state.share.token }),
|
||||
sessionId: state.sessionId
|
||||
}
|
||||
|
||||
|
||||
const apiPath = getApiPath(shareHash == "" ? 'api/raw' : 'public/api/raw', params)
|
||||
const url = window.origin + apiPath
|
||||
|
||||
@@ -167,7 +172,7 @@ export async function download(format, files, shareHash = "") {
|
||||
link.style.display = 'none'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
|
||||
|
||||
// Clean up after a short delay
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(link)
|
||||
@@ -176,36 +181,36 @@ export async function download(format, files, shareHash = "") {
|
||||
|
||||
async function downloadChunked(file, shareHash = "") {
|
||||
const chunkSizeMb = state.user?.fileLoading?.downloadChunkSizeMb || 0
|
||||
|
||||
|
||||
if (chunkSizeMb === 0) {
|
||||
throw new Error("Chunked download is disabled (chunk size is 0)")
|
||||
}
|
||||
const chunkSize = chunkSizeMb * 1024 * 1024 // Convert MB to bytes
|
||||
const fileSize = file.size
|
||||
|
||||
|
||||
// Extract filename from path if name is not available
|
||||
const fileName = file.name || (file.path ? file.path.split('/').pop() : 'download')
|
||||
|
||||
// Add to download manager
|
||||
const downloadId = downloadManager.add(file, shareHash)
|
||||
|
||||
|
||||
downloadManager.setStatus(downloadId, "downloading")
|
||||
|
||||
|
||||
// Show download prompt if not already shown (it should already be shown by downloadFiles, but check to be safe)
|
||||
const hasDownloadPrompt = state.hovers && state.hovers.some(h => h.name === 'download');
|
||||
|
||||
|
||||
if (!hasDownloadPrompt) {
|
||||
mutations.showHover({ name: 'download' })
|
||||
}
|
||||
|
||||
const params = {
|
||||
file: file.path,
|
||||
file: encodeURIComponent(file.path),
|
||||
...(shareHash && { hash: shareHash }),
|
||||
...(!shareHash && file.source && { source: file.source }),
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token }),
|
||||
...(!shareHash && file.source && { source: encodeURIComponent(file.source) }),
|
||||
...(state.share.token && { token: state.share.token }),
|
||||
sessionId: state.sessionId
|
||||
}
|
||||
|
||||
|
||||
const apiPath = getApiPath(shareHash == "" ? 'api/raw' : 'public/api/raw', params)
|
||||
const baseUrl = window.origin + apiPath
|
||||
|
||||
@@ -220,7 +225,7 @@ async function downloadChunked(file, shareHash = "") {
|
||||
let loaded = 0
|
||||
|
||||
while (offset < fileSize) {
|
||||
|
||||
|
||||
const download = downloadManager.findById(downloadId);
|
||||
if (download && download.status === "cancelled") {
|
||||
// Silently handle cancellation - don't throw error
|
||||
@@ -244,7 +249,7 @@ async function downloadChunked(file, shareHash = "") {
|
||||
|
||||
// Track progress within the chunk using ReadableStream
|
||||
const expectedChunkSize = end - offset + 1;
|
||||
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunkParts = [];
|
||||
let chunkLoaded = 0;
|
||||
@@ -259,14 +264,14 @@ async function downloadChunked(file, shareHash = "") {
|
||||
reading = false;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
chunkParts.push(value);
|
||||
chunkLoaded += value.length;
|
||||
|
||||
|
||||
// Calculate progress: only count up to expected chunk size to avoid over-counting
|
||||
const chunkProgress = Math.min(chunkLoaded, expectedChunkSize);
|
||||
const totalLoaded = offset + chunkProgress;
|
||||
|
||||
|
||||
// Update progress in real-time, but throttle updates for performance
|
||||
if (chunkLoaded - lastProgressUpdate >= progressUpdateInterval || chunkLoaded >= expectedChunkSize) {
|
||||
downloadManager.updateProgress(downloadId, totalLoaded, fileSize);
|
||||
@@ -290,19 +295,19 @@ async function downloadChunked(file, shareHash = "") {
|
||||
chunk.set(part, position);
|
||||
position += part.length;
|
||||
}
|
||||
|
||||
|
||||
// Only use the expected chunk size portion if server returned more (handles Range header issues)
|
||||
const chunkToUse = chunk.byteLength > expectedChunkSize
|
||||
? chunk.slice(0, expectedChunkSize).buffer
|
||||
const chunkToUse = chunk.byteLength > expectedChunkSize
|
||||
? chunk.slice(0, expectedChunkSize).buffer
|
||||
: chunk.buffer;
|
||||
|
||||
|
||||
chunks.push(chunkToUse)
|
||||
// Always use expected chunk size for progress to avoid double-counting
|
||||
loaded += expectedChunkSize
|
||||
|
||||
|
||||
// Final progress update for this chunk
|
||||
downloadManager.updateProgress(downloadId, loaded, fileSize)
|
||||
|
||||
|
||||
offset = end + 1
|
||||
}
|
||||
|
||||
@@ -355,8 +360,8 @@ export function post(
|
||||
}
|
||||
try {
|
||||
const apiPath = getApiPath("api/resources", {
|
||||
path: path,
|
||||
source: source,
|
||||
path: doubleEncode(path),
|
||||
source: doubleEncode(source),
|
||||
override: overwrite,
|
||||
...(isDir && { isDir: 'true' })
|
||||
});
|
||||
@@ -448,7 +453,7 @@ export async function moveCopy(
|
||||
'Content-Type': 'application/json',
|
||||
'sessionId': state.sessionId
|
||||
}
|
||||
|
||||
|
||||
const response = await fetch(apiPath, {
|
||||
method: 'PATCH',
|
||||
headers: headers,
|
||||
@@ -491,8 +496,8 @@ export async function checksum(source, path, algo) {
|
||||
}
|
||||
try {
|
||||
const params = {
|
||||
path: path,
|
||||
source: source,
|
||||
path: doubleEncode(path),
|
||||
source: doubleEncode(source),
|
||||
checksum: algo
|
||||
}
|
||||
const apiPath = getApiPath('api/resources', params)
|
||||
@@ -511,8 +516,8 @@ export function getDownloadURL(source, path, inline, useExternal) {
|
||||
}
|
||||
try {
|
||||
const params = {
|
||||
source: source,
|
||||
file: path,
|
||||
source: encodeURIComponent(source),
|
||||
file: encodeURIComponent(path),
|
||||
...(inline && { inline: 'true' })
|
||||
}
|
||||
const apiPath = getApiPath('api/raw', params)
|
||||
@@ -532,9 +537,9 @@ export function getPreviewURL(source, path, modified) {
|
||||
}
|
||||
try {
|
||||
const params = {
|
||||
path: path,
|
||||
path: encodeURIComponent(path),
|
||||
key: Date.parse(modified), // Use modified date as cache key
|
||||
source: source,
|
||||
source: encodeURIComponent(source),
|
||||
inline: 'true'
|
||||
}
|
||||
const apiPath = getApiPath('api/preview', params)
|
||||
@@ -563,9 +568,9 @@ export async function sources() {
|
||||
|
||||
export async function GetOfficeConfig(req) {
|
||||
const params = {
|
||||
path: req.path,
|
||||
...(req.hash && { hash: req.hash }),
|
||||
...(req.source && { source: req.source })
|
||||
path: encodeURIComponent(req.path),
|
||||
...(req.hash && { hash: encodeURIComponent(req.hash) }),
|
||||
...(req.source && { source: encodeURIComponent(req.source) })
|
||||
}
|
||||
let apiPath = getApiPath('api/onlyoffice/config', params)
|
||||
if (req.hash) {
|
||||
@@ -573,21 +578,4 @@ export async function GetOfficeConfig(req) {
|
||||
}
|
||||
const res = await fetchURL(apiPath)
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
export async function getSubtitleContent(source, path, subtitleName, embedded = false) {
|
||||
try {
|
||||
const apiPath = getApiPath('api/media/subtitles', {
|
||||
source: source,
|
||||
path: path,
|
||||
name: subtitleName,
|
||||
embedded: embedded.toString()
|
||||
})
|
||||
const res = await fetchURL(apiPath)
|
||||
const content = await res.text()
|
||||
return content
|
||||
} catch (err) {
|
||||
notify.showError(err.message || `Error fetching subtitle ${subtitleName}`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { adjustedData } from "./utils";
|
||||
import { notify } from "@/notify";
|
||||
import { getPublicApiPath } from "@/utils/url.js";
|
||||
import { getPublicApiPath, encodedPath, doubleEncode } from "@/utils/url.js";
|
||||
import { state } from "@/store";
|
||||
|
||||
// ============================================================================
|
||||
@@ -17,17 +17,18 @@ import { state } from "@/store";
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function fetchPub(path, hash, password = "", content = false, metadata = false) {
|
||||
path = encodedPath(path);
|
||||
const params = {
|
||||
path: path,
|
||||
hash,
|
||||
...(content && { content: 'true' }),
|
||||
...(metadata && { metadata: 'true' }),
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token })
|
||||
...(state.share.token && { token: state.share.token })
|
||||
}
|
||||
const apiPath = getPublicApiPath("resources", params);
|
||||
const response = await fetch(apiPath, {
|
||||
headers: {
|
||||
"X-SHARE-PASSWORD": password || "",
|
||||
"X-SHARE-PASSWORD": password ? encodeURIComponent(password) : "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -60,8 +61,10 @@ export async function fetchPub(path, hash, password = "", content = false, metad
|
||||
export function getDownloadURL(share, files, inline=false) {
|
||||
// Handle array of files for repeated 'file' parameters
|
||||
const fileArray = Array.isArray(files) ? files : [files]
|
||||
const filePaths = fileArray.map(file => encodeURIComponent(file))
|
||||
|
||||
const params = {
|
||||
file: fileArray, // Array of file paths - getPublicApiPath will create repeated parameters
|
||||
file: filePaths, // Array of file paths - getPublicApiPath will create repeated parameters
|
||||
hash: share.hash,
|
||||
token: share.token,
|
||||
...(inline && { inline: 'true' })
|
||||
@@ -73,18 +76,16 @@ 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) {
|
||||
export function getPreviewURL(path,size="small") {
|
||||
try {
|
||||
const params = {
|
||||
path: path,
|
||||
hash: state.shareInfo.hash,
|
||||
path: encodeURIComponent(path),
|
||||
size: size,
|
||||
hash: state.share.hash,
|
||||
inline: 'true',
|
||||
// Only add size parameter if specified and not the default
|
||||
...(size && size !== 'small' && { size: size }),
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token })
|
||||
...(state.share.token && { token: state.share.token })
|
||||
}
|
||||
const apiPath = getPublicApiPath('preview', params)
|
||||
return window.origin + apiPath
|
||||
@@ -112,7 +113,7 @@ export function post(
|
||||
}
|
||||
try {
|
||||
const apiPath = getPublicApiPath("resources", {
|
||||
path: path,
|
||||
targetPath: doubleEncode(path),
|
||||
hash: hash,
|
||||
override: overwrite,
|
||||
...(isDir && { isDir: 'true' })
|
||||
@@ -153,13 +154,13 @@ export function post(
|
||||
// If parsing fails, use responseText or default message
|
||||
errorMessage = request.responseText || errorMessage;
|
||||
}
|
||||
|
||||
|
||||
const error = new Error(errorMessage);
|
||||
error.status = request.status;
|
||||
|
||||
|
||||
// Show notification for upload errors
|
||||
notify.showError(errorMessage);
|
||||
|
||||
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
@@ -169,7 +170,7 @@ export function post(
|
||||
notify.showError("Network error during upload");
|
||||
reject(error);
|
||||
};
|
||||
|
||||
|
||||
request.onabort = () => {
|
||||
const error = new Error("Upload aborted");
|
||||
notify.showError("Upload was aborted");
|
||||
@@ -204,6 +205,7 @@ async function resourceAction(hash, path, method, content, token = "") {
|
||||
if (sharePassword) {
|
||||
headers["X-SHARE-PASSWORD"] = sharePassword;
|
||||
}
|
||||
path = doubleEncode(path)
|
||||
const apiPath = getPublicApiPath('resources', { path, hash: hash, token: token })
|
||||
const response = await fetch(apiPath, {
|
||||
method,
|
||||
@@ -236,15 +238,15 @@ export async function bulkDelete(items) {
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
throw new Error('items array is required and must not be empty')
|
||||
}
|
||||
|
||||
|
||||
const hash = state.shareInfo?.hash;
|
||||
if (!hash) {
|
||||
throw new Error('share hash is required')
|
||||
}
|
||||
|
||||
|
||||
const params = {
|
||||
hash: hash,
|
||||
...(state.shareInfo.token && { token: state.shareInfo.token }),
|
||||
...(state.share.token && { token: state.share.token }),
|
||||
sessionId: state.sessionId
|
||||
}
|
||||
const apiPath = getPublicApiPath("resources/bulk/delete", params)
|
||||
@@ -324,7 +326,7 @@ export async function moveCopy(
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(state.shareInfo.token && { 'X-Auth-Token': state.shareInfo.token })
|
||||
...(state.share.token && { 'X-Auth-Token': state.share.token })
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
|
||||
@@ -4,12 +4,14 @@ import { getApiPath } from "@/utils/url.js";
|
||||
|
||||
export default async function search(base, sources, query, largest = false) {
|
||||
try {
|
||||
query = encodeURIComponent(query);
|
||||
|
||||
// Ensure sources is an array
|
||||
const sourcesArray = Array.isArray(sources) ? sources : [sources];
|
||||
|
||||
const params = {
|
||||
query: query,
|
||||
sources: sourcesArray.join(",")
|
||||
sources: encodeURIComponent(sourcesArray.join(","))
|
||||
};
|
||||
|
||||
// Only include scope if searching a single source
|
||||
@@ -18,7 +20,7 @@ export default async function search(base, sources, query, largest = false) {
|
||||
if (!base.endsWith("/")) {
|
||||
base += "/";
|
||||
}
|
||||
params.scope = base;
|
||||
params.scope = encodeURIComponent(base);
|
||||
}
|
||||
|
||||
if (largest) {
|
||||
@@ -42,8 +44,8 @@ export async function findDuplicates(base, source, minSizeMb, useChecksum = fals
|
||||
base += "/";
|
||||
}
|
||||
const params = {
|
||||
scope: base,
|
||||
source: source,
|
||||
scope: encodeURIComponent(base),
|
||||
source: encodeURIComponent(source),
|
||||
minSizeMb: minSizeMb.toString()
|
||||
};
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function list() {
|
||||
*/
|
||||
export async function get(path, source) {
|
||||
try {
|
||||
const params = { path, source };
|
||||
const params = { path: encodeURIComponent(path), source: encodeURIComponent(source) };
|
||||
const apiPath = getApiPath("api/share", params);
|
||||
let data = await fetchJSON(apiPath);
|
||||
return adjustedData(data);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="context-menu-header">
|
||||
<div
|
||||
class="action button clickable"
|
||||
v-if="showCreateButton"
|
||||
v-if="!isSearchActive && permissions.create && !isShare"
|
||||
@click="toggleShowCreate"
|
||||
>
|
||||
<i v-if="!showCreate" class="material-icons">add</i>
|
||||
@@ -32,77 +32,71 @@
|
||||
<span>{{ selectedCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<hr v-if="!isDuplicateFinder" class="divider">
|
||||
<hr class="divider">
|
||||
<action
|
||||
v-if="showCreateActions"
|
||||
v-if="showCreate && !isSearchActive"
|
||||
icon="create_new_folder"
|
||||
:label="$t('files.newFolder')"
|
||||
@action="showHover('newDir')"
|
||||
/>
|
||||
<action
|
||||
v-if="showCreateActions"
|
||||
v-if="showCreate && !isSearchActive"
|
||||
icon="note_add"
|
||||
:label="$t('files.newFile')"
|
||||
@action="showHover('newFile')"
|
||||
/>
|
||||
<action
|
||||
v-if="showCreateActions"
|
||||
v-if="showCreate && !isSearchActive"
|
||||
icon="file_upload"
|
||||
:label="$t('general.upload')"
|
||||
@action="uploadFunc"
|
||||
/>
|
||||
<action
|
||||
v-if="showInfo"
|
||||
v-if="!showCreate && selectedCount == 1"
|
||||
icon="info"
|
||||
:label="$t('general.info')"
|
||||
@action="showInfoHover"
|
||||
show="info"
|
||||
/>
|
||||
|
||||
<action
|
||||
v-if="showDownload"
|
||||
v-if="(!showCreate && permissions.download && selectedCount > 0)"
|
||||
icon="file_download"
|
||||
:label="$t('general.download')"
|
||||
@action="startDownload"
|
||||
:counter="selectedCount"
|
||||
/>
|
||||
<action
|
||||
v-if="showShareAction"
|
||||
v-if="(showCreate || selectedCount == 1) && showShare"
|
||||
icon="share"
|
||||
:label="$t('general.share')"
|
||||
@action="showShareHover"
|
||||
/>
|
||||
<action
|
||||
v-if="showRename"
|
||||
v-if="!showCreate && selectedCount == 1 && permissions.modify && !isSearchActive"
|
||||
icon="mode_edit"
|
||||
:label="$t('general.rename')"
|
||||
@action="showRenameHover"
|
||||
/>
|
||||
<action
|
||||
v-if="showCopy"
|
||||
v-if="!showCreate && selectedCount > 0 && permissions.modify"
|
||||
icon="content_copy"
|
||||
:label="$t('buttons.copyFile')"
|
||||
show="copy"
|
||||
/>
|
||||
<action
|
||||
v-if="showOpenParentFolder"
|
||||
v-if="!showCreate && selectedCount == 1 && isSearchActive"
|
||||
icon="folder"
|
||||
:label="$t('buttons.openParentFolder')"
|
||||
@action="openParentFolder"
|
||||
/>
|
||||
<action
|
||||
v-if="showGoToItem"
|
||||
icon="folder"
|
||||
:label="$t('buttons.goToItem')"
|
||||
@action="goToItem"
|
||||
/>
|
||||
<action
|
||||
v-if="showMove"
|
||||
v-if="!showCreate && selectedCount > 0 && permissions.modify"
|
||||
icon="forward"
|
||||
:label="$t('buttons.moveFile')"
|
||||
show="move"
|
||||
/>
|
||||
<action
|
||||
v-if="showSelectAll"
|
||||
v-if="!showCreate && !isSearchActive && req?.items?.length > 0"
|
||||
icon="select_all"
|
||||
:label="$t('buttons.selectAll')"
|
||||
@action="selectAllItems"
|
||||
@@ -144,7 +138,7 @@
|
||||
class="button no-select fb-shadow"
|
||||
:class="{ 'dark-mode': isDarkMode }"
|
||||
>
|
||||
<action icon="info" :label="$t('general.info')" @action="showInfoHover"/>
|
||||
<action icon="info" :label="$t('general.info')" show="info" />
|
||||
<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" />
|
||||
@@ -187,12 +181,6 @@ export default {
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
showGoToItem() {
|
||||
return this.isDuplicateFinder && this.selectedCount == 1;
|
||||
},
|
||||
isDuplicateFinder() {
|
||||
return getters.currentView() === "duplicateFinder";
|
||||
},
|
||||
permissions() {
|
||||
return getters.permissions();
|
||||
},
|
||||
@@ -202,47 +190,7 @@ 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;
|
||||
}
|
||||
@@ -269,7 +217,6 @@ export default {
|
||||
return cv == "markdownViewer" && this.permissions.modify;
|
||||
},
|
||||
showDelete() {
|
||||
if (this.isDuplicateFinder) return false;
|
||||
if (this.selectedCount == 0) {
|
||||
return false;
|
||||
}
|
||||
@@ -299,7 +246,6 @@ export default {
|
||||
return getters.currentPromptName() == "OverflowMenu";
|
||||
},
|
||||
showAccess() {
|
||||
if (this.isDuplicateFinder) return false;
|
||||
if (getters.isShare()) {
|
||||
return false;
|
||||
}
|
||||
@@ -370,18 +316,6 @@ 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();
|
||||
},
|
||||
@@ -591,9 +525,12 @@ export default {
|
||||
});
|
||||
},
|
||||
openParentFolder() {
|
||||
const item = getters.getFirstSelected();
|
||||
const parentPath = url.removeLastDir(item.path) || "/";
|
||||
url.goToItem(item.source, parentPath, {}, this.isDuplicateFinder);
|
||||
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, {});
|
||||
},
|
||||
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: 768px) {
|
||||
@media (max-width: 800px) {
|
||||
#search {
|
||||
min-width: unset;
|
||||
max-width: 60%;
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
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" />
|
||||
@@ -78,14 +77,6 @@ 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(() => {
|
||||
@@ -204,7 +195,7 @@ export default {
|
||||
z-index: 1000;
|
||||
right: 0;
|
||||
left: 0;
|
||||
transition: 0.2s ease;
|
||||
transition: 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
@@ -213,7 +204,7 @@ export default {
|
||||
|
||||
.shelf-slide-enter-active,
|
||||
.shelf-slide-leave-active {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease !important;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.shelf-slide-enter-from,
|
||||
@@ -236,4 +227,7 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
#main.moveWithSidebar #shelf {
|
||||
padding-left: 20.5em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div id="status-bar" :style="moveWithSidebar" :class="{ 'dark-mode-header': isDarkMode, 'active': showStatusBar }" @contextmenu.prevent.stop @touchstart.stop @touchend.stop>
|
||||
<div id="status-bar" :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,9 +42,6 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
showStatusBar() {
|
||||
if (getters.isShare() && state.shareInfo.shareType === "upload") {
|
||||
return false;
|
||||
}
|
||||
return getters.currentView() === "listingView";
|
||||
},
|
||||
isDarkMode() {
|
||||
@@ -111,14 +108,6 @@ 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) {
|
||||
@@ -178,7 +167,7 @@ export default {
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transition: bottom 0.5s ease, left 0.2s ease, width 0.2s ease;
|
||||
transition: bottom 0.5s ease, margin-left 0.5s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -187,6 +176,10 @@ export default {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#status-bar.moveWithSidebar {
|
||||
margin-left: 20em;
|
||||
}
|
||||
|
||||
.status-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -245,7 +238,7 @@ input[type="range"] {
|
||||
}
|
||||
|
||||
/* Mobile styles */
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 800px) {
|
||||
#status-bar {
|
||||
height: 3em;
|
||||
bottom: -3em;
|
||||
@@ -268,6 +261,7 @@ input[type="range"] {
|
||||
|
||||
input[type="range"] {
|
||||
width: 7em;
|
||||
height: 1.5em; /* For better touch */
|
||||
}
|
||||
|
||||
.status-info {
|
||||
|
||||
@@ -190,6 +190,14 @@ 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 "/";
|
||||
@@ -201,7 +209,7 @@ export default {
|
||||
|
||||
if (normalizedTarget === normalizedCurrent) {
|
||||
notify.showErrorToast(this.$t("files.sameFolder"));
|
||||
console.error("Cannot move to same folder");
|
||||
console.log("Cannot move to same folder");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,62 +1,35 @@
|
||||
<template>
|
||||
<div class="image-ex-container" ref="container" @touchstart="touchStart" @touchmove="touchMove" @touchend="touchEnd" @dblclick="zoomAuto"
|
||||
@mousedown="mousedownStart" @mousemove="mouseMove" @mouseup="mouseUp" @wheel="wheelMove">
|
||||
<!-- 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>
|
||||
<div v-if="!isLoaded">{{ $t('general.loading', { suffix: "..." }) }}</div>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { state, mutations, getters } from "@/store";
|
||||
import { state, mutations } 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: {
|
||||
type: String,
|
||||
default: null,
|
||||
src: String,
|
||||
moveDisabledTime: {
|
||||
type: Number,
|
||||
default: () => 200,
|
||||
},
|
||||
classList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
zoomStep: {
|
||||
type: Number,
|
||||
default: () => 0.25,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
moveDisabledTime: 200,
|
||||
classList: [],
|
||||
zoomStep: 0.25,
|
||||
scale: 1,
|
||||
lastX: null,
|
||||
lastY: null,
|
||||
@@ -66,15 +39,13 @@ 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,
|
||||
isTiff: false,
|
||||
minScale: 1, // Minimum scale is 1 (full frame view)
|
||||
isTiff: false, // Determine if the image is a TIFF
|
||||
// Swipe navigation properties
|
||||
swipeStartTime: null,
|
||||
swipeStartX: 0,
|
||||
@@ -83,55 +54,19 @@ export default {
|
||||
swipeCurrentY: 0,
|
||||
isSwipeGesture: false,
|
||||
hasStartedSwipe: false,
|
||||
gestureDecided: false,
|
||||
swipeMinDistance: 150,
|
||||
swipeMaxTime: 500,
|
||||
swipeMaxVerticalDistance: 50,
|
||||
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
|
||||
};
|
||||
},
|
||||
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 {
|
||||
// Use nextTick to ensure element exists
|
||||
this.$nextTick(() => {
|
||||
this.loadFullImage();
|
||||
});
|
||||
this.$refs.imgex.src = this.src;
|
||||
}
|
||||
|
||||
let container = this.$refs.container;
|
||||
this.classList.forEach((className) => container.classList.add(className));
|
||||
if (getComputedStyle(container).width === "0px") {
|
||||
@@ -144,61 +79,39 @@ 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.fullImageLoaded = true;
|
||||
// Clear the timeout if image loaded successfully
|
||||
if (this.loadTimeout) {
|
||||
clearTimeout(this.loadTimeout);
|
||||
this.loadTimeout = null;
|
||||
}
|
||||
this.setCenter();
|
||||
this.setCenter(); // Center the image after loading
|
||||
this.showSpinner = false;
|
||||
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();
|
||||
@@ -210,11 +123,13 @@ export default {
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const blob = await response.blob(); // Convert response to a 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);
|
||||
imgex.onload = () => URL.revokeObjectURL(imgex.src); // Clean up URL object after loading
|
||||
}
|
||||
} catch (error) {
|
||||
notify.showError("Error decoding TIFF");
|
||||
@@ -232,20 +147,16 @@ export default {
|
||||
setCenter() {
|
||||
const container = this.$refs.container;
|
||||
const img = this.$refs.imgex;
|
||||
|
||||
if (!container || !img || !img.clientWidth || !img.clientHeight) {
|
||||
return;
|
||||
return; // Exit if dimensions are unavailable
|
||||
}
|
||||
// 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);
|
||||
// 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})`;
|
||||
}
|
||||
|
||||
img.style.left = `${this.position.center.x}px`;
|
||||
img.style.top = `${this.position.center.y}px`;
|
||||
},
|
||||
mousedownStart(event) {
|
||||
this.lastX = null;
|
||||
@@ -266,6 +177,8 @@ 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();
|
||||
@@ -275,10 +188,13 @@ export default {
|
||||
this.swipeCurrentY = touch.pageY;
|
||||
this.isSwipeGesture = false;
|
||||
this.hasStartedSwipe = false;
|
||||
this.gestureDecided = false;
|
||||
this.gestureDecided = false; // Reset decision for new touch
|
||||
|
||||
} else {
|
||||
// Reset swipe tracking for multi-touch (zoom gestures) or when zoomed in
|
||||
this.resetSwipeTracking();
|
||||
}
|
||||
|
||||
if (event.targetTouches.length < 2) {
|
||||
setTimeout(() => {
|
||||
this.touches = 0;
|
||||
@@ -289,6 +205,9 @@ 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();
|
||||
}
|
||||
@@ -311,31 +230,45 @@ 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;
|
||||
if (deltaX > deltaY * 2) {
|
||||
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.isSwipeGesture = true;
|
||||
event.preventDefault();
|
||||
event.preventDefault(); // Prevent scrolling during swipe
|
||||
} 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;
|
||||
return; // Block normal pan behavior for swipes
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -349,6 +282,7 @@ export default {
|
||||
() => (this.moveDisabled = false),
|
||||
this.moveDisabledTime
|
||||
);
|
||||
|
||||
let p1 = event.targetTouches[0];
|
||||
let p2 = event.targetTouches[1];
|
||||
let touchDistance = Math.sqrt(
|
||||
@@ -362,6 +296,7 @@ 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;
|
||||
@@ -374,11 +309,8 @@ export default {
|
||||
doMove(x, y) {
|
||||
this.position.relative.x += x;
|
||||
this.position.relative.y += y;
|
||||
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})`;
|
||||
}
|
||||
// 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})`;
|
||||
},
|
||||
wheelMove(event) {
|
||||
event.preventDefault()
|
||||
@@ -387,26 +319,30 @@ 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 };
|
||||
}
|
||||
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})`;
|
||||
}
|
||||
|
||||
// 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})`;
|
||||
},
|
||||
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 &&
|
||||
@@ -421,9 +357,13 @@ 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() {
|
||||
@@ -434,49 +374,7 @@ export default {
|
||||
this.swipeCurrentY = 0;
|
||||
this.isSwipeGesture = false;
|
||||
this.hasStartedSwipe = false;
|
||||
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();
|
||||
this.gestureDecided = false; // Reset decision state
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -485,31 +383,22 @@ 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,11 +33,10 @@
|
||||
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 + "public/static/img/placeholder.png"; // A generic loading placeholder
|
||||
const ERROR_URL = globalVars.baseURL + "public/static/img/placeholder.png";
|
||||
const PLACEHOLDER_URL = globalVars.baseURL + "static/img/placeholder.png"; // A generic loading placeholder
|
||||
const ERROR_URL = globalVars.baseURL + "static/img/placeholder.png";
|
||||
|
||||
export default {
|
||||
name: "Icon",
|
||||
@@ -61,18 +60,6 @@ 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,
|
||||
@@ -201,15 +188,6 @@ 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -223,19 +201,9 @@ 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;
|
||||
@@ -250,7 +218,7 @@ export default {
|
||||
let index = 0;
|
||||
|
||||
const updateThumbnail = () => {
|
||||
if (!state.popupPreviewSourceInfo || state.popupPreviewSourceInfo.path !== this.path) {
|
||||
if (state.popupPreviewSource === "") {
|
||||
this.previewTimeouts.forEach(clearTimeout);
|
||||
this.previewTimeouts = [];
|
||||
return;
|
||||
@@ -265,17 +233,6 @@ 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
|
||||
@@ -290,8 +247,6 @@ 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();
|
||||
},
|
||||
@@ -300,13 +255,9 @@ export default {
|
||||
},
|
||||
updateImageTargetSrc() {
|
||||
let newSrc = this.thumbnailUrl || 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.showLargeIcon) {
|
||||
newSrc = (this.thumbnailUrl ? this.thumbnailUrl + "&size=large" : "") || PLACEHOLDER_URL;
|
||||
}
|
||||
|
||||
if (this.imageTargetSrc !== newSrc) {
|
||||
this.imageTargetSrc = newSrc;
|
||||
}
|
||||
@@ -361,11 +312,6 @@ export default {
|
||||
this.svgPath = result.svgPath || "";
|
||||
this.updateImageTargetSrc();
|
||||
},
|
||||
beforeUnmount() {
|
||||
// Clean up any pending animation timeouts
|
||||
this.previewTimeouts.forEach(clearTimeout);
|
||||
this.previewTimeouts = [];
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -38,9 +38,6 @@
|
||||
:thumbnailUrl="isThumbnailInView ? thumbnailUrl : ''"
|
||||
:filename="name"
|
||||
:hasPreview="hasPreview"
|
||||
:modified="modified"
|
||||
:path="path"
|
||||
:source="source"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -49,14 +46,14 @@
|
||||
<p
|
||||
class="size"
|
||||
:class="{ adjustment: quickDownloadEnabled }"
|
||||
:data-order="humanSize"
|
||||
:data-order="humanSize()"
|
||||
>
|
||||
{{ humanSize }}
|
||||
{{ humanSize() }}
|
||||
</p>
|
||||
<p class="modified">
|
||||
<time :datetime="modified">{{ formattedTime }}</time>
|
||||
<time :datetime="modified">{{ getTime() }}</time>
|
||||
</p>
|
||||
<p v-if="hasDuration" class="duration">{{ formattedDuration }}</p>
|
||||
<p v-if="hasDuration" class="duration">{{ getDuration() }}</p>
|
||||
</div>
|
||||
<Icon
|
||||
@click.stop="downloadFile"
|
||||
@@ -104,9 +101,6 @@
|
||||
:thumbnailUrl="isThumbnailInView ? thumbnailUrl : ''"
|
||||
:filename="name"
|
||||
:hasPreview="hasPreview"
|
||||
:modified="modified"
|
||||
:path="path"
|
||||
:source="source"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -115,14 +109,14 @@
|
||||
<p
|
||||
class="size"
|
||||
:class="{ adjustment: quickDownloadEnabled }"
|
||||
:data-order="humanSize"
|
||||
:data-order="humanSize()"
|
||||
>
|
||||
{{ humanSize }}
|
||||
{{ humanSize() }}
|
||||
</p>
|
||||
<p class="modified">
|
||||
<time :datetime="modified">{{ formattedTime }}</time>
|
||||
<time :datetime="modified">{{ getTime() }}</time>
|
||||
</p>
|
||||
<p v-if="hasDuration" class="duration">{{ formattedDuration }}</p>
|
||||
<p v-if="hasDuration" class="duration">{{ getDuration() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -147,7 +141,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
isThumbnailInView: false,
|
||||
isInView: false,
|
||||
isInView: false, // Track if item is in viewport
|
||||
touches: 0,
|
||||
touchStartX: 0,
|
||||
touchStartY: 0,
|
||||
@@ -155,8 +149,8 @@ export default {
|
||||
isSwipe: false,
|
||||
isDraggedOver: false,
|
||||
contextTimeout: null,
|
||||
observer: null,
|
||||
localSelected: false,
|
||||
observer: null, // Store observer reference
|
||||
localSelected: false, // Track local selection when updateGlobalState is false
|
||||
};
|
||||
},
|
||||
props: {
|
||||
@@ -274,7 +268,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 "";
|
||||
@@ -283,7 +277,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()) {
|
||||
@@ -298,50 +292,25 @@ 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 for lazy-loading thumbnails
|
||||
// Set up IntersectionObserver with larger margins for smoother rendering
|
||||
this.observer = new IntersectionObserver(this.handleIntersect, {
|
||||
root: null,
|
||||
rootMargin: "500px", // Reduced from 1500px for better performance
|
||||
rootMargin: "1500px", // Start rendering 1500px before entering viewport for smooth scrolling
|
||||
threshold: 0,
|
||||
});
|
||||
|
||||
// Use $nextTick to ensure $el is available and is an Element
|
||||
this.observer.observe(this.$el);
|
||||
this.$nextTick(() => {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
});
|
||||
// Note: dragend listener moved to parent ListingView for better performance
|
||||
document.addEventListener('dragend', this.dragEnd);
|
||||
},
|
||||
beforeUnmount() {
|
||||
// Clean up observer
|
||||
@@ -349,7 +318,7 @@ export default {
|
||||
this.observer.disconnect();
|
||||
this.observer = null;
|
||||
}
|
||||
// Note: dragend listener removed - handled by parent ListingView
|
||||
document.removeEventListener('dragend', this.dragEnd);
|
||||
},
|
||||
methods: {
|
||||
/** @param {MouseEvent} event */
|
||||
@@ -419,22 +388,8 @@ export default {
|
||||
mutations.addSelected(this.index);
|
||||
}
|
||||
} else {
|
||||
|
||||
// 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);
|
||||
// Emit selection event for local handling
|
||||
this.$emit('select', { index: this.index, selected: true });
|
||||
}
|
||||
mutations.showHover({
|
||||
name: "ContextMenu",
|
||||
@@ -457,6 +412,28 @@ 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,12 +1,11 @@
|
||||
<template>
|
||||
<div class="popup-preview" v-show="sourceInfo" ref="popup" :style="popupStyle">
|
||||
<img v-if="sourceInfo" :src="sourceInfo.url" alt="Popup image" @load="onImageLoad" />
|
||||
<div class="popup-preview" v-show="source" ref="popup" :style="popupStyle">
|
||||
<img :src="source" alt="Popup image" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { state, getters } from "@/store";
|
||||
import { setImageLoaded } from "@/utils/imageCache";
|
||||
|
||||
export default {
|
||||
name: "PopupPreview",
|
||||
@@ -21,7 +20,7 @@ export default {
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
sourceInfo(newVal) {
|
||||
source(newVal) {
|
||||
if (newVal) {
|
||||
this.$nextTick(() => {
|
||||
this.positionPopup();
|
||||
@@ -30,8 +29,8 @@ export default {
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
sourceInfo() {
|
||||
return state.popupPreviewSourceInfo;
|
||||
source() {
|
||||
return state.popupPreviewSource;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
@@ -41,20 +40,13 @@ 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.sourceInfo) return;
|
||||
if (!this.source) return;
|
||||
const popup = this.$refs.popup;
|
||||
if (!popup) return;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="scroll-wrapper" :class="{ 'halloween-theme': eventTheme === 'halloween' }" :style="halloweenBackgroundStyle" ref="wrapper">
|
||||
<div class="scroll-wrapper" ref="wrapper">
|
||||
<slot />
|
||||
<div
|
||||
class="custom-scrollbar"
|
||||
@@ -35,7 +35,6 @@
|
||||
|
||||
<script>
|
||||
import { state, mutations, getters } from "@/store";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
|
||||
const offsetFromBottomListing = 110;
|
||||
const offsetFromBottomFull = 75;
|
||||
@@ -64,18 +63,6 @@ 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.shareInfo.passwordValid
|
||||
return state.share.passwordValid
|
||||
},
|
||||
shareInfo() {
|
||||
return state.shareInfo;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div
|
||||
v-if="enabled && hasPrevious"
|
||||
class="nav-zone nav-zone-left"
|
||||
:style="leftZoneStyle"
|
||||
:class="{ moveWithSidebar: moveWithSidebar }"
|
||||
@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="previousButtonStyle"
|
||||
:style="dragState.type === 'previous' ? { transform: `translateY(-50%) translate(${dragState.deltaX}px, 0)` } : {}"
|
||||
: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="nextButtonStyle"
|
||||
:style="dragState.type === 'next' ? { transform: `translateY(-50%) translate(${dragState.deltaX}px, 0)` } : {}"
|
||||
:aria-label="$t('general.next')"
|
||||
:title="$t('general.next')"
|
||||
>
|
||||
@@ -108,7 +108,6 @@ export default {
|
||||
tapTimeout: null,
|
||||
triggered: false
|
||||
},
|
||||
isSidebarResizing: false, // Track if sidebar is being resized
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -116,9 +115,6 @@ export default {
|
||||
moveWithSidebar() {
|
||||
return getters.isSidebarVisible() && getters.isStickySidebar();
|
||||
},
|
||||
sidebarWidth() {
|
||||
return state.sidebar?.width || 20;
|
||||
},
|
||||
enabled() {
|
||||
return state.navigation.enabled && getters.currentPrompt() == null;
|
||||
},
|
||||
@@ -161,45 +157,6 @@ 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: {
|
||||
@@ -250,10 +207,6 @@ 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);
|
||||
@@ -383,7 +336,7 @@ export default {
|
||||
try {
|
||||
let res;
|
||||
if (getters.isShare()) {
|
||||
res = await publicApi.fetchPub(directoryPath, state.shareInfo.hash);
|
||||
res = await publicApi.fetchPub(directoryPath, state.share.hash);
|
||||
} else {
|
||||
res = await filesApi.fetchFiles(state.req.source, directoryPath);
|
||||
}
|
||||
@@ -642,16 +595,14 @@ export default {
|
||||
}
|
||||
},
|
||||
isClickInLeftZone(event) {
|
||||
const emSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const zoneWidth = 3 * emSize; // 3em in pixels
|
||||
const sidebarOffset = this.moveWithSidebar ? (this.sidebarWidth * emSize) : 0;
|
||||
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
|
||||
|
||||
return event.clientX >= sidebarOffset && event.clientX <= (sidebarOffset + zoneWidth);
|
||||
},
|
||||
isClickInRightZone(event) {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const emSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const zoneWidth = 3 * emSize; // 3em in pixels
|
||||
const zoneWidth = 3 * parseFloat(getComputedStyle(document.documentElement).fontSize); // 3em in pixels
|
||||
|
||||
return event.clientX >= (viewportWidth - zoneWidth) && event.clientX <= viewportWidth;
|
||||
},
|
||||
@@ -993,7 +944,7 @@ export default {
|
||||
const zoneWidth = 5 * emSize; // 5em in pixels
|
||||
|
||||
// Check left zone
|
||||
const sidebarOffset = this.moveWithSidebar ? (this.sidebarWidth * emSize) : 0; // Account for sidebar
|
||||
const sidebarOffset = this.moveWithSidebar ? 20 * 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
|
||||
@@ -1031,6 +982,11 @@ export default {
|
||||
background: transparent; /* Invisible zones for mouse/touch detection */
|
||||
}
|
||||
|
||||
|
||||
.nav-zone-left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.nav-zone-right {
|
||||
right: 0;
|
||||
}
|
||||
@@ -1048,7 +1004,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, left 0.2s ease;
|
||||
transition: opacity 0.4s ease, transform 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease;
|
||||
pointer-events: auto;
|
||||
z-index: 1001;
|
||||
display: flex;
|
||||
@@ -1081,9 +1037,13 @@ export default {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 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-previous {
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.nav-next {
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.nav-button.hidden {
|
||||
@@ -1094,7 +1054,7 @@ export default {
|
||||
}
|
||||
|
||||
/* Smooth show animation for better UX */
|
||||
.nav-button:not(.hidden):not(.sidebar-resizing) {
|
||||
.nav-button:not(.hidden) {
|
||||
animation: nav-button-show 0.4s ease-out;
|
||||
}
|
||||
|
||||
@@ -1112,7 +1072,7 @@ export default {
|
||||
.nav-button.dragging {
|
||||
z-index: 1002;
|
||||
cursor: grabbing;
|
||||
transition: none !important; /* Disable transitions during drag */
|
||||
transition: none; /* Disable transitions during drag for immediate response */
|
||||
}
|
||||
|
||||
.nav-button i.material-icons {
|
||||
@@ -1133,6 +1093,14 @@ export default {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.nav-previous {
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.nav-next {
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.nav-button i.material-icons {
|
||||
font-size: 20px;
|
||||
}
|
||||
@@ -1145,8 +1113,15 @@ 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>
|
||||
@@ -17,9 +17,9 @@
|
||||
:name="getItemName(item.path)"
|
||||
:isDir="item.type === 'directory'"
|
||||
:source="item.source"
|
||||
:type="item.type"
|
||||
:size="item.size"
|
||||
:modified="item.modified"
|
||||
:type="item.type || 'file'"
|
||||
:size="item.size || 0"
|
||||
:modified="item.modified || ''"
|
||||
:index="index"
|
||||
:path="item.path"
|
||||
:hasPreview="item.hasPreview"
|
||||
@@ -107,7 +107,6 @@ export default {
|
||||
size: item.size,
|
||||
modified: item.modified,
|
||||
previewUrl: previewUrl,
|
||||
hasPreview: item.hasPreview,
|
||||
});
|
||||
} else if (!this.isListing) {
|
||||
const item = state.req.items[state.selected[0]];
|
||||
@@ -119,7 +118,6 @@ export default {
|
||||
size: item.size,
|
||||
modified: item.modified,
|
||||
previewUrl: previewUrl,
|
||||
hasPreview: item.hasPreview,
|
||||
});
|
||||
} else {
|
||||
for (let index of state.selected) {
|
||||
@@ -132,7 +130,6 @@ export default {
|
||||
size: item.size,
|
||||
modified: item.modified,
|
||||
previewUrl: previewUrl,
|
||||
hasPreview: item.hasPreview,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
:modified="item.originalItem?.modified || new Date().toISOString()"
|
||||
:index="index"
|
||||
:path="item.path"
|
||||
:hasPreview="item.originalItem?.hasPreview && item.type !== 'directory' || false"
|
||||
:hasPreview="item.originalItem?.hasPreview || false"
|
||||
:metadata="item.originalItem?.metadata"
|
||||
:hasDuration="item.originalItem?.hasDuration || false"
|
||||
:updateGlobalState="false"
|
||||
@@ -412,14 +412,13 @@ export default {
|
||||
handleItemClick(item, _index, event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Display mode: single click navigates directly to the file
|
||||
|
||||
if (this.isDisplayMode) {
|
||||
this.navigateToItem(item);
|
||||
return;
|
||||
}
|
||||
|
||||
// Browse mode: single click selects only, emit selected, do not navigate
|
||||
// Create a synthetic event-like object for compatibility with existing methods
|
||||
const syntheticEvent = {
|
||||
currentTarget: {
|
||||
dataset: {
|
||||
@@ -429,17 +428,18 @@ export default {
|
||||
preventDefault: () => {},
|
||||
stopPropagation: () => {},
|
||||
};
|
||||
this.select(syntheticEvent);
|
||||
|
||||
if (state.user.singleClick) {
|
||||
this.next(syntheticEvent);
|
||||
} else {
|
||||
this.select(syntheticEvent);
|
||||
}
|
||||
},
|
||||
handleItemDblClick(item, _index, event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Double click: navigate (into folder or open file)
|
||||
if (this.isDisplayMode) {
|
||||
this.navigateToItem(item);
|
||||
return;
|
||||
}
|
||||
// Create a synthetic event for double-click
|
||||
const syntheticEvent = {
|
||||
currentTarget: {
|
||||
dataset: {
|
||||
@@ -457,12 +457,9 @@ export default {
|
||||
if (this.selected === path) {
|
||||
this.selected = null;
|
||||
this.selectedSource = null;
|
||||
this.selectedType = null;
|
||||
this.$emit("update:selected", {
|
||||
path: this.current,
|
||||
source: this.source,
|
||||
type: 'directory',
|
||||
isValid: !this.requireFileSelection,
|
||||
source: this.source
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -471,12 +468,9 @@ export default {
|
||||
this.selected = path;
|
||||
let clickedItem = this.items.find(item => item.path === path);
|
||||
this.selectedSource = clickedItem ? clickedItem.source : this.source;
|
||||
this.selectedType = clickedItem ? clickedItem.type : null;
|
||||
this.$emit("update:selected", {
|
||||
path: this.selected,
|
||||
source: this.selectedSource,
|
||||
type: this.selectedType,
|
||||
isValid: this.selectedType === 'directory' ? !this.requireFileSelection : true,
|
||||
source: this.selectedSource
|
||||
});
|
||||
},
|
||||
createDir: async function () {
|
||||
@@ -500,7 +494,6 @@ export default {
|
||||
},
|
||||
navigateToItem(item) {
|
||||
mutations.closeHovers();
|
||||
mutations.setNavigationTransitioning(true);
|
||||
url.goToItem(item.source || state.req.source, item.path, undefined);
|
||||
},
|
||||
closePrompt() {
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
</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">
|
||||
@@ -20,15 +24,15 @@
|
||||
<strong>{{ $t("prompts.typeName") }}</strong>
|
||||
<span aria-label="info type">{{ type }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="humanTime">
|
||||
<div class="info-item" v-if="selected.length < 2 && humanTime">
|
||||
<strong>{{ $t("prompts.lastModified") }}</strong>
|
||||
<span aria-label="info last modified" :title="modTime">{{ humanTime }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="source">
|
||||
<div class="info-item" v-if="selected.length < 2 && source">
|
||||
<strong>{{ $t("general.source") }}</strong>
|
||||
<span aria-label="info source">{{ source }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="filePath">
|
||||
<div class="info-item" v-if="selected.length < 2 && filePath">
|
||||
<strong>{{ $t("general.path") }}</strong>
|
||||
<span aria-label="info path" class="break-word">{{ filePath }}</span>
|
||||
</div>
|
||||
@@ -41,6 +45,18 @@
|
||||
<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">
|
||||
@@ -97,17 +113,11 @@
|
||||
import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||
import { formatTimestamp } from "@/utils/moment";
|
||||
import { filesApi } from "@/api";
|
||||
import { state, mutations } from "@/store";
|
||||
import { state, getters, mutations } from "@/store";
|
||||
import { notify } from "@/notify";
|
||||
|
||||
export default {
|
||||
name: "info",
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedHashAlgo: "md5",
|
||||
@@ -119,52 +129,145 @@ export default {
|
||||
closeHovers() {
|
||||
return mutations.closeHovers;
|
||||
},
|
||||
req() {
|
||||
return state.req;
|
||||
},
|
||||
selected() {
|
||||
return state.selected;
|
||||
},
|
||||
selectedCount() {
|
||||
return getters.selectedCount();
|
||||
},
|
||||
isListing() {
|
||||
return getters.isListing();
|
||||
},
|
||||
humanSize() {
|
||||
return getHumanReadableFilesize(this.item?.size || 0);
|
||||
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);
|
||||
},
|
||||
humanTime() {
|
||||
if (!this.item?.modified) return "";
|
||||
return formatTimestamp(this.item.modified, state.user.locale);
|
||||
if (state.isSearchActive) {
|
||||
return "";
|
||||
}
|
||||
const modifiedDate = getters.selectedCount() === 0
|
||||
? state.req.modified
|
||||
: getters.getFirstSelected()?.modified;
|
||||
|
||||
if (!modifiedDate) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return formatTimestamp(modifiedDate, state.user.locale);
|
||||
},
|
||||
modTime() {
|
||||
if (!this.item?.modified) return "";
|
||||
return new Date(Date.parse(this.item.modified)).toLocaleString();
|
||||
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();
|
||||
},
|
||||
name() {
|
||||
return this.item?.name || "";
|
||||
if (state.isSearchActive) {
|
||||
return state.selected[0].name;
|
||||
}
|
||||
return getters.selectedCount() === 0
|
||||
? state.req.name
|
||||
: getters.getFirstSelected().name;
|
||||
},
|
||||
type() {
|
||||
return this.item?.type || "";
|
||||
if (state.isSearchActive) {
|
||||
return state.selected[0].type;
|
||||
}
|
||||
return getters.selectedCount() === 0
|
||||
? state.req.type
|
||||
: getters.getFirstSelected().type;
|
||||
},
|
||||
displayName() {
|
||||
return this.item?.name || "";
|
||||
if (this.selected.length > 1) {
|
||||
return this.$t("prompts.fileInfo");
|
||||
}
|
||||
return this.name;
|
||||
},
|
||||
dir() {
|
||||
return this.item?.type === "directory";
|
||||
if (state.isSearchActive) {
|
||||
return state.selected[0].type === "directory";
|
||||
}
|
||||
return (
|
||||
getters.selectedCount() > 1 ||
|
||||
(getters.selectedCount() === 0
|
||||
? state.req.type == "directory"
|
||||
: getters.getFirstSelected().type == "directory")
|
||||
);
|
||||
},
|
||||
source() {
|
||||
return this.item?.source || "";
|
||||
if (state.isSearchActive) {
|
||||
return state.selected[0].source;
|
||||
}
|
||||
const currentSource = state.sources.current;
|
||||
if (!currentSource || currentSource === "") {
|
||||
return "";
|
||||
}
|
||||
return currentSource;
|
||||
},
|
||||
filePath() {
|
||||
return this.item?.path || "";
|
||||
if (state.isSearchActive) {
|
||||
return state.selected[0].path;
|
||||
}
|
||||
if (getters.selectedCount() === 0) {
|
||||
return state.route.path;
|
||||
}
|
||||
return getters.getFirstSelected()?.path || "";
|
||||
},
|
||||
hidden() {
|
||||
return this.item?.hidden;
|
||||
if (state.isSearchActive) {
|
||||
return state.selected[0].hidden;
|
||||
}
|
||||
if (getters.selectedCount() === 0) {
|
||||
return state.req.hidden;
|
||||
}
|
||||
return getters.getFirstSelected()?.hidden;
|
||||
},
|
||||
hasPreview() {
|
||||
return this.item?.hasPreview;
|
||||
if (state.isSearchActive) {
|
||||
return state.selected[0].hasPreview;
|
||||
}
|
||||
if (getters.selectedCount() === 0) {
|
||||
return state.req.hasPreview;
|
||||
}
|
||||
return getters.getFirstSelected()?.hasPreview;
|
||||
},
|
||||
additionalInfo() {
|
||||
const info = [];
|
||||
|
||||
if (this.item?.token) {
|
||||
info.push({ key: "token", label: this.$t("prompts.token"), value: this.item.token });
|
||||
|
||||
// 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?.hash) {
|
||||
info.push({ key: "hash", label: this.$t("general.hash"), value: this.item.hash });
|
||||
if (state.req.hash) {
|
||||
info.push({ key: "hash", label: this.$t("general.hash"), value: state.req.hash });
|
||||
}
|
||||
if (this.item?.onlyOfficeId) {
|
||||
info.push({ key: "onlyOfficeId", label: this.$t("prompts.onlyOfficeId"), value: this.item.onlyOfficeId });
|
||||
if (state.req.onlyOfficeId) {
|
||||
info.push({ key: "onlyOfficeId", label: this.$t("prompts.onlyOfficeId"), value: state.req.onlyOfficeId });
|
||||
}
|
||||
|
||||
return info;
|
||||
@@ -172,21 +275,30 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async generateHash() {
|
||||
if (this.generatingHash || !this.item) return;
|
||||
if (this.generatingHash) return;
|
||||
|
||||
this.hashResult = "";
|
||||
this.generatingHash = true;
|
||||
|
||||
try {
|
||||
const source = this.item.source;
|
||||
const path = this.item.path;
|
||||
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 hash = await filesApi.checksum(source, path, this.selectedHashAlgo);
|
||||
this.hashResult = hash;
|
||||
} catch (err) {
|
||||
this.hashResult = this.$t("prompts.errorGeneratingHash");
|
||||
const errorMessage = err instanceof Error ? err.message : "Error generating hash";
|
||||
notify.showError(errorMessage);
|
||||
notify.showError(err.message || "Error generating hash");
|
||||
} finally {
|
||||
this.generatingHash = false;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<div v-if="showWrongCredentials" class="form-invalid">
|
||||
<div v-if="showWrongCredentials" class="share__wrong__password">
|
||||
{{ $t("login.wrongCredentials") }}
|
||||
</div>
|
||||
<div class="form-flex-group">
|
||||
@@ -65,7 +65,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wrong__password {
|
||||
.share__wrong__password {
|
||||
color: #ff4757;
|
||||
text-align: center;
|
||||
padding: 1em 0;
|
||||
|
||||