Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09713b32a5 | ||
|
|
52e3c5c142 | ||
|
|
2af75311bd | ||
|
|
82c0182c22 | ||
|
|
c8f0b5abea | ||
|
|
d3d029d1e9 | ||
|
|
f39002f375 | ||
|
|
a658f100c0 | ||
|
|
c2d255a25e | ||
|
|
2eb352c2d3 | ||
|
|
43bb4a36b4 | ||
|
|
c67abefc7b | ||
|
|
2ecb9036aa | ||
|
|
dbcfba993b | ||
|
|
ed22ce9421 | ||
|
|
8caa6d6a60 | ||
|
|
fb1af87048 | ||
|
+4 |
f73ca51f94 | ||
|
+4 |
ad839f92ac | ||
|
|
eb47df7172 |
No files matched your search
@@ -2,6 +2,136 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
|
||||
|
||||
## v1.2.2-stable
|
||||
|
||||
**Security**:
|
||||
- Patched Stored XSS in public share page via unsanitized share metadata (text/template misuse)
|
||||
- Patched Incomplete Remediation of CVE-2026-27611: Password-Protected Share Bypass via /public/api/share/info GHSA-525j-95gf-766f
|
||||
|
||||
**New Features**:
|
||||
- "Divider" option in sidebar links to add a text or divider between links (#1875)
|
||||
- shares offer a "go to source Location" sidebar link and button when editing a share.
|
||||
|
||||
**Notes**:
|
||||
- Share edit/delete permissions are scoped to the user's shares rather than global (#2050)
|
||||
- OIDC group claims accepted as map (#2084)
|
||||
|
||||
**BugFixes**:
|
||||
- fixed the requirement that the database path needed to be set in the config file, now it loads `FILEBROWSER_DATABASE` value by default, fallback to config file property.
|
||||
- Error downloading zipped directory: no such file or directory (users with scope) #2015
|
||||
|
||||
## v1.2.1-stable
|
||||
|
||||
**BugFixes**:
|
||||
- startup icon generation order issue (#2019)
|
||||
- login button on shares not showing (#2028)
|
||||
- removed file watcher link in context menu for shares (#2025)
|
||||
- Direct download link for single-file shares generates incorrect URL (#2026)
|
||||
- Deleting files causes error V1.2.0 #2039
|
||||
|
||||
## v1.2.7-beta
|
||||
|
||||
**BugFixes**:
|
||||
- head > title > infinitely duplicating titles (#2016)
|
||||
- Proxy auth not working with Nginx, stuck at logo (#2013)
|
||||
|
||||
## v1.2.6-beta
|
||||
|
||||
**Security**:
|
||||
- resolves 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
|
||||
|
||||
**New Features**:
|
||||
- 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)
|
||||
- updated download api to use repeated `file` query param for path instead of `files` with comma. See swagger for details (#1881)
|
||||
|
||||
**BugFixes**:
|
||||
- OnlyOffice stopped working after 1.2.0 upgrade due to download api changes
|
||||
- Search result link to wrong path when search context other than / (#1863)
|
||||
- File actions doesn't work if using user scopes (#1879)
|
||||
- Drop items into folders doesn't work (#1860)
|
||||
- duplicate finder context menu (#1862)
|
||||
- File list in previews doesn't show the parent items (#1861)
|
||||
|
||||
## v1.2.0-beta
|
||||
|
||||
This is a major version update with many code changes which could cause unexpected behavior. Upgrades should proceed with caution and report any undesriable behavior by opening an issue on github. The index is now fully in sqlite database - see [Indexing Overview](https://filebrowserquantum.com/en/docs/features/indexing/) for more information.
|
||||
|
||||
@@ -104,12 +104,12 @@ Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
webdav support | :warning:* | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Docker image size | 180 MB (with ffmpeg) | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
|
||||
Min. Memory Requirements | 256 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
|
||||
Min. Memory Requirements | 512 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
|
||||
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
|
||||
price | free | free | free | free tier | free tier | $99+ |
|
||||
rich media preview | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
@@ -146,3 +146,5 @@ Can browse archive files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can convert documents | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
|
||||
Can convert videos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
Can convert photos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
*Accessing files from FileBrowser via WebDav is a newly introduced and experimental feature.
|
||||
@@ -6,7 +6,6 @@ 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}'" \
|
||||
@@ -27,6 +26,7 @@ FROM alpine:latest
|
||||
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
|
||||
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
|
||||
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
|
||||
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
|
||||
ENV PATH="$PATH:/home/filebrowser"
|
||||
RUN apk --no-cache add ca-certificates mailcap tzdata curl exiftool
|
||||
RUN adduser -D -s /bin/true -u 1000 filebrowser
|
||||
|
||||
@@ -4,7 +4,6 @@ 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}'" \
|
||||
@@ -23,6 +22,7 @@ RUN npm run build:docker
|
||||
|
||||
FROM alpine:latest
|
||||
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
|
||||
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
|
||||
ENV PATH="$PATH:/home/filebrowser"
|
||||
RUN apk --no-cache add ca-certificates mailcap tzdata curl
|
||||
RUN adduser -D -s /bin/true -u 1000 filebrowser
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// Cache interface for caching operations
|
||||
@@ -29,9 +32,61 @@ type FileCache struct {
|
||||
}
|
||||
}
|
||||
|
||||
// NewFileCache creates a new FileCache
|
||||
func NewFileCache(dir string) (*FileCache, error) {
|
||||
return &FileCache{dir: dir}, nil
|
||||
cacheDir := filepath.Join(dir, "diskcache")
|
||||
|
||||
// Migrate existing cache files from old structure (if any)
|
||||
if err := migrateOldCacheStructure(dir, cacheDir); err != nil {
|
||||
logger.Warningf("failed to migrate old cache structure: %v\n", err)
|
||||
}
|
||||
|
||||
// Create the cache directory immediately with proper permissions
|
||||
if err := os.MkdirAll(cacheDir, fileutils.PermDir); err != nil {
|
||||
return nil, fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
return &FileCache{dir: cacheDir}, nil
|
||||
}
|
||||
|
||||
// migrateOldCacheStructure moves cache files from old structure (dir/a/...) to new structure (dir/diskcache/a/...)
|
||||
func migrateOldCacheStructure(oldDir, newDir string) error {
|
||||
// Read the old cache directory
|
||||
entries, err := os.ReadDir(oldDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // Directory doesn't exist yet, nothing to migrate
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
migrated := 0
|
||||
for _, entry := range entries {
|
||||
// Only migrate single-character directories (cache hash directories like a, b, c, etc.)
|
||||
if !entry.IsDir() || len(entry.Name()) != 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip if it's already the diskcache directory
|
||||
if entry.Name() == "d" && filepath.Join(oldDir, entry.Name()) == newDir {
|
||||
continue
|
||||
}
|
||||
|
||||
oldPath := filepath.Join(oldDir, entry.Name())
|
||||
newPath := filepath.Join(newDir, entry.Name())
|
||||
|
||||
// Create parent directory in new location
|
||||
if err := os.MkdirAll(newDir, fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Move the directory
|
||||
if err := os.Rename(oldPath, newPath); err != nil {
|
||||
// If rename fails (e.g., cross-device), skip this one
|
||||
continue
|
||||
}
|
||||
migrated++
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
|
||||
@@ -40,11 +95,11 @@ func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
|
||||
defer mu.Unlock()
|
||||
|
||||
fileName := f.getFileName(key)
|
||||
if err := os.MkdirAll(filepath.Dir(fileName), 0700); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(fileName), fileutils.PermDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(fileName, value, 0600); err != nil {
|
||||
if err := os.WriteFile(fileName, value, fileutils.PermFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -19,6 +20,9 @@ func TestFileCache(t *testing.T) {
|
||||
cachedFilePath = "a/62/a62f2225bf70bfaccbc7f1ef2a397836717377de"
|
||||
)
|
||||
|
||||
// Set up file permissions before creating cache
|
||||
fileutils.SetFsPermissions(0644, 0755)
|
||||
|
||||
// Create temporary directory for the cache
|
||||
cacheDir, err := os.MkdirTemp("", cacheRoot)
|
||||
require.NoError(t, err)
|
||||
@@ -28,19 +32,20 @@ func TestFileCache(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// store new key
|
||||
// Note: NewFileCache creates a "diskcache" subdirectory, so the actual path includes it
|
||||
err = cache.Store(ctx, key, []byte(value))
|
||||
require.NoError(t, err)
|
||||
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, value)
|
||||
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, value)
|
||||
|
||||
// update existing key
|
||||
err = cache.Store(ctx, key, []byte(newValue))
|
||||
require.NoError(t, err)
|
||||
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, newValue)
|
||||
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, newValue)
|
||||
|
||||
// delete key
|
||||
err = cache.Delete(ctx, key)
|
||||
require.NoError(t, err)
|
||||
exists := fileExists(filepath.Join(cacheDir, cachedFilePath))
|
||||
exists := fileExists(filepath.Join(cacheDir, "diskcache", cachedFilePath))
|
||||
require.False(t, exists)
|
||||
}
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
|
||||
// Initialize the database first (use test helper to avoid permission issues)
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
|
||||
db, _, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
@@ -219,7 +219,7 @@ func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: "test",
|
||||
Path: "/mock/path",
|
||||
}, true) // true for mock mode
|
||||
}, true, false) // true for mock mode, false for isNewDb
|
||||
|
||||
// Get the index and set up mock data
|
||||
idx := indexing.GetIndex("test")
|
||||
@@ -318,7 +318,7 @@ func TestOverrideFileToDirectory(t *testing.T) {
|
||||
|
||||
// Initialize the database first (use test helper to avoid permission issues)
|
||||
if indexing.GetIndexDB() == nil {
|
||||
db, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
|
||||
db, _, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
@@ -329,7 +329,7 @@ func TestOverrideFileToDirectory(t *testing.T) {
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: "test",
|
||||
Path: "/mock/path",
|
||||
}, true) // true for mock mode
|
||||
}, true, false) // true for mock mode, false for isNewDb
|
||||
|
||||
// Get the index and set up mock data
|
||||
idx := indexing.GetIndex("test")
|
||||
|
||||
@@ -2,7 +2,6 @@ package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -11,8 +10,6 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
@@ -39,6 +36,18 @@ 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 {
|
||||
@@ -52,9 +61,6 @@ 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]
|
||||
@@ -88,10 +94,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)
|
||||
@@ -213,17 +219,16 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
}
|
||||
|
||||
// Handle subtitles if requested
|
||||
if opts.ExtractEmbeddedSubtitles {
|
||||
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())
|
||||
}
|
||||
}
|
||||
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...)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -238,23 +243,14 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
} else {
|
||||
// Copy metadata to ExtendedFileInfo
|
||||
info.Metadata = extItem.Metadata
|
||||
info.HasPreview = extItem.Metadata != nil && extItem.Metadata.AlbumArt != ""
|
||||
info.HasMetadata = true
|
||||
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
|
||||
}
|
||||
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)
|
||||
@@ -335,11 +331,11 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract album art and encode as base64 with strict size limits
|
||||
// Extract album art if available (stored as raw bytes, auto-encoded to base64 in JSON)
|
||||
if picture := m.Picture(); picture != nil && picture.Data != nil {
|
||||
// More aggressive size limit to prevent memory issues (max 5MB)
|
||||
// Size limit to prevent memory issues (max 5MB)
|
||||
if len(picture.Data) <= 5*1024*1024 {
|
||||
item.Metadata.AlbumArt = base64.StdEncoding.EncodeToString(picture.Data)
|
||||
item.Metadata.AlbumArt = picture.Data
|
||||
} else {
|
||||
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, len(picture.Data))
|
||||
}
|
||||
@@ -746,149 +742,30 @@ func WriteFile(source, path string, in io.Reader) error {
|
||||
}
|
||||
|
||||
// getContent reads and returns the file content if it's considered an editable text file.
|
||||
// This is a wrapper around utils.IsTextFile that also returns the file content.
|
||||
func getContent(realPath string) (string, error) {
|
||||
const headerSize = 4096
|
||||
// Thresholds for detecting binary-like content (these can be tuned)
|
||||
const maxNullBytesInHeaderAbs = 10 // Max absolute null bytes in header
|
||||
const maxNullByteRatioInHeader = 0.1 // Max 10% null bytes in header
|
||||
const maxNullByteRatioInFile = 0.05 // Max 5% null bytes in the entire file
|
||||
const maxNonPrintableRuneRatio = 0.05 // Max 5% non-printable runes in the entire file
|
||||
|
||||
// Open file
|
||||
f, err := os.Open(realPath)
|
||||
// First check if it's a text file using the consolidated validation logic
|
||||
isText, err := utils.IsTextFile(realPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Read header
|
||||
headerBytes := make([]byte, headerSize)
|
||||
n, err := f.Read(headerBytes)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
if !isText {
|
||||
// Not a text file, return empty string (no error, just not text)
|
||||
return "", nil
|
||||
}
|
||||
actualHeader := headerBytes[:n]
|
||||
|
||||
// --- Start of new heuristic checks ---
|
||||
|
||||
if n > 0 {
|
||||
// Trim header to last complete UTF-8 rune to avoid false negatives
|
||||
// when the header read cuts off in the middle of a multi-byte sequence.
|
||||
// We decode runes from the end until we find a valid one, trimming
|
||||
// any incomplete sequences at the end.
|
||||
trimmedHeader := actualHeader
|
||||
for len(trimmedHeader) > 0 {
|
||||
lastRune, size := utf8.DecodeLastRune(trimmedHeader)
|
||||
if lastRune != utf8.RuneError {
|
||||
// Found a valid complete rune
|
||||
break
|
||||
}
|
||||
// RuneError occurred - this could be an incomplete sequence or invalid byte
|
||||
// Trim the last byte and try again
|
||||
if size == 1 && len(trimmedHeader) > 0 {
|
||||
trimmedHeader = trimmedHeader[:len(trimmedHeader)-1]
|
||||
} else {
|
||||
// Shouldn't happen, but break to avoid infinite loop
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Basic Check: Is the header valid UTF-8?
|
||||
// If not, it's unlikely an editable UTF-8 text file.
|
||||
// Use trimmed header to avoid false negatives from truncated sequences
|
||||
if len(trimmedHeader) > 0 && !utf8.Valid(trimmedHeader) {
|
||||
return "", nil // Not an error, just not the text file we want
|
||||
}
|
||||
|
||||
// 2. Check for excessive null bytes in the header
|
||||
nullCountInHeader := 0
|
||||
for _, b := range actualHeader {
|
||||
if b == 0x00 {
|
||||
nullCountInHeader++
|
||||
}
|
||||
}
|
||||
// Reject if too many nulls absolutely or relatively in the header
|
||||
if nullCountInHeader > 0 { // Only perform check if there are any nulls
|
||||
if nullCountInHeader > maxNullBytesInHeaderAbs ||
|
||||
(float64(nullCountInHeader)/float64(n) > maxNullByteRatioInHeader) {
|
||||
return "", nil // Too many nulls in header
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check for other non-text ASCII control characters in the header
|
||||
// (C0 controls excluding \t, \n, \r)
|
||||
for _, b := range actualHeader {
|
||||
if b < 0x20 && b != '\t' && b != '\n' && b != '\r' {
|
||||
return "", nil // Found problematic control character
|
||||
}
|
||||
// C1 control characters (0x80-0x9F) would be caught by utf8.Valid if part of invalid sequences,
|
||||
// or by the non-printable rune check later if they form valid (but undesirable) codepoints.
|
||||
}
|
||||
|
||||
// Optional: Use http.DetectContentType for an additional check on the header
|
||||
// contentType := http.DetectContentType(actualHeader)
|
||||
// if !strings.HasPrefix(contentType, "text/") && contentType != "application/octet-stream" {
|
||||
// // If it's clearly a non-text MIME type (e.g., "image/jpeg"), reject it.
|
||||
// // "application/octet-stream" is ambiguous, so we rely on other heuristics.
|
||||
// return "", nil
|
||||
// }
|
||||
}
|
||||
// --- End of new heuristic checks for header ---
|
||||
|
||||
// Now read the full file (original logic)
|
||||
// It's a text file, read and return the content
|
||||
content, err := os.ReadFile(realPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Handle empty file (original logic - returns specific string)
|
||||
|
||||
// Handle empty file (return specific marker string)
|
||||
if len(content) == 0 {
|
||||
return "empty-file-x6OlSil", nil
|
||||
}
|
||||
|
||||
stringContent := string(content)
|
||||
|
||||
// 4. Final UTF-8 validation for the entire file
|
||||
// (This is crucial as the header might be fine, but the rest of the file isn't)
|
||||
if !utf8.ValidString(stringContent) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// 5. Check for excessive null bytes in the entire file content
|
||||
if len(content) > 0 { // Check only for non-empty files
|
||||
totalNullCount := 0
|
||||
for _, b := range content {
|
||||
if b == 0x00 {
|
||||
totalNullCount++
|
||||
}
|
||||
}
|
||||
if float64(totalNullCount)/float64(len(content)) > maxNullByteRatioInFile {
|
||||
return "", nil // Too many nulls in the entire file
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Check for excessive non-printable runes in the entire file content
|
||||
// (Excluding tab, newline, carriage return, which are common in text files)
|
||||
if len(stringContent) > 0 { // Check only for non-empty strings
|
||||
nonPrintableRuneCount := 0
|
||||
totalRuneCount := 0
|
||||
for _, r := range stringContent {
|
||||
totalRuneCount++
|
||||
// unicode.IsPrint includes letters, numbers, punctuation, symbols, and spaces.
|
||||
// It excludes control characters. We explicitly allow \t, \n, \r.
|
||||
if !unicode.IsPrint(r) && r != '\t' && r != '\n' && r != '\r' {
|
||||
nonPrintableRuneCount++
|
||||
}
|
||||
}
|
||||
|
||||
if totalRuneCount > 0 { // Avoid division by zero
|
||||
if float64(nonPrintableRuneCount)/float64(totalRuneCount) > maxNonPrintableRuneRatio {
|
||||
return "", nil // Too many non-printable runes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The file has passed all checks and is considered editable text.
|
||||
return stringContent, nil
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
func IsNamedPipe(mode os.FileMode) bool {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package fileutils
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
assetFs fs.FS
|
||||
)
|
||||
|
||||
// Custom dirFS to handle both embedded and non-embedded file systems
|
||||
type dirFS struct {
|
||||
http.Dir
|
||||
}
|
||||
|
||||
// Implement the Open method for dirFS, which wraps http.Dir
|
||||
func (d dirFS) Open(name string) (fs.File, error) {
|
||||
return d.Dir.Open(name)
|
||||
}
|
||||
|
||||
// InitAssetFS initializes the asset filesystem for the application
|
||||
// This should be called once during startup before http or preview services start
|
||||
func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool) {
|
||||
if useEmbedded {
|
||||
assetFs = embeddedAssets
|
||||
} else {
|
||||
// Dev mode: Serve files from http/dist directory
|
||||
assetFs = dirFS{Dir: http.Dir("http/dist")}
|
||||
}
|
||||
}
|
||||
|
||||
// GetAssetFS returns the initialized asset filesystem
|
||||
func GetAssetFS() fs.FS {
|
||||
return assetFs
|
||||
}
|
||||
@@ -67,15 +67,17 @@ func GenerateOtpForUser(user *users.User, userStore *users.Storage) (string, err
|
||||
|
||||
secretText := key.Secret()
|
||||
nonce := ""
|
||||
secretToStore := secretText
|
||||
if settings.Config.Auth.TotpSecret != "" {
|
||||
// If an encryption key is provided, encrypt the secret.
|
||||
secretText, nonce, err = encryptSecret(secretText, encryptionKey)
|
||||
secretToStore, nonce, err = encryptSecret(secretText, encryptionKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encrypt TOTP secret: %w", err)
|
||||
}
|
||||
}
|
||||
// set cache so verify can attempt to use it but not require it for user yet.
|
||||
TotpCache.Set(user.Username, secretText+"||"+nonce)
|
||||
TotpCache.Set(user.Username, secretToStore+"||"+nonce)
|
||||
// Use the original base32 secret in the OTP URL, not the encrypted version
|
||||
url := fmt.Sprintf("otpauth://totp/%v?secret=%v", "FileBrowser Quantum: "+user.Username, secretText)
|
||||
return url, nil
|
||||
}
|
||||
@@ -181,12 +183,13 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
|
||||
Algorithm: TOTPAlgorithm,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("error during TOTP validation: %v", err)
|
||||
logger.Errorf("error during TOTP validation for user %s: %v", user.Username, err)
|
||||
}
|
||||
if !valid {
|
||||
logger.Warningf("Invalid TOTP code for user %s", user.Username)
|
||||
return fmt.Errorf("invalid OTP token")
|
||||
}
|
||||
if totpSecret != "" {
|
||||
if totpSecret != "" || !user.OtpEnabled {
|
||||
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,6 +85,23 @@ func runCLI() bool {
|
||||
}
|
||||
subcommand := os.Args[2]
|
||||
|
||||
// Handle old syntax: "set -u user,pass -c config.yaml"
|
||||
if subcommand == "-u" {
|
||||
// Parse flags starting from os.Args[2:] to include the -u flag
|
||||
err := setCmd.Parse(os.Args[2:])
|
||||
if err != nil {
|
||||
setCmd.PrintDefaults()
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Debugf("setUser called with args: %v, config: %v, admin: %v", os.Args, dbConfig, asAdmin)
|
||||
err = setUser(dbConfig, asAdmin)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// New pattern: subcommand-based (e.g., "set user" or "set rule")
|
||||
// Parse flags starting from os.Args[3:] to skip the subcommand
|
||||
err := setCmd.Parse(os.Args[3:])
|
||||
@@ -93,13 +110,6 @@ func runCLI() bool {
|
||||
os.Exit(1)
|
||||
}
|
||||
switch subcommand {
|
||||
case "-u":
|
||||
err := setUser(dbConfig, asAdmin)
|
||||
if err != nil {
|
||||
fmt.Printf("error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return false
|
||||
case "rule":
|
||||
err := setRule(dbConfig, fsPath, indexPath, ruleCategory, value, allow)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
@@ -18,6 +19,7 @@ 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"
|
||||
@@ -116,17 +118,23 @@ func StartFilebrowser() {
|
||||
}
|
||||
|
||||
// Initialize shared index database before starting HTTP service
|
||||
if err := indexing.InitializeIndexDB(); err != nil {
|
||||
isNewDb, err := indexing.InitializeIndexDB()
|
||||
if err != nil {
|
||||
logger.Fatalf("Failed to initialize index database: %v", err)
|
||||
}
|
||||
|
||||
// Set indexing storage for persistence
|
||||
if store != nil && store.Indexing != nil {
|
||||
indexing.SetIndexingStorage(store.Indexing)
|
||||
if isNewDb {
|
||||
if err := store.Indexing.ResetAllComplexities(); err != nil {
|
||||
logger.Errorf("Failed to reset index complexities: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, source := range settings.Config.Server.SourceMap {
|
||||
go indexing.Initialize(source, false)
|
||||
go indexing.Initialize(source, false, isNewDb)
|
||||
}
|
||||
validateUserInfo(!dbExists)
|
||||
validateOfficeIntegration()
|
||||
@@ -175,11 +183,31 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
|
||||
numWorkers := settings.Config.Server.NumImageProcessors
|
||||
ffmpeg.SetFFmpegPaths()
|
||||
|
||||
// setup disk cache
|
||||
// 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
|
||||
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
|
||||
}
|
||||
@@ -119,7 +119,7 @@ func updateShowFirstLogin(user *users.User) bool {
|
||||
|
||||
// func to convert legacy user with perm key to permissions
|
||||
func updatePermissions(user *users.User) bool {
|
||||
if user.Version == 1 {
|
||||
if user.Version >= 1 {
|
||||
return false
|
||||
}
|
||||
updateUser := true
|
||||
|
||||
@@ -281,39 +281,37 @@ func setupFrontend(generate bool) {
|
||||
}
|
||||
|
||||
func setupMedia() {
|
||||
// If VideoPreview is not initialized, initialize with all types enabled
|
||||
if Config.Integrations.Media.Convert.VideoPreview == nil {
|
||||
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
|
||||
for _, t := range AllVideoPreviewTypes {
|
||||
Config.Integrations.Media.Convert.VideoPreview[t] = true
|
||||
}
|
||||
return
|
||||
// Save user's explicit config before applying defaults
|
||||
userImageConfig := make(map[ImagePreviewType]*bool)
|
||||
for k, v := range Config.Integrations.Media.Convert.ImagePreview {
|
||||
userImageConfig[k] = v
|
||||
}
|
||||
|
||||
// If VideoPreview map is empty, it means user didn't configure any video preview settings
|
||||
// In this case, enable all by default
|
||||
if len(Config.Integrations.Media.Convert.VideoPreview) == 0 {
|
||||
for _, t := range AllVideoPreviewTypes {
|
||||
Config.Integrations.Media.Convert.VideoPreview[t] = true
|
||||
}
|
||||
return
|
||||
// Re-initialize with defaults (gets wiped out during YAML unmarshal)
|
||||
Config.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]*bool)
|
||||
Config.Integrations.Media.Convert.ImagePreview[HEICImagePreview] = boolPtr(false) // HEIC defaults to disabled
|
||||
Config.Integrations.Media.Convert.ImagePreview[JPEGImagePreview] = boolPtr(true) // JPEG fallback defaults to enabled
|
||||
|
||||
// Apply user overrides (only for keys explicitly set in YAML)
|
||||
for k, v := range userImageConfig {
|
||||
Config.Integrations.Media.Convert.ImagePreview[k] = v
|
||||
}
|
||||
|
||||
// User has explicitly configured some video preview settings
|
||||
// Start with all enabled, then apply user overrides
|
||||
userConfig := make(map[VideoPreviewType]bool)
|
||||
// VideoPreview: Merge user config with defaults
|
||||
// Save user's explicit config from YAML
|
||||
userVideoConfig := make(map[VideoPreviewType]*bool)
|
||||
for k, v := range Config.Integrations.Media.Convert.VideoPreview {
|
||||
userConfig[k] = v
|
||||
userVideoConfig[k] = v
|
||||
}
|
||||
|
||||
// Reset to defaults (all enabled)
|
||||
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
|
||||
// Re-initialize with defaults (all enabled)
|
||||
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]*bool)
|
||||
for _, t := range AllVideoPreviewTypes {
|
||||
Config.Integrations.Media.Convert.VideoPreview[t] = true
|
||||
Config.Integrations.Media.Convert.VideoPreview[t] = boolPtr(true)
|
||||
}
|
||||
|
||||
// Apply user overrides (only for explicitly set values)
|
||||
for k, v := range userConfig {
|
||||
// Apply user overrides (only for keys explicitly set in YAML)
|
||||
for k, v := range userVideoConfig {
|
||||
Config.Integrations.Media.Convert.VideoPreview[k] = v
|
||||
}
|
||||
}
|
||||
@@ -636,14 +634,9 @@ func setDefaults(generate bool) Settings {
|
||||
database := os.Getenv("FILEBROWSER_DATABASE")
|
||||
if database == "" {
|
||||
database = "database.db"
|
||||
} else {
|
||||
// check if database file exists
|
||||
if _, err := os.Stat(database); os.IsNotExist(err) {
|
||||
database = "database.db"
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(database); os.IsNotExist(err) {
|
||||
logger.Warning("database file could not be found. If this is unexpected, the default path is `./database.db`, but it can be configured in the config file under `server.database`.")
|
||||
logger.Warning("database file could not be found. If this is unexpected, please set the FILEBROWSER_DATABASE environment variable to the correct path.")
|
||||
}
|
||||
s := Settings{
|
||||
Server: Server{
|
||||
@@ -680,7 +673,6 @@ func setDefaults(generate bool) Settings {
|
||||
Frontend: Frontend{
|
||||
Name: "FileBrowser Quantum",
|
||||
},
|
||||
|
||||
UserDefaults: UserDefaults{
|
||||
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
|
||||
StickySidebar: true,
|
||||
@@ -710,27 +702,26 @@ func setDefaults(generate bool) Settings {
|
||||
Folder: boolPtr(true),
|
||||
},
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 10,
|
||||
UploadChunkSize: 10, // 10MB
|
||||
MaxConcurrent: 10,
|
||||
UploadChunkSize: 10, // 10MB
|
||||
DownloadChunkSize: 0, // 0MB, default to no chunking
|
||||
},
|
||||
},
|
||||
}
|
||||
// Initialize ImagePreview map with all supported types set to false by default
|
||||
s.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]bool)
|
||||
for _, t := range AllImagePreviewTypes {
|
||||
s.Integrations.Media.Convert.ImagePreview[t] = false
|
||||
}
|
||||
|
||||
// Initialize VideoPreview map with all supported types set to true by default
|
||||
s.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
|
||||
// Initialize ImagePreview map with defaults
|
||||
s.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]*bool)
|
||||
s.Integrations.Media.Convert.ImagePreview[HEICImagePreview] = boolPtr(false) // HEIC defaults to disabled
|
||||
s.Integrations.Media.Convert.ImagePreview[JPEGImagePreview] = boolPtr(true) // JPEG fallback defaults to enabled
|
||||
// Initialize VideoPreview map with defaults (all enabled)
|
||||
s.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]*bool)
|
||||
for _, t := range AllVideoPreviewTypes {
|
||||
s.Integrations.Media.Convert.VideoPreview[t] = true
|
||||
s.Integrations.Media.Convert.VideoPreview[t] = boolPtr(true)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// validateCustomImage validates a custom image file path and returns the absolute path or error
|
||||
func validateCustomImage(configPath, imageName string, maxSize int64, allowedFormats []string) (absolutePath string, err error) {
|
||||
func validateCustomImage(configPath, imageName string, allowedFormats []string) (absolutePath string, err error) {
|
||||
// Get absolute path
|
||||
absolutePath, err = filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
@@ -738,16 +729,11 @@ func validateCustomImage(configPath, imageName string, maxSize int64, allowedFor
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
stat, err := os.Stat(absolutePath)
|
||||
_, err = os.Stat(absolutePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not access file: %w", err)
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if stat.Size() > maxSize {
|
||||
return "", fmt.Errorf("file too large (%d bytes), maximum allowed is %d bytes", stat.Size(), maxSize)
|
||||
}
|
||||
|
||||
// Validate file format
|
||||
ext := strings.ToLower(filepath.Ext(absolutePath))
|
||||
validFormat := false
|
||||
@@ -766,8 +752,7 @@ func validateCustomImage(configPath, imageName string, maxSize int64, allowedFor
|
||||
|
||||
func loadCustomFavicon() {
|
||||
const imageName = "favicon"
|
||||
const maxSize = 1024 * 1024 // 1MB
|
||||
allowedFormats := []string{".ico", ".png", ".svg"}
|
||||
allowedFormats := []string{".ico", ".png", ".svg", ".jpg", ".jpeg", ".gif", ".webp"}
|
||||
|
||||
// Set default embedded favicon path
|
||||
Env.FaviconEmbeddedPath = "img/icons/favicon.svg"
|
||||
@@ -780,7 +765,7 @@ func loadCustomFavicon() {
|
||||
}
|
||||
|
||||
// Validate custom favicon
|
||||
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, maxSize, allowedFormats)
|
||||
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, allowedFormats)
|
||||
if err != nil {
|
||||
logger.Warningf("Custom favicon validation failed: %v, using default", err)
|
||||
Config.Frontend.Favicon = ""
|
||||
@@ -798,16 +783,10 @@ func loadCustomFavicon() {
|
||||
|
||||
func loadLoginIcon() {
|
||||
const imageName = "login icon"
|
||||
const maxSize = 1024 * 1024 // 1MB
|
||||
allowedFormats := []string{".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"}
|
||||
|
||||
// Set default embedded icon path based on dark mode preference
|
||||
isDarkMode := Config.UserDefaults.DarkMode != nil && *Config.UserDefaults.DarkMode
|
||||
if isDarkMode {
|
||||
Env.LoginIconEmbeddedPath = "img/icons/favicon.svg" // Dark mode: dark background
|
||||
} else {
|
||||
Env.LoginIconEmbeddedPath = "img/icons/favicon-light.svg" // Light mode: light background
|
||||
}
|
||||
// Set default embedded icon path - just use favicon.svg (light/dark handled by CSS)
|
||||
Env.LoginIconEmbeddedPath = "img/icons/favicon.svg"
|
||||
|
||||
// Check if a custom login icon path is configured
|
||||
if Config.Frontend.LoginIcon == "" {
|
||||
@@ -817,7 +796,7 @@ func loadLoginIcon() {
|
||||
}
|
||||
|
||||
// Validate custom login icon
|
||||
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, maxSize, allowedFormats)
|
||||
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, allowedFormats)
|
||||
if err != nil {
|
||||
logger.Warningf("Custom login icon validation failed: %v, using default", err)
|
||||
Env.LoginIconPath = Env.LoginIconEmbeddedPath
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
package settings
|
||||
|
||||
func CanConvertImage(ext string) bool {
|
||||
return MediaEnabled() && Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
|
||||
if !MediaEnabled() {
|
||||
return false
|
||||
}
|
||||
val := Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
|
||||
if val == nil {
|
||||
return false // Extension not in the configured list
|
||||
}
|
||||
return *val
|
||||
}
|
||||
|
||||
func CanConvertVideo(ext string) bool {
|
||||
return MediaEnabled() && Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
|
||||
if !MediaEnabled() {
|
||||
return false
|
||||
}
|
||||
val := Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
|
||||
if val == nil {
|
||||
return false // Extension not in the configured list
|
||||
}
|
||||
return *val
|
||||
}
|
||||
|
||||
func MediaEnabled() bool {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/tools/go/packages"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -111,74 +112,93 @@ 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) {
|
||||
fset := token.NewFileSet()
|
||||
pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments)
|
||||
absDir, err := filepath.Abs(dir)
|
||||
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 _, pkg := range pkgs {
|
||||
for _, file := range pkg.Files {
|
||||
for _, decl := range file.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.TYPE {
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
for _, spec := range gen.Specs {
|
||||
ts, ok := spec.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
st, ok := ts.Type.(*ast.StructType)
|
||||
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
|
||||
}
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
name := field.Names[0].Name
|
||||
var parts []string
|
||||
var fullComment string
|
||||
if len(parts) > 0 {
|
||||
commentMap[name] = strings.Join(parts, " : ")
|
||||
}
|
||||
|
||||
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 secret
|
||||
if strings.Contains(strings.ToLower(fullComment), "secret:") {
|
||||
secretMap[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
|
||||
}
|
||||
// Check if field should be treated as deprecated
|
||||
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
|
||||
deprecatedMap[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,6 +207,29 @@ 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
|
||||
@@ -649,11 +692,6 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
|
||||
}
|
||||
mapNode := &yaml.Node{Kind: yaml.MappingNode}
|
||||
|
||||
// Special handling for ImagePreview maps - show all supported types
|
||||
if isImagePreviewMap(v) {
|
||||
return buildImagePreviewMap(v, comm, secrets, deprecated)
|
||||
}
|
||||
|
||||
// Get and sort map keys alphabetically for consistent output
|
||||
mapKeys := v.MapKeys()
|
||||
keyStrings := make([]string, len(mapKeys))
|
||||
@@ -707,16 +745,15 @@ func GenerateYaml() {
|
||||
{
|
||||
Path: ".",
|
||||
Config: SourceConfig{
|
||||
Conditionals: ConditionalFilter{
|
||||
ItemRules: []ConditionalRule{
|
||||
{}, // Add empty sample rule to show all fields in generated YAML
|
||||
},
|
||||
Rules: []ConditionalRule{
|
||||
{}, // Add empty sample rule to show all fields in generated YAML
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
setupLogging()
|
||||
setupFs()
|
||||
setupAuth(true)
|
||||
|
||||
// Save original paths before setupSources modifies them (for YAML generation)
|
||||
@@ -1136,53 +1173,3 @@ func AlignComments(input string) string {
|
||||
|
||||
return strings.Join(outputLines, "\n")
|
||||
}
|
||||
|
||||
// isImagePreviewMap checks if the given map is an ImagePreview map
|
||||
func isImagePreviewMap(v reflect.Value) bool {
|
||||
if v.Kind() != reflect.Map {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the map key type is ImagePreviewType
|
||||
keyType := v.Type().Key()
|
||||
return keyType.Name() == "ImagePreviewType"
|
||||
}
|
||||
|
||||
// buildImagePreviewMap builds a YAML node for ImagePreview maps, showing all supported types
|
||||
func buildImagePreviewMap(v reflect.Value, comm CommentsMap, secrets SecretFieldsMap, deprecated DeprecatedFieldsMap) (*yaml.Node, error) {
|
||||
mapNode := &yaml.Node{Kind: yaml.MappingNode}
|
||||
|
||||
// Get all supported image preview types from the global slice
|
||||
for _, imageType := range AllImagePreviewTypes {
|
||||
typeName := string(imageType)
|
||||
|
||||
// Create key node
|
||||
keyNode := &yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: typeName,
|
||||
}
|
||||
|
||||
// Get the value from the map, defaulting to false if not present
|
||||
var value bool
|
||||
if v.Len() > 0 {
|
||||
// Look for the key in the map
|
||||
for _, mapKey := range v.MapKeys() {
|
||||
if mapKey.String() == typeName {
|
||||
value = v.MapIndex(mapKey).Bool()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create value node
|
||||
valNode := &yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Tag: "!!bool",
|
||||
Value: fmt.Sprintf("%t", value),
|
||||
}
|
||||
|
||||
mapNode.Content = append(mapNode.Content, keyNode, valNode)
|
||||
}
|
||||
|
||||
return mapNode, nil
|
||||
}
|
||||
@@ -36,6 +36,10 @@ type Environment struct {
|
||||
FaviconPath string `json:"-"` // resolved favicon path (filesystem or embedded)
|
||||
FaviconIsCustom bool `json:"-"` // true if favicon is from custom filesystem path
|
||||
FaviconEmbeddedPath string `json:"-"` // embedded asset path for default favicon
|
||||
PWAIconsDir string `json:"-"` // directory where generated PWA icons are stored
|
||||
PWAIcon192 string `json:"-"` // path to 192x192 PWA icon
|
||||
PWAIcon256 string `json:"-"` // path to 256x256 PWA icon
|
||||
PWAIcon512 string `json:"-"` // path to 512x512 PWA icon
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -101,14 +105,15 @@ type Media struct {
|
||||
}
|
||||
|
||||
type FfmpegConvert struct {
|
||||
ImagePreview map[ImagePreviewType]bool `json:"imagePreview"` // supported image preview formats. defaults to false for all types unless explicitly enabled.
|
||||
VideoPreview map[VideoPreviewType]bool `json:"videoPreview"` // supported video preview formats. defaults to true for all types unless explicitly disabled.
|
||||
ImagePreview map[ImagePreviewType]*bool `json:"imagePreview"` // supported image preview formats. defaults vary by type (see individual type docs)
|
||||
VideoPreview map[VideoPreviewType]*bool `json:"videoPreview"` // supported video preview formats. defaults to true for all types unless explicitly disabled.
|
||||
}
|
||||
|
||||
type ImagePreviewType string
|
||||
|
||||
const (
|
||||
HEICImagePreview ImagePreviewType = "heic"
|
||||
JPEGImagePreview ImagePreviewType = "jpeg" // only used as fallback for JPEG formats that can't otherwise be decoded
|
||||
//RAWImagePreview ImagePreviewType = "raw"
|
||||
)
|
||||
|
||||
@@ -119,6 +124,7 @@ func (i ImagePreviewType) String() string {
|
||||
// AllImagePreviewTypes contains all supported image preview types.
|
||||
var AllImagePreviewTypes = []ImagePreviewType{
|
||||
HEICImagePreview,
|
||||
JPEGImagePreview,
|
||||
//RAWImagePreview,
|
||||
}
|
||||
|
||||
@@ -263,9 +269,10 @@ type Frontend struct {
|
||||
ExternalLinks []ExternalLink `json:"externalLinks"`
|
||||
DisableNavButtons bool `json:"disableNavButtons"` // disable the nav buttons in the sidebar
|
||||
Styling StylingConfig `json:"styling"`
|
||||
Favicon string `json:"favicon"` // path to a favicon to use for the frontend
|
||||
Description string `json:"description"` // description that shows up in html head meta description
|
||||
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
|
||||
Favicon string `json:"favicon"` // path to a favicon to use for the frontend
|
||||
Description string `json:"description"` // description that shows up in html head meta description
|
||||
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
|
||||
OIDCLoginButtonText string `json:"oidcLoginButtonText"` // text to display on the OIDC login button
|
||||
}
|
||||
|
||||
type StylingConfig struct {
|
||||
@@ -350,5 +357,4 @@ type UserDefaults struct {
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
DefaultLandingPage string `json:"defaultLandingPage"` // deprecated: determined by sidebar link order since 1.1.0
|
||||
}
|
||||
@@ -26,7 +26,7 @@ integrations:
|
||||
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
|
||||
// All video formats should be enabled by default
|
||||
for _, format := range AllVideoPreviewTypes {
|
||||
if !config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ integrations:
|
||||
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
|
||||
// All video formats should be enabled by default
|
||||
for _, format := range AllVideoPreviewTypes {
|
||||
if !config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
|
||||
}
|
||||
}
|
||||
@@ -81,16 +81,16 @@ integrations:
|
||||
`
|
||||
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
|
||||
// Check explicitly set formats
|
||||
if !config.Integrations.Media.Convert.VideoPreview[MP4VideoPreview] {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[MP4VideoPreview] {
|
||||
t.Error("Expected mp4 to be enabled")
|
||||
}
|
||||
if config.Integrations.Media.Convert.VideoPreview[WebMVideoPreview] {
|
||||
if *config.Integrations.Media.Convert.VideoPreview[WebMVideoPreview] {
|
||||
t.Error("Expected webm to be disabled")
|
||||
}
|
||||
if config.Integrations.Media.Convert.VideoPreview[MKVVideoPreview] {
|
||||
if *config.Integrations.Media.Convert.VideoPreview[MKVVideoPreview] {
|
||||
t.Error("Expected mkv to be disabled")
|
||||
}
|
||||
if config.Integrations.Media.Convert.VideoPreview[WMVVideoPreview] {
|
||||
if *config.Integrations.Media.Convert.VideoPreview[WMVVideoPreview] {
|
||||
t.Error("Expected wmv to be disabled")
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ integrations:
|
||||
M4VVideoPreview, ThreeGPVideoPreview, TSVideoPreview, VOBVideoPreview,
|
||||
}
|
||||
for _, format := range unmentionedFormats {
|
||||
if !config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s to be enabled (not mentioned in config), but it was disabled", format)
|
||||
}
|
||||
}
|
||||
@@ -155,13 +155,13 @@ integrations:
|
||||
}
|
||||
|
||||
for _, format := range expectedEnabled {
|
||||
if !config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s to be enabled, but it was disabled", format)
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range expectedDisabled {
|
||||
if config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
if *config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s to be disabled, but it was enabled", format)
|
||||
}
|
||||
}
|
||||
@@ -182,7 +182,7 @@ integrations:
|
||||
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
|
||||
// All video formats should be enabled by default
|
||||
for _, format := range AllVideoPreviewTypes {
|
||||
if !config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
|
||||
}
|
||||
}
|
||||
@@ -217,7 +217,7 @@ integrations:
|
||||
for category, formats := range categories {
|
||||
t.Run(category, func(t *testing.T) {
|
||||
for _, format := range formats {
|
||||
if !config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
if !*config.Integrations.Media.Convert.VideoPreview[format] {
|
||||
t.Errorf("Expected %s (%s) to be enabled by default, but it was disabled", format, category)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,12 +63,18 @@ 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)
|
||||
|
||||
// Reject absolute paths or attempts to escape scope
|
||||
if strings.HasPrefix(clean, "..") {
|
||||
return "", fmt.Errorf("invalid path")
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
return clean, nil
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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,148 +1,65 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// IsTextFile checks if a file is likely a text file by examining its content.
|
||||
// It returns true if the file appears to be a valid text file, false otherwise.
|
||||
// This function performs various heuristics to detect text files:
|
||||
// IsTextFile checks if a file is viewable/editable as text (like with cat or a text editor).
|
||||
// It returns true if the file appears to be text, false if it's likely binary.
|
||||
// This function uses simple heuristics:
|
||||
// - Valid UTF-8 encoding
|
||||
// - Limited null bytes
|
||||
// - Limited non-printable characters
|
||||
// - Not full of null bytes (which indicate binary data)
|
||||
func IsTextFile(realPath string) (bool, error) {
|
||||
const headerSize = 4096
|
||||
// Thresholds for detecting binary-like content (these can be tuned)
|
||||
const maxNullBytesInHeaderAbs = 10 // Max absolute null bytes in header
|
||||
const maxNullByteRatioInHeader = 0.1 // Max 10% null bytes in header
|
||||
const maxNullByteRatioInFile = 0.05 // Max 5% null bytes in the entire file
|
||||
const maxNonPrintableRuneRatio = 0.05 // Max 5% non-printable runes in the entire file
|
||||
|
||||
// Open file
|
||||
f, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Read header
|
||||
headerBytes := make([]byte, headerSize)
|
||||
n, err := f.Read(headerBytes)
|
||||
if err != nil && err != io.EOF {
|
||||
return false, err
|
||||
}
|
||||
actualHeader := headerBytes[:n]
|
||||
|
||||
// --- Start of heuristic checks ---
|
||||
|
||||
if n > 0 {
|
||||
// Trim header to last complete UTF-8 rune to avoid false negatives
|
||||
// when the header read cuts off in the middle of a multi-byte sequence.
|
||||
// We decode runes from the end until we find a valid one, trimming
|
||||
// any incomplete sequences at the end.
|
||||
trimmedHeader := actualHeader
|
||||
for len(trimmedHeader) > 0 {
|
||||
lastRune, size := utf8.DecodeLastRune(trimmedHeader)
|
||||
if lastRune != utf8.RuneError {
|
||||
// Found a valid complete rune
|
||||
break
|
||||
}
|
||||
// RuneError occurred - this could be an incomplete sequence or invalid byte
|
||||
// Trim the last byte and try again
|
||||
if size == 1 && len(trimmedHeader) > 0 {
|
||||
trimmedHeader = trimmedHeader[:len(trimmedHeader)-1]
|
||||
} else {
|
||||
// Shouldn't happen, but break to avoid infinite loop
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Basic Check: Is the header valid UTF-8?
|
||||
// If not, it's unlikely an editable UTF-8 text file.
|
||||
// Use trimmed header to avoid false negatives from truncated sequences
|
||||
if len(trimmedHeader) > 0 && !utf8.Valid(trimmedHeader) {
|
||||
return false, nil // Not an error, just not the text file we want
|
||||
}
|
||||
|
||||
// 2. Check for excessive null bytes in the header
|
||||
nullCountInHeader := 0
|
||||
for _, b := range actualHeader {
|
||||
if b == 0x00 {
|
||||
nullCountInHeader++
|
||||
}
|
||||
}
|
||||
// Reject if too many nulls absolutely or relatively in the header
|
||||
if nullCountInHeader > 0 { // Only perform check if there are any nulls
|
||||
if nullCountInHeader > maxNullBytesInHeaderAbs ||
|
||||
(float64(nullCountInHeader)/float64(n) > maxNullByteRatioInHeader) {
|
||||
return false, nil // Too many nulls in header
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check for other non-text ASCII control characters in the header
|
||||
// (C0 controls excluding \t, \n, \r)
|
||||
for _, b := range actualHeader {
|
||||
if b < 0x20 && b != '\t' && b != '\n' && b != '\r' {
|
||||
return false, nil // Found problematic control character
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now read the full file (original logic)
|
||||
const sampleSize = 8192 // Read first 8KB to check
|
||||
const maxNullByteRatio = 0.05 // Reject if more than 5% null bytes
|
||||
// Read sample from file
|
||||
content, err := os.ReadFile(realPath)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
// Handle empty file (empty files are considered text)
|
||||
// Empty files are considered text
|
||||
if len(content) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
// Use sample for large files to avoid reading entire file into memory
|
||||
sample := content
|
||||
if len(content) > sampleSize {
|
||||
sample = content[:sampleSize]
|
||||
}
|
||||
// Check 1: Count null bytes - binary files typically have many nulls
|
||||
nullCount := 0
|
||||
for _, b := range sample {
|
||||
if b == 0x00 {
|
||||
nullCount++
|
||||
}
|
||||
}
|
||||
|
||||
stringContent := string(content)
|
||||
if nullCount > 0 {
|
||||
nullRatio := float64(nullCount) / float64(len(sample))
|
||||
if nullRatio > maxNullByteRatio {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Final UTF-8 validation for the entire file
|
||||
// (This is crucial as the header might be fine, but the rest of the file isn't)
|
||||
if !utf8.ValidString(stringContent) {
|
||||
// Check 2: Validate UTF-8 encoding
|
||||
// Trim sample to last complete UTF-8 rune to avoid false negatives
|
||||
trimmedSample := sample
|
||||
for len(trimmedSample) > 0 {
|
||||
lastRune, size := utf8.DecodeLastRune(trimmedSample)
|
||||
if lastRune != utf8.RuneError {
|
||||
break
|
||||
}
|
||||
if size == 1 {
|
||||
trimmedSample = trimmedSample[:len(trimmedSample)-1]
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(trimmedSample) > 0 && !utf8.Valid(trimmedSample) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 5. Check for excessive null bytes in the entire file content
|
||||
if len(content) > 0 { // Check only for non-empty files
|
||||
totalNullCount := 0
|
||||
for _, b := range content {
|
||||
if b == 0x00 {
|
||||
totalNullCount++
|
||||
}
|
||||
}
|
||||
if float64(totalNullCount)/float64(len(content)) > maxNullByteRatioInFile {
|
||||
return false, nil // Too many nulls in the entire file
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Check for excessive non-printable runes in the entire file content
|
||||
// (Excluding tab, newline, carriage return, which are common in text files)
|
||||
if len(stringContent) > 0 { // Check only for non-empty strings
|
||||
nonPrintableRuneCount := 0
|
||||
totalRuneCount := 0
|
||||
for _, r := range stringContent {
|
||||
totalRuneCount++
|
||||
// unicode.IsPrint includes letters, numbers, punctuation, symbols, and spaces.
|
||||
// It excludes control characters. We explicitly allow \t, \n, \r.
|
||||
if !unicode.IsPrint(r) && r != '\t' && r != '\n' && r != '\r' {
|
||||
nonPrintableRuneCount++
|
||||
}
|
||||
}
|
||||
|
||||
if totalRuneCount > 0 { // Avoid division by zero
|
||||
if float64(nonPrintableRuneCount)/float64(totalRuneCount) > maxNonPrintableRuneRatio {
|
||||
return false, nil // Too many non-printable runes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The file has passed all checks and is considered editable text.
|
||||
return true, nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package indexing
|
||||
package dbindex
|
||||
|
||||
import (
|
||||
"time"
|
||||
@@ -1,4 +1,4 @@
|
||||
package indexing
|
||||
package dbindex
|
||||
|
||||
import (
|
||||
"sync"
|
||||
@@ -167,3 +167,37 @@ func (s *Storage) Flush() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetAllComplexities sets the complexity to 0 for all indexes and their scanners.
|
||||
// This should be called when the index database is recreated (either fresh start or after corruption).
|
||||
func (s *Storage) ResetAllComplexities() error {
|
||||
// Get all indexes from storage
|
||||
infos, err := s.All()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reset complexity for each index
|
||||
for _, info := range infos {
|
||||
if info == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Reset index-level complexity
|
||||
info.Complexity = 0
|
||||
|
||||
// Reset scanner-level complexities
|
||||
for _, scanner := range info.Scanners {
|
||||
if scanner != nil {
|
||||
scanner.Complexity = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Save the updated info
|
||||
if err := s.Save(info); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// IsSingleFileShare determines if this share is for a single file (not a directory).
|
||||
@@ -144,10 +145,28 @@ func (l *Link) HasReachedUserLimit(username string) bool {
|
||||
return count >= l.DownloadsLimit
|
||||
}
|
||||
|
||||
func (l *Link) GetSourceName() (string, error) {
|
||||
func (l *Link) GetSourceName() string {
|
||||
sourceInfo, ok := settings.Config.Server.SourceMap[l.Source]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("source not found")
|
||||
return ""
|
||||
}
|
||||
return sourceInfo.Name, nil
|
||||
return sourceInfo.Name
|
||||
}
|
||||
|
||||
func (l *Link) UserCanEdit(user *users.User) bool {
|
||||
return l.UserID == user.ID || user.Permissions.Admin
|
||||
}
|
||||
|
||||
func (l *Link) SourceURL(user *users.User) string {
|
||||
sourceName := l.GetSourceName()
|
||||
// get user scope path from share
|
||||
userScope, err := user.GetScopeForSourceName(sourceName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if !strings.HasPrefix(l.Path, userScope) {
|
||||
return ""
|
||||
}
|
||||
scopedPath := strings.TrimPrefix(l.Path, userScope)
|
||||
return "/" + filepath.Join("files", sourceName, scopedPath)
|
||||
}
|
||||
@@ -43,8 +43,10 @@ type CommonShare struct {
|
||||
AllowReplacements bool `json:"allowReplacements,omitempty"` // allow replacements of files
|
||||
SidebarLinks []users.SidebarLink `json:"sidebarLinks"` // customizable sidebar links
|
||||
HasPassword bool `json:"hasPassword,omitempty"`
|
||||
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
|
||||
DisableLoginOption bool `json:"disableLoginOption,omitempty"` // disable login option in share (true = hide, false = show)
|
||||
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
|
||||
DisableLoginOption bool `json:"disableLoginOption"` // disable login option in share (true = hide, false = show)
|
||||
SourceURL string `json:"sourceURL,omitempty"`
|
||||
CanEdit bool `json:"canEdit,omitempty"`
|
||||
}
|
||||
type CreateBody struct {
|
||||
CommonShare
|
||||
|
||||
@@ -247,7 +247,6 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
|
||||
continue
|
||||
}
|
||||
|
||||
oldFullPath := l.Path
|
||||
l.Source = newSource
|
||||
l.Path = newPath
|
||||
|
||||
@@ -264,14 +263,8 @@ 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,9 +4,11 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
@@ -41,7 +43,7 @@ func createIndexDB(name string, journalMode string, lockingMode string, batchSiz
|
||||
BatchSize: batchSize,
|
||||
CacheSizeKB: cacheSizeKB, // From config, converted to KB
|
||||
SoftHeapLimitBytes: 16 * 1024 * 1024, // 16MB soft heap limit (reduced to minimize memory pressure)
|
||||
CacheSpillThreshold: 200, // Spill dirty pages to disk when cache exceeds 200 pages (~800KB) - more aggressive
|
||||
CacheSpillThreshold: 2000, // Spill dirty pages to disk when cache exceeds 2000 pages (~8MB)
|
||||
MmapSize: 0, // Disable mmap to prevent additional OS page cache usage
|
||||
Synchronous: "OFF", // No sync for maximum performance - safe since DB can be rebuilt
|
||||
TempStore: "FILE", // FILE instead of MEMORY to reduce memory usage
|
||||
@@ -61,33 +63,53 @@ 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)
|
||||
func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int, disableReuse bool) (*IndexDB, error) {
|
||||
// Returns the IndexDB and a boolean indicating if the database is new (true) or reused (false)
|
||||
func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int, disableReuse bool) (*IndexDB, bool, error) {
|
||||
isNewDb := disableReuse // If disableReuse is true, we're creating fresh
|
||||
|
||||
// Check if database file exists (only if reuse is enabled)
|
||||
if !disableReuse {
|
||||
// Database files are stored in sql subdirectory (see sqlite.go:207)
|
||||
dbPath := filepath.Join(settings.Config.Server.CacheDir, "sql", fmt.Sprintf("index_%s.db", name))
|
||||
_, err := os.Stat(dbPath)
|
||||
if os.IsNotExist(err) {
|
||||
// Database file doesn't exist, so this is a new database
|
||||
isNewDb = true
|
||||
} else if err != nil {
|
||||
// Some other error occurred
|
||||
logger.Warningf("Error checking database file: %v", err)
|
||||
isNewDb = true
|
||||
}
|
||||
}
|
||||
|
||||
idxDB, err := createIndexDB(name, journalMode, "", batchSize, cacheSizeMB, disableReuse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// For persistent databases, check integrity on startup
|
||||
// If corrupted, delete and recreate the database
|
||||
if !disableReuse {
|
||||
if err := idxDB.checkIntegrityAndRecreateIfNeeded(); err != nil {
|
||||
// Database was recreated, need to create a new connection
|
||||
// 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
|
||||
idxDB.Close()
|
||||
|
||||
// Recreate the database connection
|
||||
idxDB, err = createIndexDB(name, journalMode, "", batchSize, cacheSizeMB, disableReuse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := idxDB.CreateIndexTable(); err != nil {
|
||||
idxDB.Close()
|
||||
return nil, err
|
||||
return nil, false, err
|
||||
}
|
||||
go idxDB.startPeriodicCleanup()
|
||||
return idxDB, nil
|
||||
return idxDB, isNewDb, nil
|
||||
}
|
||||
|
||||
// checkIntegrityAndRecreateIfNeeded checks database integrity.
|
||||
@@ -519,7 +541,7 @@ func (db *IndexDB) ExplainQueryPlan(query string, args ...interface{}) (string,
|
||||
if err := rows.Scan(&id, &parent, ¬used, &detail); err != nil {
|
||||
return "", err
|
||||
}
|
||||
plan.WriteString(fmt.Sprintf("%d|%d|%s\n", id, parent, detail))
|
||||
fmt.Fprintf(&plan, "%d|%d|%s\n", id, parent, detail)
|
||||
}
|
||||
return plan.String(), rows.Err()
|
||||
}
|
||||
|
||||
@@ -247,12 +247,22 @@ func NewTempDB(id string, config ...*TempDBConfig) (*TempDB, error) {
|
||||
db.SetMaxIdleConns(1)
|
||||
}
|
||||
|
||||
// Calculate cache_size in pages (negative value) to ensure consistent unit handling
|
||||
// SQLite's cache_size: positive = KB, negative = pages
|
||||
// When using positive KB values, SQLite seems to have issues with cache_spill being less than cache_size
|
||||
// So we convert to pages (negative) to ensure both cache_size and cache_spill use the same units
|
||||
cacheSizeInPages := cfg.CacheSizeKB
|
||||
if cfg.CacheSizeKB > 0 {
|
||||
// Convert KB to pages: KB / 4 = pages, then negate to indicate pages
|
||||
cacheSizeInPages = -(cfg.CacheSizeKB / 4)
|
||||
}
|
||||
|
||||
basePragmas := []struct {
|
||||
sql string
|
||||
err string
|
||||
}{
|
||||
{fmt.Sprintf("PRAGMA journal_mode = %s;", cfg.JournalMode), "failed to set journal_mode"},
|
||||
{fmt.Sprintf("PRAGMA cache_size = %d;", cfg.CacheSizeKB), "failed to set cache_size"},
|
||||
{fmt.Sprintf("PRAGMA cache_size = %d;", cacheSizeInPages), "failed to set cache_size"},
|
||||
{fmt.Sprintf("PRAGMA synchronous = %s;", cfg.Synchronous), "failed to set synchronous"},
|
||||
{fmt.Sprintf("PRAGMA temp_store = %s;", cfg.TempStore), "failed to set temp_store"},
|
||||
{fmt.Sprintf("PRAGMA locking_mode = %s;", cfg.LockingMode), "failed to set locking_mode"},
|
||||
@@ -274,19 +284,7 @@ func NewTempDB(id string, config ...*TempDBConfig) (*TempDB, error) {
|
||||
}{{fmt.Sprintf("PRAGMA hard_heap_limit = %d;", cfg.HardHeapLimitBytes), "failed to set hard_heap_limit"}}, basePragmas...)
|
||||
}
|
||||
|
||||
// Cache spill threshold - triggers early spilling to reduce memory usage
|
||||
if cfg.CacheSpillThreshold > 0 {
|
||||
basePragmas = append(basePragmas, struct {
|
||||
sql string
|
||||
err string
|
||||
}{fmt.Sprintf("PRAGMA cache_spill = %d;", cfg.CacheSpillThreshold), "failed to set cache_spill"})
|
||||
} else {
|
||||
// Enable cache_spill by default (value of 1 enables it)
|
||||
basePragmas = append(basePragmas, struct {
|
||||
sql string
|
||||
err string
|
||||
}{"PRAGMA cache_spill = 1;", "failed to set cache_spill"})
|
||||
}
|
||||
// Cache spill will be set later, after all other pragmas, to avoid interaction issues
|
||||
|
||||
// Page size must be set before any tables are created
|
||||
if cfg.PageSize > 0 {
|
||||
@@ -333,6 +331,32 @@ func NewTempDB(id string, config ...*TempDBConfig) (*TempDB, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Set cache_spill LAST, after all other pragmas are configured
|
||||
// SQLite may adjust cache_spill based on internal constraints or formulas
|
||||
if cfg.CacheSpillThreshold > 0 {
|
||||
// Try setting cache_spill multiple times with different approaches
|
||||
// Approach 1: Standard syntax
|
||||
if _, err := db.Exec(fmt.Sprintf("PRAGMA cache_spill = %d;", cfg.CacheSpillThreshold)); err != nil {
|
||||
logger.Warningf("[SQLITE_CONFIG] Failed to set cache_spill with standard syntax: %v", err)
|
||||
}
|
||||
|
||||
// Immediately verify what value was actually set
|
||||
var actualValue int64
|
||||
if err := db.QueryRow("PRAGMA cache_spill").Scan(&actualValue); err == nil {
|
||||
if actualValue != int64(cfg.CacheSpillThreshold) {
|
||||
// SQLite adjusted our value - calculate what percentage it used
|
||||
percentage := float64(actualValue) / float64(-cacheSizeInPages) * 100.0
|
||||
logger.Warningf("[SQLITE_CONFIG] SQLite adjusted cache_spill from %d to %d pages (%.1f%% of cache_size). This may be due to internal constraints.",
|
||||
cfg.CacheSpillThreshold, actualValue, percentage)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Enable cache_spill with default behavior
|
||||
if _, err := db.Exec("PRAGMA cache_spill = 1;"); err != nil {
|
||||
logger.Warningf("[SQLITE_CONFIG] Failed to enable cache_spill: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify PRAGMA settings were applied correctly
|
||||
if err := verifyPragmaSettings(db, cfg); err != nil {
|
||||
logger.Warningf("Failed to verify PRAGMA settings: %v", err)
|
||||
@@ -452,18 +476,39 @@ func verifyPragmaSettings(db *sql.DB, cfg *TempDBConfig) error {
|
||||
cacheSizeKB = -cacheSize * 4 // pages * 4KB per page
|
||||
}
|
||||
|
||||
// Expected cache_spill value in pages (cache_spill is always specified and returned in pages)
|
||||
expectedCacheSpill := int64(cfg.CacheSpillThreshold)
|
||||
|
||||
// Convert numeric PRAGMA values to human-readable strings
|
||||
tempStoreMap := map[string]string{"0": "DEFAULT", "1": "FILE", "2": "MEMORY"}
|
||||
synchronousMap := map[string]string{"0": "OFF", "1": "NORMAL", "2": "FULL", "3": "EXTRA"}
|
||||
autoVacuumMap := map[string]string{"0": "NONE", "1": "FULL", "2": "INCREMENTAL"}
|
||||
|
||||
tempStoreStr := tempStoreMap[tempStore]
|
||||
if tempStoreStr == "" {
|
||||
tempStoreStr = tempStore
|
||||
}
|
||||
synchronousStr := synchronousMap[synchronous]
|
||||
if synchronousStr == "" {
|
||||
synchronousStr = synchronous
|
||||
}
|
||||
autoVacuumStr := autoVacuumMap[autoVacuum]
|
||||
if autoVacuumStr == "" {
|
||||
autoVacuumStr = autoVacuum
|
||||
}
|
||||
|
||||
// Log all settings
|
||||
logger.Debugf("[SQLITE_CONFIG] Database PRAGMA settings verified:")
|
||||
logger.Debugf(" cache_size: %d KB (configured: %d KB)", cacheSizeKB, cfg.CacheSizeKB)
|
||||
logger.Debugf(" mmap_size: %d bytes (configured: %d bytes)", mmapSize, cfg.MmapSize)
|
||||
logger.Debugf(" soft_heap_limit: %d bytes (configured: %d bytes)", softHeapLimit, cfg.SoftHeapLimitBytes)
|
||||
logger.Debugf(" hard_heap_limit: %d bytes (configured: %d bytes)", hardHeapLimit, cfg.HardHeapLimitBytes)
|
||||
logger.Debugf(" cache_spill: %d pages (configured: %d pages)", cacheSpill, cfg.CacheSpillThreshold)
|
||||
logger.Debugf(" journal_mode: %s (configured: %s)", journalMode, cfg.JournalMode)
|
||||
logger.Debugf(" synchronous: %s (configured: %s)", synchronous, cfg.Synchronous)
|
||||
logger.Debugf(" temp_store: %s (configured: %s)", tempStore, cfg.TempStore)
|
||||
logger.Debugf(" locking_mode: %s (configured: %s)", lockingMode, cfg.LockingMode)
|
||||
logger.Debugf(" auto_vacuum: %s (configured: %s)", autoVacuum, cfg.AutoVacuum)
|
||||
logger.Debugf(" cache_size : %d KB", cacheSizeKB)
|
||||
logger.Debugf(" mmap_size : %d bytes", mmapSize)
|
||||
logger.Debugf(" soft_heap_limit : %d bytes", softHeapLimit)
|
||||
logger.Debugf(" hard_heap_limit : %d bytes", hardHeapLimit)
|
||||
logger.Debugf(" cache_spill : %d pages", cacheSpill)
|
||||
logger.Debugf(" journal_mode : %s", journalMode)
|
||||
logger.Debugf(" synchronous : %s", synchronousStr)
|
||||
logger.Debugf(" temp_store : %s", tempStoreStr)
|
||||
logger.Debugf(" locking_mode : %s", lockingMode)
|
||||
logger.Debugf(" auto_vacuum : %s", autoVacuumStr)
|
||||
|
||||
// Verify critical settings match
|
||||
if cfg.CacheSizeKB != 0 {
|
||||
@@ -484,6 +529,31 @@ func verifyPragmaSettings(db *sql.DB, cfg *TempDBConfig) error {
|
||||
if cfg.HardHeapLimitBytes > 0 && hardHeapLimit != cfg.HardHeapLimitBytes {
|
||||
logger.Warningf("[SQLITE_CONFIG] hard_heap_limit mismatch: got %d bytes, expected %d bytes", hardHeapLimit, cfg.HardHeapLimitBytes)
|
||||
}
|
||||
// Verify cache_spill matches expected value (accounting for unit conversion)
|
||||
if cfg.CacheSpillThreshold > 0 && cacheSpill != expectedCacheSpill {
|
||||
logger.Warningf("[SQLITE_CONFIG] cache_spill mismatch: got %d pages, expected %d pages (from threshold %d pages)", cacheSpill, expectedCacheSpill, cfg.CacheSpillThreshold)
|
||||
// If cache_spill equals cache_size in pages, it means SQLite ignored our setting
|
||||
var cacheSizePages int64
|
||||
if cfg.CacheSizeKB > 0 {
|
||||
cacheSizePages = int64(cfg.CacheSizeKB) / 4 // KB to pages
|
||||
} else {
|
||||
cacheSizePages = -int64(cfg.CacheSizeKB) // Already in pages (negative)
|
||||
}
|
||||
if cacheSpill == cacheSizePages {
|
||||
logger.Warningf("[SQLITE_CONFIG] cache_spill equals cache_size (%d pages), indicating SQLite may have ignored our setting", cacheSizePages)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify string-based PRAGMA settings match (using converted values)
|
||||
if synchronousStr != cfg.Synchronous {
|
||||
logger.Warningf("[SQLITE_CONFIG] synchronous mismatch: got %s, expected %s", synchronousStr, cfg.Synchronous)
|
||||
}
|
||||
if tempStoreStr != cfg.TempStore {
|
||||
logger.Warningf("[SQLITE_CONFIG] temp_store mismatch: got %s, expected %s", tempStoreStr, cfg.TempStore)
|
||||
}
|
||||
if autoVacuumStr != cfg.AutoVacuum {
|
||||
logger.Warningf("[SQLITE_CONFIG] auto_vacuum mismatch: got %s, expected %s", autoVacuumStr, cfg.AutoVacuum)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/auth"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
@@ -19,7 +19,7 @@ type BoltStore struct {
|
||||
Auth *auth.Storage
|
||||
Settings *settings.Storage
|
||||
Access *access.Storage
|
||||
Indexing *indexing.Storage
|
||||
Indexing *dbindex.Storage
|
||||
}
|
||||
|
||||
// NewStorage creates a storage.Storage based on Bolt DB.
|
||||
@@ -35,6 +35,6 @@ func NewStorage(db *storm.DB) (*BoltStore, error) {
|
||||
Auth: authStore,
|
||||
Settings: settings.NewStorage(settingsBackend{db: db}),
|
||||
Access: access.NewStorage(db, userStore),
|
||||
Indexing: indexing.NewStorage(indexingBackend{db: db}),
|
||||
Indexing: dbindex.NewStorage(indexingBackend{db: db}),
|
||||
}, nil
|
||||
}
|
||||
@@ -5,15 +5,15 @@ import (
|
||||
"github.com/asdine/storm/v3/q"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
|
||||
)
|
||||
|
||||
type indexingBackend struct {
|
||||
db *storm.DB
|
||||
}
|
||||
|
||||
func (s indexingBackend) All() ([]*indexing.IndexInfo, error) {
|
||||
var v []*indexing.IndexInfo
|
||||
func (s indexingBackend) All() ([]*dbindex.IndexInfo, error) {
|
||||
var v []*dbindex.IndexInfo
|
||||
err := s.db.All(&v)
|
||||
if err == storm.ErrNotFound {
|
||||
return v, errors.ErrNotExist
|
||||
@@ -22,8 +22,8 @@ func (s indexingBackend) All() ([]*indexing.IndexInfo, error) {
|
||||
return v, err
|
||||
}
|
||||
|
||||
func (s indexingBackend) GetByPath(path string) (*indexing.IndexInfo, error) {
|
||||
var v indexing.IndexInfo
|
||||
func (s indexingBackend) GetByPath(path string) (*dbindex.IndexInfo, error) {
|
||||
var v dbindex.IndexInfo
|
||||
err := s.db.One("Path", path, &v)
|
||||
if err == storm.ErrNotFound {
|
||||
return nil, errors.ErrNotExist
|
||||
@@ -32,8 +32,8 @@ func (s indexingBackend) GetByPath(path string) (*indexing.IndexInfo, error) {
|
||||
return &v, err
|
||||
}
|
||||
|
||||
func (s indexingBackend) GetBySource(source string) ([]*indexing.IndexInfo, error) {
|
||||
var v []*indexing.IndexInfo
|
||||
func (s indexingBackend) GetBySource(source string) ([]*dbindex.IndexInfo, error) {
|
||||
var v []*dbindex.IndexInfo
|
||||
err := s.db.Select(q.Eq("Source", source)).Find(&v)
|
||||
if err == storm.ErrNotFound {
|
||||
return nil, errors.ErrNotExist
|
||||
@@ -41,12 +41,12 @@ func (s indexingBackend) GetBySource(source string) ([]*indexing.IndexInfo, erro
|
||||
return v, err
|
||||
}
|
||||
|
||||
func (s indexingBackend) Save(info *indexing.IndexInfo) error {
|
||||
func (s indexingBackend) Save(info *dbindex.IndexInfo) error {
|
||||
return s.db.Save(info)
|
||||
}
|
||||
|
||||
func (s indexingBackend) Delete(path string) error {
|
||||
err := s.db.DeleteStruct(&indexing.IndexInfo{Path: path})
|
||||
err := s.db.DeleteStruct(&dbindex.IndexInfo{Path: path})
|
||||
if err == storm.ErrNotFound {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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 && !user.OtpEnabled {
|
||||
if passwordUser && enforcedOtp && !existingUser.OtpEnabled {
|
||||
return errors.ErrNoTotpConfigured
|
||||
}
|
||||
if user.LoginMethod == "" {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
var userStore *users.Storage
|
||||
|
||||
func InitializeDb(path string) (*bolt.BoltStore, bool, error) {
|
||||
logger.Debugf("Initializing database: %v", path)
|
||||
exists, err := dbExists(path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -116,7 +116,6 @@ type NonAdminEditable struct {
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
|
||||
DefaultLandingPage string `json:"defaultLandingPage"` // deprecated: determined by sidebar link order instead
|
||||
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ package ffmpeg
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
)
|
||||
|
||||
var (
|
||||
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
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
package ffmpeg
|
||||
|
||||
import "github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
|
||||
func CanConvertImage(ext string) bool {
|
||||
if !MediaEnabled() {
|
||||
return false
|
||||
}
|
||||
val := settings.Config.Integrations.Media.Convert.ImagePreview[settings.ImagePreviewType(ext)]
|
||||
if val == nil {
|
||||
return false // Extension not in the configured list
|
||||
}
|
||||
return *val
|
||||
}
|
||||
|
||||
func CanConvertVideo(ext string) bool {
|
||||
if !MediaEnabled() {
|
||||
return false
|
||||
}
|
||||
val := settings.Config.Integrations.Media.Convert.VideoPreview[settings.VideoPreviewType(ext)]
|
||||
if val == nil {
|
||||
return false // Extension not in the configured list
|
||||
}
|
||||
return *val
|
||||
}
|
||||
|
||||
func MediaEnabled() bool {
|
||||
return settings.Env.FFmpegPath != "" && settings.Env.FFprobePath != ""
|
||||
}
|
||||
@@ -378,3 +378,11 @@ func (s *FFmpegService) ConvertHEICToJPEG(ctx context.Context, heicPath string,
|
||||
|
||||
return jpegBytes, nil
|
||||
}
|
||||
|
||||
// ConvertImageToJPEG converts any image format (including problematic JPEGs) to standard JPEG using FFmpeg
|
||||
// This handles extended JPEG formats that Go's image/jpeg decoder doesn't support (e.g., SOF1/C1 marker)
|
||||
// and can be used as a fallback for images that fail to decode with Go's standard library.
|
||||
func (s *FFmpegService) ConvertImageToJPEG(ctx context.Context, imagePath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
|
||||
// Use the direct conversion method which works for all image formats
|
||||
return s.ConvertHEICToJPEGDirect(ctx, imagePath, targetWidth, targetHeight, quality)
|
||||
}
|
||||
@@ -10,20 +10,10 @@ 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 {
|
||||
@@ -36,7 +26,7 @@ type FFProbeOutput struct {
|
||||
}
|
||||
|
||||
// DetectAllSubtitles finds both embedded and external subtitle tracks
|
||||
func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) []SubtitleTrack {
|
||||
func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) []utils.SubtitleTrack {
|
||||
key := "all_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
|
||||
|
||||
// Check cache first
|
||||
@@ -44,7 +34,7 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
|
||||
return cached
|
||||
}
|
||||
|
||||
var allSubtitles []SubtitleTrack
|
||||
var allSubtitles []utils.SubtitleTrack
|
||||
|
||||
// First, get embedded subtitles
|
||||
embeddedSubs := detectEmbeddedSubtitles(videoPath)
|
||||
@@ -60,9 +50,25 @@ 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) []SubtitleTrack {
|
||||
func detectEmbeddedSubtitles(realPath string) []utils.SubtitleTrack {
|
||||
cmd := exec.Command("ffprobe",
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
@@ -82,13 +88,13 @@ func detectEmbeddedSubtitles(realPath string) []SubtitleTrack {
|
||||
return nil
|
||||
}
|
||||
|
||||
var subtitles []SubtitleTrack
|
||||
var subtitles []utils.SubtitleTrack
|
||||
for _, stream := range probeOutput.Streams {
|
||||
if stream.CodecType == "subtitle" {
|
||||
track := SubtitleTrack{
|
||||
Index: &stream.Index,
|
||||
Codec: stream.CodecName,
|
||||
IsFile: false, // This is an embedded subtitle
|
||||
track := utils.SubtitleTrack{
|
||||
Index: &stream.Index,
|
||||
Codec: stream.CodecName,
|
||||
Embedded: true, // This is an embedded subtitle
|
||||
}
|
||||
|
||||
// Extract language and title from tags
|
||||
@@ -117,8 +123,8 @@ func detectEmbeddedSubtitles(realPath string) []SubtitleTrack {
|
||||
}
|
||||
|
||||
// detectExternalSubtitles finds external subtitle files in the same directory
|
||||
func detectExternalSubtitles(videoPath string, parentDir string) []SubtitleTrack {
|
||||
var subtitles []SubtitleTrack
|
||||
func detectExternalSubtitles(videoPath string, parentDir string) []utils.SubtitleTrack {
|
||||
var subtitles []utils.SubtitleTrack
|
||||
|
||||
// Get the base name of the video (without extension)
|
||||
videoBaseName := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
|
||||
@@ -131,9 +137,9 @@ func detectExternalSubtitles(videoPath string, parentDir string) []SubtitleTrack
|
||||
subtitlePath := filepath.Join(parentDir, videoBaseName+ext)
|
||||
if _, err := os.Stat(subtitlePath); err == nil {
|
||||
// File exists
|
||||
track := SubtitleTrack{
|
||||
Name: filepath.Base(subtitlePath),
|
||||
IsFile: true, // This is an external file
|
||||
track := utils.SubtitleTrack{
|
||||
Name: filepath.Base(subtitlePath),
|
||||
Embedded: false, // This is an external file
|
||||
}
|
||||
|
||||
// Try to infer language from filename patterns like "video.en.srt"
|
||||
@@ -170,120 +176,11 @@ func ExtractSubtitleContent(videoPath string, streamIndex int) (string, error) {
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
// 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
|
||||
// LoadSubtitleFile loads a subtitle file and returns its raw content
|
||||
func LoadSubtitleFile(subtitlePath string) (string, error) {
|
||||
content, err := os.ReadFile(subtitlePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 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
|
||||
return string(content), nil
|
||||
}
|
||||
@@ -5,32 +5,33 @@ go 1.25.0
|
||||
require (
|
||||
github.com/asdine/storm/v3 v3.2.1
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/coreos/go-systemd/v22 v22.6.0
|
||||
github.com/coreos/go-systemd/v22 v22.7.0
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/dsoprea/go-exif/v3 v3.0.1
|
||||
github.com/gen2brain/go-fitz v1.24.15
|
||||
github.com/go-playground/validator/v10 v10.29.0
|
||||
github.com/goccy/go-yaml v1.19.0
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/gtsteffaniak/go-cache v1.1.0
|
||||
github.com/gtsteffaniak/go-logger v1.0.2
|
||||
github.com/gtsteffaniak/go-push v0.1.0
|
||||
github.com/kovidgoyal/imaging v1.7.2
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/kovidgoyal/imaging v1.8.20
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
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.46.0
|
||||
golang.org/x/image v0.34.0
|
||||
golang.org/x/mod v0.31.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/sys v0.39.0
|
||||
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.51.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
golang.org/x/sys v0.41.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.40.1
|
||||
modernc.org/sqlite v1.46.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -52,16 +53,17 @@ 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.4.0 // indirect
|
||||
github.com/MirrexOne/unqueryvet v1.5.3 // indirect
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
|
||||
github.com/air-verse/air v1.64.0 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.21.1 // indirect
|
||||
github.com/air-verse/air v1.64.5 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.23.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.1 // indirect
|
||||
github.com/alexkohler/prealloc v1.0.2 // 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
|
||||
@@ -71,7 +73,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.3.0 // indirect
|
||||
github.com/bombsimon/wsl/v5 v5.6.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
|
||||
@@ -83,7 +85,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.8.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // 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
|
||||
@@ -97,28 +99,28 @@ 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.9.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // 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.12 // indirect
|
||||
github.com/ghostiam/protogetter v0.3.18 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/ghostiam/protogetter v0.3.20 // 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
|
||||
github.com/go-openapi/jsonpointer v0.22.4 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.4 // indirect
|
||||
github.com/go-openapi/spec v0.22.2 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.5 // indirect
|
||||
github.com/go-openapi/spec v0.22.4 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.5 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-toolsmith/astcast v1.1.0 // indirect
|
||||
@@ -128,21 +130,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.4.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.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.1 // indirect
|
||||
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
|
||||
github.com/gofrs/flock v0.13.0 // indirect
|
||||
github.com/gohugoio/hugo v0.149.1 // indirect
|
||||
github.com/golang/geo v0.0.0-20251215195340-f7ee789d9fc1 // indirect
|
||||
github.com/golang/geo v0.0.0-20260302211937-87f5a40ea07a // 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.8.0 // indirect
|
||||
github.com/golangci/golines v0.14.0 // indirect
|
||||
github.com/golangci/misspell v0.7.0 // 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/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
|
||||
@@ -162,12 +164,14 @@ require (
|
||||
github.com/jgautheron/goconst v1.8.2 // indirect
|
||||
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
|
||||
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.5.1 // indirect
|
||||
github.com/jupiterrider/ffi v0.6.0 // 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
|
||||
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
|
||||
github.com/kovidgoyal/go-shm v1.0.0 // indirect
|
||||
github.com/kulti/thelper v0.7.1 // indirect
|
||||
github.com/kunwardeep/paralleltest v1.0.15 // indirect
|
||||
github.com/lasiar/canonicalheader v1.1.2 // indirect
|
||||
@@ -192,7 +196,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.13.0 // indirect
|
||||
github.com/mgechev/revive v1.14.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
|
||||
@@ -201,11 +205,10 @@ 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.21.2 // indirect
|
||||
github.com/nunnatsa/ginkgolinter v0.23.0 // 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
|
||||
github.com/polyfloyd/go-errorlint v1.8.0 // indirect
|
||||
github.com/prometheus/client_golang v1.12.1 // indirect
|
||||
github.com/prometheus/client_model v0.2.0 // indirect
|
||||
github.com/prometheus/common v0.32.1 // indirect
|
||||
@@ -227,8 +230,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.22.11 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/securego/gosec/v2 v2.23.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // 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
|
||||
@@ -261,24 +264,21 @@ 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.3.1 // indirect
|
||||
go.augendre.info/arangolint v0.4.0 // 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-20251209150349-8475f28825e9 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.48.0 // 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/sync v0.19.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
golang.org/x/tools v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.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.6.1 // indirect
|
||||
modernc.org/libc v1.67.1 // indirect
|
||||
honnef.co/go/tools v0.7.0 // indirect
|
||||
modernc.org/libc v1.69.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
mvdan.cc/gofumpt v0.9.2 // indirect
|
||||
|
||||
@@ -62,8 +62,6 @@ github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9
|
||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
|
||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
@@ -75,35 +73,22 @@ 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.2.1 h1:M+zdXMq84g+E1YOLa7g7ExN3dWfZQrdDSTCM7gC+m/A=
|
||||
github.com/MirrexOne/unqueryvet v1.2.1/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
|
||||
github.com/MirrexOne/unqueryvet v1.3.0 h1:5slWSomgqpYU4zFuZ3NNOfOUxVPlXFDBPAVasZOGlAY=
|
||||
github.com/MirrexOne/unqueryvet v1.3.0/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
|
||||
github.com/MirrexOne/unqueryvet v1.4.0 h1:6KAkqqW2KUnkl9Z0VuTphC3IXRPoFqEkJEtyxxHj5eQ=
|
||||
github.com/MirrexOne/unqueryvet v1.4.0/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
|
||||
github.com/MirrexOne/unqueryvet v1.5.3 h1:LpT3rsH+IY3cQddWF9bg4C7jsbASdGnrOSofY8IPEiw=
|
||||
github.com/MirrexOne/unqueryvet v1.5.3/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
|
||||
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.63.1 h1:N6kD5niKKVx0wF2mW0mgK6LNfJqP5/lCAqm3WWl9vlw=
|
||||
github.com/air-verse/air v1.63.1/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
|
||||
github.com/air-verse/air v1.63.4 h1:Z+R4328Bja5QKFMTP0CNeT8aVWdb3D5kbbFvnXnuRhE=
|
||||
github.com/air-verse/air v1.63.4/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
|
||||
github.com/air-verse/air v1.63.7 h1:Wi85jUmyhGWcNpm/OUUvPiGNp65X+mUVKjr1N9HfZ3Q=
|
||||
github.com/air-verse/air v1.63.7/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
|
||||
github.com/air-verse/air v1.64.0 h1:lKEvIQzaOM5vAVKOdQxq1WrXLfLmzz+e+hPrhmgF6yo=
|
||||
github.com/air-verse/air v1.64.0/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
|
||||
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/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.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
|
||||
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/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/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.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
|
||||
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
@@ -111,16 +96,16 @@ 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.0 h1:Hbq0/3fJPQhNkN0dR95AVrr6R7tou91y0uHG5pOcUuw=
|
||||
github.com/alexkohler/prealloc v1.0.0/go.mod h1:VetnK3dIgFBBKmg0YnD9F9x6Icjd+9cvfHR56wJVlKE=
|
||||
github.com/alexkohler/prealloc v1.0.1 h1:A9P1haqowqUxWvU9nk6tQ7YktXIHf+LQM9wPRhuteEE=
|
||||
github.com/alexkohler/prealloc v1.0.1/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
|
||||
github.com/alexkohler/prealloc v1.0.2 h1:MPo8cIkGkZytq7WNH9UHv3DIX1mPz1RatPXnZb0zHWQ=
|
||||
github.com/alexkohler/prealloc v1.0.2/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=
|
||||
@@ -171,8 +156,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.3.0 h1:nZWREJFL6U3vgW/B1lfDOigl+tEF6qgs6dGGbFeR0UM=
|
||||
github.com/bombsimon/wsl/v5 v5.3.0/go.mod h1:Gp8lD04z27wm3FANIUPZycXp+8huVsn0oxc+n4qfV9I=
|
||||
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/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 +169,6 @@ github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E
|
||||
github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70=
|
||||
github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc=
|
||||
github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI=
|
||||
github.com/catenacyber/perfsprint v0.10.0 h1:AZj1mYyxbxLRqmnYOeguZXEQwWOgQGm2wzLI5d7Hl/0=
|
||||
github.com/catenacyber/perfsprint v0.10.0/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc=
|
||||
github.com/catenacyber/perfsprint v0.10.1 h1:u7Riei30bk46XsG8nknMhKLXG9BcXz3+3tl/WpKm0PQ=
|
||||
github.com/catenacyber/perfsprint v0.10.1/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc=
|
||||
github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc=
|
||||
@@ -201,8 +184,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.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
|
||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
|
||||
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/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=
|
||||
@@ -218,8 +201,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
|
||||
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
@@ -261,8 +244,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.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
|
||||
github.com/ebitengine/purego v0.10.0/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=
|
||||
@@ -284,20 +267,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.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gen2brain/go-fitz v1.24.15 h1:sJNB1MOWkqnzzENPHggFpgxTwW0+S5WF/rM5wUBpJWo=
|
||||
github.com/gen2brain/go-fitz v1.24.15/go.mod h1:SftkiVbTHqF141DuiLwBBM65zP7ig6AVDQpf2WlHamo=
|
||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
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.17 h1:sjGPErP9o7i2Ym+z3LsQzBdLCNaqbYy2iJQPxGXg04Q=
|
||||
github.com/ghostiam/protogetter v0.3.17/go.mod h1:AivIX1eKA/TcUmzZdzbl+Tb8tjIe8FcyG6JFyemQAH4=
|
||||
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.2 h1:PMvP5f+LdR8p6B29npvChUXbD1vrNlKDf60NJtgMBOo=
|
||||
github.com/go-critic/go-critic v0.14.2/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
|
||||
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/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=
|
||||
@@ -319,41 +298,41 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
|
||||
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
|
||||
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
|
||||
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
|
||||
github.com/go-openapi/spec v0.22.2 h1:KEU4Fb+Lp1qg0V4MxrSCPv403ZjBl8Lx1a83gIPU8Qc=
|
||||
github.com/go-openapi/spec v0.22.2/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
|
||||
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
|
||||
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
|
||||
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
|
||||
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
|
||||
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
|
||||
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
|
||||
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
|
||||
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
|
||||
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
|
||||
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
|
||||
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
|
||||
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
|
||||
github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
|
||||
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
|
||||
github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
|
||||
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
|
||||
github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
|
||||
github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
|
||||
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
|
||||
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.29.0 h1:lQlF5VNJWNlRbRZNeOIkWElR+1LL/OuHcc0Kp14w1xk=
|
||||
github.com/go-playground/validator/v10 v10.29.0/go.mod h1:D6QxqeMlgIPuT02L66f2ccrZ7AGgHkzKmmTMZhk/Kc4=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
@@ -378,22 +357,18 @@ 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.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
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-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=
|
||||
github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
|
||||
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.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE=
|
||||
github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godoc-lint/godoc-lint v0.10.1 h1:ZPUVzlDtJfA+P688JfPJPkI/SuzcBr/753yGIk5bOPA=
|
||||
github.com/godoc-lint/godoc-lint v0.10.1/go.mod h1:KleLcHu/CGSvkjUH2RvZyoK1MBC7pDQg4NxMYLcBBsw=
|
||||
github.com/godoc-lint/godoc-lint v0.10.2 h1:dksNgK+zebnVlj4Fx83CRnCmPO0qRat/9xfFsir1nfg=
|
||||
github.com/godoc-lint/godoc-lint v0.10.2/go.mod h1:KleLcHu/CGSvkjUH2RvZyoK1MBC7pDQg4NxMYLcBBsw=
|
||||
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/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/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=
|
||||
@@ -418,8 +393,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-20251215195340-f7ee789d9fc1 h1:D/2kgdp+Hl79R59gNSnTQPpjWKd/IVUYhuvkqWQG8N0=
|
||||
github.com/golang/geo v0.0.0-20251215195340-f7ee789d9fc1/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
|
||||
github.com/golang/geo v0.0.0-20260302211937-87f5a40ea07a h1:7f/cr+n7Jtd7mzCLrx47sdVIGabpSSxCHrf6fJczDp0=
|
||||
github.com/golang/geo v0.0.0-20260302211937-87f5a40ea07a/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=
|
||||
@@ -459,18 +434,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.6.2 h1:jkMSVv36JmyTENcEertckvimvjPcD5qxNM7W7qhECvI=
|
||||
github.com/golangci/golangci-lint/v2 v2.6.2/go.mod h1:fSIMDiBt9kzdpnvvV7GO6iWzyv5uaeZ+iPor+2uRczE=
|
||||
github.com/golangci/golangci-lint/v2 v2.7.2 h1:AhBC+YeEueec4AGlIbvPym5C70Thx0JykIqXbdIXWx0=
|
||||
github.com/golangci/golangci-lint/v2 v2.7.2/go.mod h1:pDijleoBu7e8sejMqyZ3L5n6geqe+cVvOAz2QImqqVc=
|
||||
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.0.0-20250217134842-442fd0091d95 h1:AkK+w9FZBXlU/xUmBtSJN1+tAI4FIvy5WtnUnY8e4p8=
|
||||
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95/go.mod h1:k9mmcyWKSTMcPPvQUCfRWWQ9VHJ1U9Dc0R7kaXAgtnQ=
|
||||
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/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/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=
|
||||
@@ -504,8 +473,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-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
|
||||
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
|
||||
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/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=
|
||||
@@ -531,8 +500,6 @@ github.com/gtsteffaniak/go-cache v1.1.0 h1:ni/2cJpCxpGmaMZd92iTDIGOy+yUcMcxQuW7b
|
||||
github.com/gtsteffaniak/go-cache v1.1.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
|
||||
github.com/gtsteffaniak/go-logger v1.0.2 h1:S4AgR7yDu2geD8y2D9NyJVOBqpZRnl37r2mBi7GDNR0=
|
||||
github.com/gtsteffaniak/go-logger v1.0.2/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
|
||||
github.com/gtsteffaniak/go-push v0.1.0 h1:bMkin7Ce2xjTllPMk5q3IAxD2ZcMDGZICuQeeKDJDXo=
|
||||
github.com/gtsteffaniak/go-push v0.1.0/go.mod h1:xZplgyS4bJdYmLbfEBbkETdrN/vQn6M/tim72fX2s1c=
|
||||
github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
|
||||
github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q=
|
||||
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo=
|
||||
@@ -540,8 +507,6 @@ github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1T
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4=
|
||||
github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
@@ -565,6 +530,8 @@ github.com/jingyugao/rowserrcheck v1.1.1 h1:zibz55j/MJtLsjP1OF4bSdgXxwL1b+Vn7Tjz
|
||||
github.com/jingyugao/rowserrcheck v1.1.1/go.mod h1:4yvlZSDb3IyDTUZJUmpZfm2Hwok+Dtp+nu2qOq+er9c=
|
||||
github.com/jjti/go-spancheck v0.6.5 h1:lmi7pKxa37oKYIMScialXUK6hP3iY5F1gu+mLBPgYB8=
|
||||
github.com/jjti/go-spancheck v0.6.5/go.mod h1:aEogkeatBrbYsyW6y5TgDfihCulDYciL1B7rG2vSsrU=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||
@@ -578,8 +545,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.5.1 h1:l7ANXU+Ex33LilVa283HNaf/sTzCrrht7D05k6T6nlc=
|
||||
github.com/jupiterrider/ffi v0.5.1/go.mod h1:x7xdNKo8h0AmLuXfswDUBxUsd2OqUP4ekC8sCnsmbvo=
|
||||
github.com/jupiterrider/ffi v0.6.0 h1:UX378KcZvH5c8qgLi9KL/bL82SZTHdRspZ+jj7bvBng=
|
||||
github.com/jupiterrider/ffi v0.6.0/go.mod h1:PqZ5Go6X9by8CIXgfprxfMPYmn8oT5m2O7AA56s64bY=
|
||||
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=
|
||||
@@ -591,8 +558,10 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811uiUXyI7ok=
|
||||
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
|
||||
github.com/kovidgoyal/imaging v1.7.2 h1:mmT6k6Az3mC6dbqdZ6Q9KQCdZFWTAQ+q97NyGZgJ/2c=
|
||||
github.com/kovidgoyal/imaging v1.7.2/go.mod h1:GdkCORjfZMMGFY0Pb7TDmRhj7PDhxF/QShKukSCj0VU=
|
||||
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
|
||||
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
|
||||
github.com/kovidgoyal/imaging v1.8.20 h1:74GZ7C2rIm3rqmGEjK1GvvPOOnJ0SS5iDOa6Flfo0b0=
|
||||
github.com/kovidgoyal/imaging v1.8.20/go.mod h1:d3phGYkTChGYkY4y++IjpHgUGhWGELDc2NEQAqxwZZg=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -611,8 +580,6 @@ github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0
|
||||
github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI=
|
||||
github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ=
|
||||
github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM=
|
||||
github.com/ldez/gomoddirectives v0.7.1 h1:FaULkvUIG36hj6chpwa+FdCNGZBsD7/fO+p7CCsM6pE=
|
||||
github.com/ldez/gomoddirectives v0.7.1/go.mod h1:auDNtakWJR1rC+YX7ar+HmveqXATBAyEK1KYpsIRW/8=
|
||||
github.com/ldez/gomoddirectives v0.8.0 h1:JqIuTtgvFC2RdH1s357vrE23WJF2cpDCPFgA/TWDGpk=
|
||||
github.com/ldez/gomoddirectives v0.8.0/go.mod h1:jutzamvZR4XYJLr0d5Honycp4Gy6GEg2mS9+2YX3F1Q=
|
||||
github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o=
|
||||
@@ -657,14 +624,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.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
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/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.12.0 h1:Q+/kkbbwerrVYPv9d9efaPGmAO/NsxwW/nE6ahpQaCU=
|
||||
github.com/mgechev/revive v1.12.0/go.mod h1:VXsY2LsTigk8XU9BpZauVLjVrhICMOV3k1lpB3CXrp8=
|
||||
github.com/mgechev/revive v1.13.0 h1:yFbEVliCVKRXY8UgwEO7EOYNopvjb1BFbmYqm9hZjBM=
|
||||
github.com/mgechev/revive v1.13.0/go.mod h1:efJfeBVCX2JUumNQ7dtOLDja+QKj9mYGgEZA7rt5u+0=
|
||||
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/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=
|
||||
@@ -696,8 +661,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.21.2 h1:khzWfm2/Br8ZemX8QM1pl72LwM+rMeW6VUbQ4rzh0Po=
|
||||
github.com/nunnatsa/ginkgolinter v0.21.2/go.mod h1:GItSI5fw7mCGLPmkvGYrr1kEetZe7B593jcyOpyabsY=
|
||||
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/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=
|
||||
@@ -708,11 +673,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.26.0 h1:1J4Wut1IlYZNEAWIV3ALrT9NfiaGW2cDCJQSFQMs/gE=
|
||||
github.com/onsi/ginkgo/v2 v2.26.0/go.mod h1:qhEywmzWTBUY88kfO0BRvX4py7scov9yR+Az2oavUzw=
|
||||
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
|
||||
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/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/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=
|
||||
@@ -734,12 +698,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/polyfloyd/go-errorlint v1.8.0 h1:DL4RestQqRLr8U4LygLw8g2DX6RN1eBJOpa2mzsrl1Q=
|
||||
github.com/polyfloyd/go-errorlint v1.8.0/go.mod h1:G2W0Q5roxbLCt0ZQbdoxQxXktTjwNyDbEaj3n7jvl4s=
|
||||
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=
|
||||
@@ -799,12 +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.22.10 h1:ntbBqdWXnu46DUOXn+R2SvPo3PiJCDugTCgTW2g4tQg=
|
||||
github.com/securego/gosec/v2 v2.22.10/go.mod h1:9UNjK3tLpv/w2b0+7r82byV43wCJDNtEDQMeS+H/g2w=
|
||||
github.com/securego/gosec/v2 v2.22.11-0.20251204091113-daccba6b93d7 h1:rZg6IGn0ySYZwCX8LHwZoYm03JhG/cVAJJ3O+u3Vclo=
|
||||
github.com/securego/gosec/v2 v2.22.11-0.20251204091113-daccba6b93d7/go.mod h1:9sr22NZO5Kfh7unW/xZxkGYTmj2484/fCiE54gw7UTY=
|
||||
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/securego/gosec/v2 v2.23.0 h1:h4TtF64qFzvnkqvsHC/knT7YC5fqyOCItlVR8+ptEBo=
|
||||
github.com/securego/gosec/v2 v2.23.0/go.mod h1:qRHEgXLFuYUDkI2T7W7NJAmOkxVhkR0x9xyHOIcMNZ0=
|
||||
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=
|
||||
@@ -813,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.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
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=
|
||||
@@ -825,8 +781,6 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
|
||||
@@ -839,8 +793,6 @@ github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ=
|
||||
github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI=
|
||||
github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0=
|
||||
github.com/ssgreg/nlreturn/v2 v2.2.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I=
|
||||
github.com/stbenjam/no-sprintf-host-port v0.2.0 h1:i8pxvGrt1+4G0czLr/WnmyH7zbZ8Bg8etvARQ1rpyl4=
|
||||
github.com/stbenjam/no-sprintf-host-port v0.2.0/go.mod h1:eL0bQ9PasS0hsyTyfTjjG+E80QIyPnBVQbYZyv20Jfk=
|
||||
github.com/stbenjam/no-sprintf-host-port v0.3.1 h1:AyX7+dxI4IdLBPtDbsGAyqiTSLpCP9hWRrXQDU4Cm/g=
|
||||
github.com/stbenjam/no-sprintf-host-port v0.3.1/go.mod h1:ODbZesTCHMVKthBHskvUUexdcNHAQRXk9NpSsL8p/HQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -850,7 +802,6 @@ 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=
|
||||
@@ -879,8 +830,6 @@ github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk
|
||||
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67/go.mod h1:mkjARE7Yr8qU23YcGMSALbIxTQ9r9QBVahQOBRfU460=
|
||||
github.com/timonwong/loggercheck v0.11.0 h1:jdaMpYBl+Uq9mWPXv1r8jc5fC3gyXx4/WGwTnnNKn4M=
|
||||
github.com/timonwong/loggercheck v0.11.0/go.mod h1:HEAWU8djynujaAVX7QI65Myb8qgfcZ1uKbdpg3ZzKl8=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.11.0 h1:BJSt36snX9+4WTIXeJ7nvHBQBcm1h2SjQMSlmQ6aFSU=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.11.0/go.mod h1:wFL9pDWDAbXhhPZZt+nG8Fu+h29TtnZ2MW6Lx4BRXIU=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0 h1:H/qQ1aNWz/eeIhxKAFvkfIA+N7YDvq6TWVFL27Of9is=
|
||||
github.com/tomarrell/wrapcheck/v2 v2.12.0/go.mod h1:AQhQuZd0p7b6rfW+vUwHm5OMCGgp63moQ9Qr/0BpIWo=
|
||||
github.com/tommy-muehle/go-mnd/v2 v2.5.1 h1:NowYhSdyE/1zwK9QCLeRb6USWdoif80Ie+v+yU8u1Zw=
|
||||
@@ -903,6 +852,8 @@ 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=
|
||||
@@ -928,8 +879,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.3.1 h1:n2E6p8f+zfXSFLa2e2WqFPp4bfvcuRdd50y6cT65pSo=
|
||||
go.augendre.info/arangolint v0.3.1/go.mod h1:6ZKzEzIZuBQwoSvlKT+qpUfIbBfFCE5gbAoTg0/117g=
|
||||
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/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=
|
||||
@@ -940,8 +891,6 @@ 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=
|
||||
@@ -959,8 +908,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.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
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=
|
||||
@@ -971,16 +920,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-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
|
||||
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
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/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-20251023183803-a4bb9ffd2546 h1:HDjDiATsGqvuqvkDvgJjD1IgPrVekcSXVVE21JwvzGE=
|
||||
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
|
||||
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/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.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
|
||||
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
|
||||
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/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=
|
||||
@@ -1006,8 +955,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.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
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=
|
||||
@@ -1050,16 +999,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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
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.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.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=
|
||||
@@ -1121,7 +1070,6 @@ 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=
|
||||
@@ -1130,8 +1078,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.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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/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=
|
||||
@@ -1148,8 +1096,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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
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/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=
|
||||
@@ -1205,8 +1153,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.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
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/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=
|
||||
@@ -1312,7 +1260,6 @@ 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=
|
||||
@@ -1322,22 +1269,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.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
|
||||
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
|
||||
honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
|
||||
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
|
||||
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.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/ccgo/v4 v4.31.0 h1:/bsaxqdgX3gy/0DboxcvWrc3NpzH+6wpFfI/ZaA/hrg=
|
||||
modernc.org/ccgo/v4 v4.31.0/go.mod h1:jKe8kPBjIN/VdGTVqARTQ8N1gAziBmiISY8j5HoKwjg=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
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.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
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/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
|
||||
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/libc v1.69.0 h1:YQJ5QMSReTgQ3QFmI0dudfjXIjCcYTUxcH8/9P9f0D8=
|
||||
modernc.org/libc v1.69.0/go.mod h1:YfLLduUEbodNV2xLU5JOnRHBTAHVHsVW3bVYGw0ZCV4=
|
||||
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=
|
||||
@@ -1346,8 +1293,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.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
|
||||
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/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,7 +8,6 @@ import (
|
||||
libError "errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -398,10 +397,6 @@ 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,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -394,13 +393,10 @@ func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions, stats
|
||||
|
||||
for _, fileInfo := range checksumGroup.Files {
|
||||
// Remove the user scope from path
|
||||
adjustedPath := strings.TrimPrefix(fileInfo.Path, opts.combinedPath)
|
||||
if adjustedPath == "" {
|
||||
adjustedPath = "/"
|
||||
}
|
||||
|
||||
adjustedPath := "/" + strings.TrimPrefix(fileInfo.Path, opts.combinedPath)
|
||||
resultGroup = append(resultGroup, &indexing.SearchResult{
|
||||
Path: adjustedPath,
|
||||
Source: opts.source,
|
||||
Type: fileInfo.Type,
|
||||
Size: fileInfo.Size,
|
||||
Modified: fileInfo.ModTime.Format(time.RFC3339),
|
||||
@@ -559,7 +555,10 @@ func mergeGroupsByChecksum(groups []duplicateGroupWithChecksums) []duplicateGrou
|
||||
|
||||
func prepDuplicatesOptions(r *http.Request, d *requestContext) (*duplicatesOptions, error) {
|
||||
source := r.URL.Query().Get("source")
|
||||
scope := r.URL.Query().Get("scope")
|
||||
scope, err := utils.SanitizeUserPath(r.URL.Query().Get("scope"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid scope: %v", err)
|
||||
}
|
||||
|
||||
minSizeMbStr := r.URL.Query().Get("minSizeMb")
|
||||
// First pass uses filename matching for speed, then checksums verify final groups
|
||||
@@ -568,19 +567,14 @@ 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
|
||||
|
||||
unencodedScope, err := url.PathUnescape(scope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
|
||||
searchScope := strings.TrimPrefix(unencodedScope, ".")
|
||||
searchScope := strings.TrimPrefix(scope, ".")
|
||||
|
||||
index := indexing.GetIndex(source)
|
||||
if index == nil {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -131,23 +130,18 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
path := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
linesStr := r.URL.Query().Get("lines")
|
||||
|
||||
// Decode URL-encoded path
|
||||
path, err := url.QueryUnescape(encodedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
source, err = url.QueryUnescape(source)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
|
||||
}
|
||||
|
||||
if path == "" || source == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("path and source are required")
|
||||
}
|
||||
var err error
|
||||
path, err = utils.SanitizeUserPath(path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
|
||||
// Parse lines parameter
|
||||
lines := 10 // default
|
||||
@@ -282,25 +276,23 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to read file content")
|
||||
}
|
||||
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
path := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
linesStr := r.URL.Query().Get("lines")
|
||||
intervalStr := r.URL.Query().Get("interval")
|
||||
|
||||
// Decode URL-encoded path
|
||||
path, err := url.QueryUnescape(encodedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
source, err = url.QueryUnescape(source)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
|
||||
}
|
||||
|
||||
var err error
|
||||
if path == "" || source == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("path and source are required")
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
|
||||
@@ -6,15 +6,16 @@ import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
_ "net/http/pprof"
|
||||
|
||||
"github.com/coreos/go-systemd/v22/activation"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/events"
|
||||
@@ -27,14 +28,9 @@ import (
|
||||
//go:embed embed/*
|
||||
var assets embed.FS
|
||||
|
||||
// Custom dirFS to handle both embedded and non-embedded file systems
|
||||
type dirFS struct {
|
||||
http.Dir
|
||||
}
|
||||
|
||||
// Implement the Open method for dirFS, which wraps http.Dir
|
||||
func (d dirFS) Open(name string) (fs.File, error) {
|
||||
return d.Dir.Open(name)
|
||||
// GetEmbeddedAssets returns the embedded assets filesystem
|
||||
func GetEmbeddedAssets() embed.FS {
|
||||
return assets
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -56,20 +52,10 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
}()
|
||||
}
|
||||
|
||||
// Determine filesystem mode and set asset paths
|
||||
if settings.Env.EmbeddedFs {
|
||||
// Embedded mode: Serve files from the embedded assets
|
||||
assetFs, err = fs.Sub(assets, "embed")
|
||||
if err != nil {
|
||||
logger.Fatalf("fs.Sub failed: %v", err)
|
||||
}
|
||||
entries, err := fs.ReadDir(assetFs, ".")
|
||||
if err != nil || len(entries) == 0 {
|
||||
logger.Fatalf("Could not embed frontend. Does dist exist? %v", err)
|
||||
}
|
||||
} else {
|
||||
// Dev mode: Serve files from http/dist directory
|
||||
assetFs = dirFS{Dir: http.Dir("http/dist")}
|
||||
// Get the asset filesystem from fileutils
|
||||
assetFs = fileutils.GetAssetFS()
|
||||
if assetFs == nil {
|
||||
logger.Fatal("Asset filesystem not initialized. Call fileutils.InitAssetFS first.")
|
||||
}
|
||||
|
||||
// In development mode, we want to reload the templates on each request.
|
||||
@@ -88,6 +74,9 @@ 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()
|
||||
@@ -209,14 +198,10 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
publicRoutes.HandleFunc("GET /share/", withOrWithoutUser(indexHandler))
|
||||
|
||||
// Static and index file handlers
|
||||
staticPrefix := config.Server.BaseURL + "static/"
|
||||
router.Handle(staticPrefix, http.HandlerFunc(staticAssetHandler))
|
||||
publicRoutes.Handle("GET /static/", http.HandlerFunc(staticAssetHandler))
|
||||
|
||||
// Standard browser favicon and manifest routes
|
||||
// Standard browser favicon route
|
||||
router.HandleFunc("GET /favicon.svg", http.HandlerFunc(staticAssetHandler))
|
||||
router.HandleFunc("GET /site.webmanifest", http.HandlerFunc(staticAssetHandler))
|
||||
router.HandleFunc("GET /manifest.json", http.HandlerFunc(staticAssetHandler))
|
||||
|
||||
router.HandleFunc(config.Server.BaseURL, withOrWithoutUser(indexHandler))
|
||||
router.HandleFunc(fmt.Sprintf("GET %vhealth", config.Server.BaseURL), healthHandler)
|
||||
|
||||
@@ -4,46 +4,39 @@ import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"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 extraction requests
|
||||
// @Summary Extract embedded subtitles
|
||||
// @Description Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content
|
||||
// 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
|
||||
// @Tags Subtitles
|
||||
// @Accept json
|
||||
// @Produce text/vtt
|
||||
// @Produce text/plain
|
||||
// @Param path query string true "Index path to the video file"
|
||||
// @Param source query string true "Source name for the desired source"
|
||||
// @Param index query int false "Stream index for embedded subtitle extraction, defaults to 0"
|
||||
// @Success 200 {string} string "Raw WebVTT subtitle content"
|
||||
// @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"
|
||||
// @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) {
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
path := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
indexParam := r.URL.Query().Get("index")
|
||||
name := r.URL.Query().Get("name")
|
||||
embedded := r.URL.Query().Get("embedded") == "true"
|
||||
|
||||
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)
|
||||
if name == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("name parameter is required")
|
||||
}
|
||||
|
||||
userscope, err := d.user.GetScopeForSourceName(source)
|
||||
@@ -55,28 +48,54 @@ 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)
|
||||
}
|
||||
metadata, exists := idx.GetMetadataInfo(userscope, true, false)
|
||||
if !exists {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
// Return raw content with appropriate content type
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", "inline")
|
||||
w.Header().Set("Cache-Control", "private")
|
||||
http.ServeContent(w, r, fmt.Sprintf("%s-%d.vtt", subtitle.Name, index),
|
||||
time.Now(), bytes.NewReader([]byte(subtitle.Content)))
|
||||
http.ServeContent(w, r, name, time.Now(), bytes.NewReader([]byte(content)))
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -26,6 +25,7 @@ import (
|
||||
|
||||
type requestContext struct {
|
||||
user *users.User
|
||||
shareUser *users.User
|
||||
fileInfo iteminfo.ExtendedFileInfo
|
||||
token string
|
||||
share *share.Link
|
||||
@@ -51,12 +51,7 @@ 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")
|
||||
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)
|
||||
}
|
||||
path := r.URL.Query().Get("path")
|
||||
|
||||
// Get the file link by hash
|
||||
link, err := store.Share.GetByHash(hash)
|
||||
@@ -96,7 +91,6 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
if source.Config.Private {
|
||||
return http.StatusForbidden, fmt.Errorf("the target source is private")
|
||||
}
|
||||
|
||||
// Get file information with options
|
||||
getContent := r.URL.Query().Get("content") == "true"
|
||||
getMetadata := r.URL.Query().Get("metadata") == "true"
|
||||
@@ -104,37 +98,42 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
if link.DisableFileViewer || reachedDownloadsLimit {
|
||||
getContent = false
|
||||
}
|
||||
shareCreatedByUser, err := store.Users.Get(link.UserID)
|
||||
data.shareUser, 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 := shareCreatedByUser.GetScopeForSourceName(source.Name)
|
||||
userScope, err := data.shareUser.GetScopeForSourceName(source.Name)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
// so trim user scope from link.Path
|
||||
userScopedPath := utils.JoinPathAsUnix("/", strings.TrimPrefix(link.Path, userScope), path)
|
||||
if !strings.HasSuffix(userScopedPath, "/") {
|
||||
userScopedPath = userScopedPath + "/"
|
||||
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)
|
||||
}
|
||||
data.IndexPath = userScopedPath
|
||||
file, err := FileInfoFasterFunc(utils.FileOptions{
|
||||
Path: userScopedPath,
|
||||
Path: pathWithoutUserScope,
|
||||
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, shareCreatedByUser)
|
||||
}, store.Access, data.shareUser)
|
||||
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 = ""
|
||||
file.Source = link.Hash
|
||||
file.Hash = link.Hash
|
||||
if !link.EnableOnlyOffice || link.DisableFileViewer || reachedDownloadsLimit {
|
||||
file.OnlyOfficeId = ""
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
@@ -44,6 +45,8 @@ func (u *userInfoUnmarshaller) UnmarshalJSON(data []byte) error {
|
||||
if u.groupsClaim != "" {
|
||||
if v, ok := raw[u.groupsClaim]; ok {
|
||||
switch val := v.(type) {
|
||||
case map[string]interface{}:
|
||||
u.userInfo.Groups = slices.Collect(maps.Keys(val))
|
||||
case []interface{}:
|
||||
groups := make([]string, len(val))
|
||||
for i, g := range val {
|
||||
|
||||
@@ -80,6 +80,14 @@ 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
|
||||
@@ -102,7 +110,8 @@ 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)
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
var fileInfo *iteminfo.ExtendedFileInfo
|
||||
fileInfo, err = files.FileInfoFaster(utils.FileOptions{
|
||||
Path: path,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
@@ -255,17 +264,13 @@ func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token strin
|
||||
baseURL = fmt.Sprintf("%s://%s%s", scheme, host, settings.Config.Server.BaseURL)
|
||||
}
|
||||
|
||||
var downloadURL string
|
||||
escapedPath := url.QueryEscape(path)
|
||||
downloadURL := fmt.Sprintf("%s/api/raw?file=%s&auth=%s",
|
||||
strings.TrimSuffix(baseURL, "/"), escapedPath, token)
|
||||
if hash != "" {
|
||||
// Share download URL - don't expose source name, just use the path relative to share
|
||||
filesParam := url.QueryEscape(path)
|
||||
downloadURL = fmt.Sprintf("%s/public/api/raw?files=%s&hash=%s&token=%s&auth=%s",
|
||||
strings.TrimSuffix(baseURL, "/"), filesParam, hash, token, token)
|
||||
downloadURL = downloadURL + "&hash=" + hash
|
||||
} else {
|
||||
// Regular download URL - include source for non-share requests
|
||||
filesParam := url.QueryEscape(source + "::" + path)
|
||||
downloadURL = fmt.Sprintf("%s/api/raw?files=%s&auth=%s",
|
||||
strings.TrimSuffix(baseURL, "/"), filesParam, token)
|
||||
downloadURL = downloadURL + "&source=" + url.QueryEscape(source)
|
||||
}
|
||||
|
||||
return downloadURL
|
||||
@@ -340,6 +345,15 @@ 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 != "" {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -15,7 +16,6 @@ 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"
|
||||
@@ -79,17 +79,13 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
d.fileInfo = *fileInfo
|
||||
status, err := previewHelperFunc(w, r, d)
|
||||
if err != nil {
|
||||
logger.Errorf("error getting preview: %v", err)
|
||||
// Error already logged in previewHelperFunc or its callees
|
||||
return errToStatus(err), err
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
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
|
||||
@@ -161,7 +157,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") {
|
||||
if !(previewSize == "large" || previewSize == "original" || previewSize == "xlarge") {
|
||||
previewSize = "small"
|
||||
}
|
||||
if !d.fileInfo.HasPreview {
|
||||
@@ -182,9 +178,13 @@ 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 (!resizable || config.Server.DisableResize) && isImage {
|
||||
|
||||
// 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 {
|
||||
return rawFileHandler(w, r, d.fileInfo)
|
||||
}
|
||||
|
||||
seekPercentage := 0
|
||||
percentage := r.URL.Query().Get("atPercentage")
|
||||
if percentage != "" {
|
||||
@@ -201,7 +201,7 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
|
||||
officeUrl := ""
|
||||
if d.fileInfo.OnlyOfficeId != "" {
|
||||
pathUrl := fmt.Sprintf("/api/raw?files=%s::%s", d.fileInfo.Source, d.fileInfo.Path)
|
||||
pathUrl := fmt.Sprintf("/api/raw?file=%s&source=%s", url.QueryEscape(d.fileInfo.Path), url.QueryEscape(d.fileInfo.Source))
|
||||
pathUrl = pathUrl + "&auth=" + d.token
|
||||
if settings.Config.Server.InternalUrl != "" {
|
||||
officeUrl = config.Server.InternalUrl + pathUrl
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
@@ -22,11 +21,15 @@ import (
|
||||
// publicRawHandler serves the raw content of a file, multiple files, or directory via a public share.
|
||||
// @Summary Download files from a public share
|
||||
// @Description Downloads raw content from a public share. Supports single files, multiple files, or directories as archives. Enforces download limits (global or per-user) and blocks anonymous users when per-user limits are enabled.
|
||||
// @Description
|
||||
// @Description **Multiple Files:**
|
||||
// @Description - Use repeated query parameters: `?file=file1.txt&file=file2.txt&file=file3.txt`
|
||||
// @Description - This supports filenames containing commas and special characters
|
||||
// @Tags Public Shares
|
||||
// @Accept json
|
||||
// @Produce octet-stream
|
||||
// @Param hash query string true "Share hash for authentication"
|
||||
// @Param files query string true "Comma-separated list of file paths. Example: 'file1.txt,folder/file2.txt'"
|
||||
// @Param file query []string true "File path (can be repeated for multiple files)"
|
||||
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
|
||||
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
|
||||
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
|
||||
@@ -71,12 +74,11 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if d.share.PerUserDownloadLimit {
|
||||
d.share.IncrementUserDownload(d.user.Username)
|
||||
}
|
||||
encodedFiles := r.URL.Query().Get("files")
|
||||
|
||||
// Decode the URL-encoded path - use PathUnescape to preserve + as literal character
|
||||
f, err := url.PathUnescape(encodedFiles)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
// Get all "file" parameter values (supports repeated params)
|
||||
files := r.URL.Query()["file"]
|
||||
if len(files) == 0 {
|
||||
files = []string{"/"}
|
||||
}
|
||||
|
||||
// Get the actual source name from the share's source mapping
|
||||
@@ -86,22 +88,27 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
}
|
||||
actualSourceName := sourceInfo.Name
|
||||
|
||||
// Parse comma-separated file list
|
||||
// Process each file path
|
||||
fileList := []string{}
|
||||
for _, file := range strings.Split(f, ",") {
|
||||
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)
|
||||
}
|
||||
|
||||
// Join the share path with the requested path
|
||||
filePath := utils.JoinPathAsUnix(d.share.Path, file)
|
||||
filePath := utils.JoinPathAsUnix(d.share.Path, cleanFile)
|
||||
fileList = append(fileList, filePath)
|
||||
}
|
||||
|
||||
var status int
|
||||
status, err = rawFilesHandler(w, r, d, actualSourceName, fileList)
|
||||
status, err := rawFilesHandler(w, r, d, actualSourceName, fileList)
|
||||
if err != nil {
|
||||
if err == errors.ErrDownloadNotAllowed {
|
||||
return http.StatusForbidden, errors.ErrDownloadNotAllowed
|
||||
}
|
||||
logger.Errorf("public share handler: error processing filelist: '%v' with error %v", f, err)
|
||||
return status, fmt.Errorf("error processing filelist: %v", f)
|
||||
logger.Errorf("public share handler: error processing filelist: %v with error %v", files, err)
|
||||
return status, fmt.Errorf("error processing filelist: %v", files)
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
@@ -136,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 targetPath query string true "Target path within the share to upload to. Must be relative to share root."
|
||||
// @Param path query string true "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"
|
||||
@@ -152,21 +159,15 @@ 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")
|
||||
}
|
||||
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)
|
||||
// Go automatically decodes query params
|
||||
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", fullPath)
|
||||
q.Set("path", d.IndexPath)
|
||||
r.URL.RawQuery = q.Encode()
|
||||
status, err := resourcePostHandler(w, r, d)
|
||||
if err != nil {
|
||||
@@ -216,8 +217,6 @@ func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
if d.share.ShareType == "upload" {
|
||||
return http.StatusNotImplemented, fmt.Errorf("preview is disabled for upload shares")
|
||||
}
|
||||
d.fileInfo.Source = d.share.Source
|
||||
d.fileInfo.Path = utils.JoinPathAsUnix(d.share.Path, r.URL.Query().Get("path"))
|
||||
status, err := previewHelperFunc(w, r, d)
|
||||
if err != nil {
|
||||
logger.Errorf("public preview handler: error getting preview with error %v", err)
|
||||
@@ -247,23 +246,25 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if !d.share.AllowModify {
|
||||
return http.StatusForbidden, fmt.Errorf("create is not allowed for this share")
|
||||
}
|
||||
source, err := d.share.GetSourceName()
|
||||
if err != nil {
|
||||
sourceName := d.share.GetSourceName()
|
||||
if sourceName == "" {
|
||||
return http.StatusNotFound, fmt.Errorf("source not available")
|
||||
}
|
||||
|
||||
if !d.share.AllowModify {
|
||||
return http.StatusForbidden, fmt.Errorf("edit permission not allowed for this share")
|
||||
}
|
||||
// Go automatically decodes query params
|
||||
path := r.URL.Query().Get("path")
|
||||
|
||||
// Decode the URL-encoded path
|
||||
path, err = url.QueryUnescape(path)
|
||||
// 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 encoding: %v", err)
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
resolvedPath := utils.JoinPathAsUnix(d.share.Path, path)
|
||||
err = files.WriteFile(source, resolvedPath, r.Body)
|
||||
|
||||
resolvedPath := utils.JoinPathAsUnix(d.share.Path, cleanPath)
|
||||
err = files.WriteFile(sourceName, resolvedPath, r.Body)
|
||||
// hide the error
|
||||
if err != nil {
|
||||
logger.Errorf("public put handler: error updating resource with error %v", err)
|
||||
@@ -272,44 +273,26 @@ 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: indexPath,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
ShowHidden: d.share.ShowHidden,
|
||||
}, store.Access, d.user)
|
||||
Path: d.IndexPath,
|
||||
Source: d.share.Source,
|
||||
}, store.Access, d.shareUser)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("resource not available")
|
||||
}
|
||||
|
||||
// delete thumbnails
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
|
||||
err = files.DeleteFiles(source, fileInfo.RealPath, fileInfo.Type == "directory")
|
||||
err = files.DeleteFiles(d.share.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
|
||||
}
|
||||
|
||||
@@ -331,10 +314,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
|
||||
@@ -361,8 +344,8 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
}
|
||||
|
||||
// Get the source from the share
|
||||
source, err := d.share.GetSourceName()
|
||||
if err != nil {
|
||||
sourceName := d.share.GetSourceName()
|
||||
if sourceName == "" {
|
||||
return http.StatusNotFound, fmt.Errorf("source not available")
|
||||
}
|
||||
|
||||
@@ -388,9 +371,9 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
// Note: Share paths are absolute, so we don't strip user scope here
|
||||
// resourcePatchHandler will skip adding scope for shares
|
||||
for i := range req.Items {
|
||||
req.Items[i].FromSource = source
|
||||
req.Items[i].FromSource = sourceName
|
||||
req.Items[i].FromPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].FromPath)
|
||||
req.Items[i].ToSource = source
|
||||
req.Items[i].ToSource = sourceName
|
||||
req.Items[i].ToPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].ToPath)
|
||||
}
|
||||
d.Data = req
|
||||
@@ -409,15 +392,15 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
return status, err
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// @Tags Public Shares
|
||||
// @Produce octet-stream
|
||||
// @Produce image/jpeg
|
||||
// @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} binary "Image file content"
|
||||
// @Success 200 {file} file "Preview image content (JPEG)"
|
||||
// @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"
|
||||
@@ -462,9 +445,24 @@ func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid file type, must be image")
|
||||
}
|
||||
|
||||
// Serve the file
|
||||
http.ServeFile(w, r, fileInfo.RealPath)
|
||||
return 0, nil
|
||||
// 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
|
||||
}
|
||||
|
||||
func getShareImagePartsHelper(share *share.Link, isBanner bool) (string, string, error) {
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
@@ -94,10 +93,14 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
|
||||
// @Description - The Content-Disposition header will always include both:
|
||||
// @Description 1. `filename="..."`: An ASCII-safe version of the filename for compatibility.
|
||||
// @Description 2. `filename*=utf-8"...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.
|
||||
// @Description
|
||||
// @Description **Multiple Files:**
|
||||
// @Description - Use repeated query parameters: `?file=file1.txt&file=file2.txt&file=file3.txt`
|
||||
// @Description - This supports filenames containing commas and special characters
|
||||
// @Tags Resources
|
||||
// @Accept json
|
||||
// @Param source query string true "Source name for the files (required)"
|
||||
// @Param files query string true "Comma-separated list of file paths (required)"
|
||||
// @Param file query []string true "File path (can be repeated for multiple files)"
|
||||
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
|
||||
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
|
||||
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
|
||||
@@ -108,8 +111,17 @@ func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName stri
|
||||
// @Router /api/raw [get]
|
||||
func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
source := r.URL.Query().Get("source")
|
||||
filesParam := r.URL.Query().Get("files")
|
||||
fileList := strings.Split(filesParam, ",")
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -130,16 +142,6 @@ func addFile(source string, path string, d *requestContext, tarWriter *tar.Write
|
||||
}
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
_, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Path: path,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
FollowSymlinks: true,
|
||||
}, store.Access, d.user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
realPath, _, _ := idx.GetRealPath(path)
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
@@ -271,7 +273,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to download")
|
||||
}
|
||||
|
||||
if len(fileList) == 0 {
|
||||
if len(fileList) == 0 && d.share == nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("no files specified")
|
||||
}
|
||||
|
||||
@@ -285,6 +287,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
var documentId string
|
||||
var logContext *OnlyOfficeLogContext
|
||||
|
||||
// modify all filepaths for user scope
|
||||
if d.share == nil {
|
||||
userscope, err = d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
@@ -306,8 +309,11 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
}
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
firstFilePath = utils.JoinPathAsUnix(userscope, firstFilePath)
|
||||
for i, filePath := range fileList {
|
||||
fileList[i] = utils.JoinPathAsUnix(userscope, filePath)
|
||||
}
|
||||
}
|
||||
firstFilePath = fileList[0]
|
||||
// For shares, the path is already correctly resolved by publicRawHandler
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
@@ -492,7 +498,7 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
_, err = io.Copy(w, reader)
|
||||
os.Remove(archiveData) // Remove the file after streaming
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to copy archive data to response: %v", err)
|
||||
logger.Debugf("client likely aborted: %v, failed to copy archive data to response: %v", r.Context().Err(), err)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
@@ -506,30 +512,17 @@ func computeArchiveSize(source string, fileList []string, d *requestContext) (in
|
||||
return 0, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
|
||||
var userScope string
|
||||
var err error
|
||||
if d.share == nil {
|
||||
userScope, err = d.user.GetScopeForSourceName(source)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range fileList {
|
||||
var fullPath string
|
||||
if d.share == nil {
|
||||
fullPath = utils.JoinPathAsUnix(userScope, path)
|
||||
// Check access control for each file in the archive
|
||||
// Silently skip if access is denied (as if the file doesn't exist)
|
||||
if store.Access != nil && !store.Access.Permitted(idx.Path, fullPath, d.user.Username) {
|
||||
if store.Access != nil && !store.Access.Permitted(idx.Path, path, d.user.Username) {
|
||||
continue // Skip this file and continue with the next one
|
||||
}
|
||||
} else {
|
||||
fullPath = path
|
||||
}
|
||||
|
||||
// For shares, the path is already correctly resolved by publicRawHandler
|
||||
realPath, isDir, err := idx.GetRealPath(fullPath)
|
||||
realPath, isDir, err := idx.GetRealPath(path)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -555,11 +548,11 @@ func createZip(d *requestContext, source string, tmpDirPath string, filenames ..
|
||||
|
||||
zipWriter := zip.NewWriter(file)
|
||||
|
||||
for _, fname := range filenames {
|
||||
err := addFile(source, fname, d, nil, zipWriter, false)
|
||||
for _, filepath := range filenames {
|
||||
err := addFile(source, filepath, d, nil, zipWriter, false)
|
||||
if err != nil {
|
||||
// Access control failures return nil, so any error here is a real error
|
||||
logger.Errorf("Failed to add %s to ZIP: %v", fname, err)
|
||||
logger.Errorf("Failed to add %s to ZIP: %v", filepath, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -582,10 +575,10 @@ func createTarGz(d *requestContext, source string, tmpDirPath string, filenames
|
||||
gzWriter := gzip.NewWriter(file)
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
|
||||
for _, fname := range filenames {
|
||||
err := addFile(source, fname, d, tarWriter, nil, false)
|
||||
for _, filepath := range filenames {
|
||||
err := addFile(source, filepath, d, tarWriter, nil, false)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to add %s to TAR.GZ: %v", fname, err)
|
||||
logger.Errorf("Failed to add %s to TAR.GZ: %v", filepath, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
@@ -71,17 +70,8 @@ func validateMoveOperation(src, dst string, isSrcDir bool) error {
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/resources [get]
|
||||
func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
path := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
// Decode the URL-encoded path
|
||||
path, err := url.QueryUnescape(encodedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
source, err = url.QueryUnescape(source)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
|
||||
}
|
||||
getContent := r.URL.Query().Get("content") == "true"
|
||||
getMetadata := r.URL.Query().Get("metadata") == "true"
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
@@ -136,20 +126,9 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to delete")
|
||||
}
|
||||
|
||||
// TODO source := r.URL.Query().Get("source")
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
path := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
var err error
|
||||
// decode url encoded source name
|
||||
source, err = url.QueryUnescape(source)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
|
||||
}
|
||||
// Decode the URL-encoded path
|
||||
path, err := url.QueryUnescape(encodedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
|
||||
if path == "/" {
|
||||
return http.StatusForbidden, fmt.Errorf("cannot delete your user's root directory")
|
||||
}
|
||||
@@ -224,6 +203,10 @@ 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 {
|
||||
@@ -269,24 +252,35 @@ 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 {
|
||||
sourceName := d.share.GetSourceName()
|
||||
if sourceName == "" {
|
||||
return http.StatusNotFound, fmt.Errorf("source not available")
|
||||
}
|
||||
// get user scope path from share
|
||||
userScope, err := d.shareUser.GetScopeForSourceName(sourceName)
|
||||
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,
|
||||
Source: sourceName,
|
||||
ShowHidden: true,
|
||||
}, store.Access, d.user)
|
||||
}, store.Access, filePermUser)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("resource not available")
|
||||
}
|
||||
|
||||
// Delete the file/directory
|
||||
err = files.DeleteFiles(source, fileInfo.RealPath, fileInfo.Type == "directory")
|
||||
err = files.DeleteFiles(sourceName, fileInfo.RealPath, fileInfo.Type == "directory")
|
||||
if err != nil {
|
||||
logger.Errorf("resource bulk delete handler: error deleting file/directory: %v", err)
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
@@ -310,7 +304,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
}
|
||||
|
||||
// Check user scope for this source
|
||||
_, err := d.user.GetScopeForSourceName(item.Source)
|
||||
_, err := filePermUser.GetScopeForSourceName(item.Source)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
@@ -336,7 +330,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
Path: idx.MakeIndexPath(item.Path, false),
|
||||
Source: item.Source,
|
||||
ShowHidden: true,
|
||||
}, store.Access, d.user)
|
||||
}, store.Access, filePermUser)
|
||||
if err != nil {
|
||||
response.Failed = append(response.Failed, BulkDeleteItem{
|
||||
Source: item.Source,
|
||||
@@ -375,7 +369,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, a directory must end in / to create a directory"
|
||||
// @Param path query string true "url encoded destination path where to place the files inside the destination source"
|
||||
// @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"
|
||||
@@ -387,35 +381,23 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
// @Router /api/resources [post]
|
||||
func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
path := r.URL.Query().Get("path")
|
||||
unescapedPath := path
|
||||
source := r.URL.Query().Get("source")
|
||||
var err error
|
||||
accessStore := store.Access
|
||||
// if share is not nil, then set accessStore to nil
|
||||
if d.share != nil {
|
||||
accessStore = nil
|
||||
} else {
|
||||
// decode url encoded source name
|
||||
source, err = url.QueryUnescape(source)
|
||||
if err != nil {
|
||||
logger.Debugf("invalid source encoding: %v", err)
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
|
||||
}
|
||||
unescapedPath, err = url.QueryUnescape(path)
|
||||
if err != nil {
|
||||
logger.Debugf("invalid path encoding: %v", err)
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
if !d.user.Permissions.Create {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
|
||||
}
|
||||
// Path is now handled by FileInfoFaster which will apply user scope
|
||||
path = unescapedPath
|
||||
}
|
||||
|
||||
// Determine if this is a directory based on isDir query param or trailing slash (for backwards compatibility)
|
||||
isDirParam := r.URL.Query().Get("isDir")
|
||||
isDir := isDirParam == "true" || strings.HasSuffix(unescapedPath, "/")
|
||||
// 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
|
||||
if d.share != nil {
|
||||
filePermUser = d.shareUser
|
||||
}
|
||||
isDir := r.URL.Query().Get("isDir") == "true"
|
||||
fileOpts := utils.FileOptions{
|
||||
Path: path,
|
||||
Source: source,
|
||||
@@ -427,10 +409,21 @@ 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)
|
||||
}
|
||||
realPath, _, _ := idx.GetRealPath(path)
|
||||
|
||||
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)
|
||||
|
||||
// Check access control for the target path
|
||||
if accessStore != nil && !accessStore.Permitted(idx.Path, path, d.user.Username) {
|
||||
if !store.Access.Permitted(idx.Path, path, filePermUser.Username) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
|
||||
}
|
||||
|
||||
@@ -449,14 +442,6 @@ 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
|
||||
@@ -502,7 +487,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
}
|
||||
|
||||
var fileInfo *iteminfo.ExtendedFileInfo
|
||||
fileInfo, err = files.FileInfoFaster(fileOpts, accessStore, d.user)
|
||||
fileInfo, err = files.FileInfoFaster(fileOpts, store.Access, filePermUser)
|
||||
if err == nil { // File exists
|
||||
if r.URL.Query().Get("override") != "true" {
|
||||
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
|
||||
@@ -552,23 +537,16 @@ 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 = fileutils.MoveFile(tempFilePath, realPath)
|
||||
err = files.MoveResource(false, source, source, tempFilePath, realPath, store.Share, store.Access)
|
||||
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, accessStore, d.user)
|
||||
fileInfo, err := files.FileInfoFaster(fileOpts, store.Access, filePermUser)
|
||||
if err == nil { // File exists
|
||||
if r.URL.Query().Get("override") != "true" {
|
||||
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
|
||||
@@ -578,13 +556,6 @@ 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)
|
||||
@@ -609,20 +580,15 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// @Router /api/resources [put]
|
||||
func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
source := r.URL.Query().Get("source")
|
||||
var err error
|
||||
// decode url encoded source name
|
||||
source, err = url.QueryUnescape(source)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid source encoding: %v", err)
|
||||
}
|
||||
path := r.URL.Query().Get("path")
|
||||
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
|
||||
// Decode the URL-encoded path
|
||||
path, err := url.QueryUnescape(encodedPath)
|
||||
// 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 encoding: %v", err)
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
|
||||
}
|
||||
path = cleanPath
|
||||
|
||||
// Only allow PUT for files.
|
||||
if strings.HasSuffix(path, "/") {
|
||||
return http.StatusMethodNotAllowed, nil
|
||||
@@ -690,6 +656,7 @@ 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"
|
||||
@@ -702,26 +669,26 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
response.Failed = append(response.Failed, item)
|
||||
continue
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
cleanFromPath, err := utils.SanitizeUserPath(item.FromPath)
|
||||
if err != nil {
|
||||
item.Message = fmt.Sprintf("invalid fromPath: %v", err)
|
||||
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"
|
||||
@@ -767,6 +734,11 @@ 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) {
|
||||
@@ -793,7 +765,9 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
}
|
||||
|
||||
// Get real paths
|
||||
realSrc, isSrcDir, err := srcIdx.GetRealPath(userscopeSrc, item.FromPath)
|
||||
// Combine user scope with item paths BEFORE calling GetRealPath to avoid double scope application
|
||||
fullSrcPath := utils.JoinPathAsUnix(userscopeSrc, item.FromPath)
|
||||
realSrc, isSrcDir, err := srcIdx.GetRealPath(fullSrcPath)
|
||||
if err != nil {
|
||||
logger.Errorf("could not resolve source path: %v, item.FromPath: %v", err, item.FromPath)
|
||||
item.Message = "could not resolve source path"
|
||||
@@ -808,7 +782,9 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
}
|
||||
|
||||
// Check destination parent directory exists
|
||||
parentDir, _, err := dstIdx.GetRealPath(userscopeDst, filepath.Dir(item.ToPath))
|
||||
dstParentPath := filepath.Dir(item.ToPath)
|
||||
fullDstParentPath := utils.JoinPathAsUnix(userscopeDst, dstParentPath)
|
||||
parentDir, _, err := dstIdx.GetRealPath(fullDstParentPath)
|
||||
if err != nil {
|
||||
item.Message = "destination directory does not exist"
|
||||
if d.share != nil {
|
||||
@@ -938,6 +914,13 @@ func patchAction(ctx context.Context, params patchActionParams) error {
|
||||
case "rename", "move":
|
||||
idx := indexing.GetIndex(params.srcIndex)
|
||||
srcPath := idx.MakeIndexPath(params.src, params.isSrcDir)
|
||||
userScope := ""
|
||||
userScope, _ = params.d.user.GetScopeForSourceName(params.srcIndex)
|
||||
if userScope != "" && userScope != "/" {
|
||||
// Strip the user scope from srcPath so FileInfoFaster doesn't double it
|
||||
srcPath = strings.TrimPrefix(srcPath, userScope)
|
||||
}
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
FollowSymlinks: true,
|
||||
Path: srcPath,
|
||||
@@ -959,10 +942,8 @@ func patchAction(ctx context.Context, params patchActionParams) error {
|
||||
}
|
||||
|
||||
func inspectIndex(w http.ResponseWriter, r *http.Request) {
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
path := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
// Decode the URL-encoded path
|
||||
path, _ := url.QueryUnescape(encodedPath)
|
||||
isNotDir := r.URL.Query().Get("isDir") == "false" // default to isDir true
|
||||
index := indexing.GetIndex(source)
|
||||
if index == nil {
|
||||
|
||||
@@ -3,7 +3,6 @@ package http
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -121,8 +120,13 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
|
||||
query := r.URL.Query().Get("query")
|
||||
sourcesParam := r.URL.Query().Get("sources")
|
||||
sourceParam := r.URL.Query().Get("source") // deprecated, but still supported
|
||||
scope := r.URL.Query().Get("scope")
|
||||
scope := r.URL.Query().Get("scope") // Go automatically decodes query params
|
||||
largest := r.URL.Query().Get("largest") == "true"
|
||||
cleanScope, err := utils.SanitizeUserPath(scope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid scope: %v", err)
|
||||
}
|
||||
scope = cleanScope
|
||||
|
||||
var sources []string
|
||||
if sourcesParam != "" {
|
||||
@@ -141,14 +145,10 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
|
||||
}
|
||||
}
|
||||
|
||||
unencodedScope, err := url.PathUnescape(scope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
if len(query) < settings.Config.Server.MinSearchLength && !largest {
|
||||
return nil, fmt.Errorf("query is too short, minimum length is %d", settings.Config.Server.MinSearchLength)
|
||||
}
|
||||
searchScope := strings.TrimPrefix(unencodedScope, ".")
|
||||
searchScope := strings.TrimPrefix(scope, ".")
|
||||
|
||||
// If multiple sources, always use user scope (ignore searchScope)
|
||||
if len(sources) > 1 {
|
||||
|
||||
@@ -33,14 +33,10 @@ type ShareResponse struct {
|
||||
}
|
||||
|
||||
// convertToFrontendShareResponse converts shares to response format with usernames
|
||||
func convertToFrontendShareResponse(r *http.Request, shares []*share.Link) ([]*ShareResponse, error) {
|
||||
func convertToFrontendShareResponse(r *http.Request, shares []*share.Link, user *users.User) ([]*ShareResponse, error) {
|
||||
responses := make([]*ShareResponse, 0, len(shares))
|
||||
for _, s := range shares {
|
||||
user, err := store.Users.Get(s.UserID)
|
||||
username := ""
|
||||
if err == nil {
|
||||
username = user.Username
|
||||
}
|
||||
username := user.Username
|
||||
|
||||
// Get source info to convert path to name for frontend
|
||||
sourceInfo, ok := config.Server.SourceMap[s.Source]
|
||||
@@ -65,6 +61,9 @@ func convertToFrontendShareResponse(r *http.Request, shares []*share.Link) ([]*S
|
||||
s.CommonShare.HasPassword = s.HasPassword()
|
||||
s.DownloadURL = getShareURL(r, s.Hash, true, s.Token)
|
||||
s.ShareURL = getShareURL(r, s.Hash, false, s.Token)
|
||||
if s.UserCanEdit(user) {
|
||||
s.CommonShare.SourceURL = s.SourceURL(user)
|
||||
}
|
||||
// Create response with source name (overrides the embedded Link's source field)
|
||||
responses = append(responses, &ShareResponse{
|
||||
Link: s,
|
||||
@@ -97,7 +96,7 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
shares = utils.NonNilSlice(shares)
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, shares)
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, shares, d.user)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -116,13 +115,8 @@ 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) {
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
path := 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)
|
||||
@@ -142,7 +136,7 @@ func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error getting share info from server")
|
||||
}
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, s)
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, s, d.user)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -167,7 +161,16 @@ func shareDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
return http.StatusBadRequest, nil
|
||||
}
|
||||
|
||||
err := store.Share.Delete(hash)
|
||||
// only allow users to delete their own shares
|
||||
thisShare, err := store.Share.GetByHash(hash)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("share not found")
|
||||
}
|
||||
if thisShare.UserID != d.user.ID && !d.user.Permissions.Admin {
|
||||
return http.StatusForbidden, fmt.Errorf("you are not allowed to delete this share")
|
||||
}
|
||||
|
||||
err = store.Share.Delete(hash)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
@@ -201,8 +204,16 @@ func sharePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
return http.StatusBadRequest, fmt.Errorf("hash and path are required")
|
||||
}
|
||||
|
||||
// only allow users to update their own shares
|
||||
thisShare, err := store.Share.GetByHash(body.Hash)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("share not found")
|
||||
}
|
||||
if thisShare.UserID != d.user.ID && !d.user.Permissions.Admin {
|
||||
return http.StatusForbidden, fmt.Errorf("you are not allowed to update this share")
|
||||
}
|
||||
// Update the share path
|
||||
err := store.Share.UpdateSharePath(body.Hash, body.Path)
|
||||
err = store.Share.UpdateSharePath(body.Hash, body.Path)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -214,7 +225,7 @@ func sharePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{updatedShare})
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{updatedShare}, d.user)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -363,7 +374,14 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
providedPath := body.Path
|
||||
body.Path = utils.JoinPathAsUnix(userscope, providedPath)
|
||||
|
||||
// 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.AddTrailingSlashIfNotExists(body.Path)
|
||||
// validate path exists as file or folder
|
||||
_, _, err = idx.GetRealPath(body.Path)
|
||||
@@ -387,7 +405,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if err = store.Share.Save(s); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{s})
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{s}, d.user)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -420,23 +438,17 @@ type DirectDownloadResponse struct {
|
||||
// @Router /api/share/direct [get]
|
||||
func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// Extract query parameters
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
path := r.URL.Query().Get("path")
|
||||
source := r.URL.Query().Get("source")
|
||||
duration := r.URL.Query().Get("duration")
|
||||
downloadCountStr := r.URL.Query().Get("count")
|
||||
downloadSpeedStr := r.URL.Query().Get("speed")
|
||||
|
||||
// Validate required parameters
|
||||
if encodedPath == "" || source == "" {
|
||||
if path == "" || source == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("path and source are required")
|
||||
}
|
||||
|
||||
// Decode the URL-encoded path
|
||||
path, err := url.QueryUnescape(encodedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
|
||||
// Validate source exists
|
||||
sourceInfo, ok := config.Server.NameToSource[source]
|
||||
if !ok {
|
||||
@@ -614,8 +626,7 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
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)
|
||||
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)
|
||||
@@ -626,6 +637,26 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
}
|
||||
commonShare.Source = ""
|
||||
commonShare.Path = ""
|
||||
commonShare.DownloadURL = ""
|
||||
commonShare.SidebarLinks = []users.SidebarLink{}
|
||||
for _, link := range shareLink.SidebarLinks {
|
||||
if link.Category == "download" && shareLink.ShareType == "upload" {
|
||||
continue
|
||||
} else {
|
||||
commonShare.SidebarLinks = append(commonShare.SidebarLinks, link)
|
||||
}
|
||||
}
|
||||
commonShare.SourceURL = shareLink.SourceURL(d.user)
|
||||
commonShare.CanEdit = shareLink.UserCanEdit(d.user)
|
||||
if commonShare.SourceURL != "" {
|
||||
link := users.SidebarLink{
|
||||
Name: "sourceLocation",
|
||||
Category: "custom",
|
||||
Target: commonShare.SourceURL,
|
||||
}
|
||||
commonShare.SidebarLinks = append(commonShare.SidebarLinks, link)
|
||||
}
|
||||
|
||||
return renderJSON(w, r, commonShare)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ package http
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
)
|
||||
|
||||
var templateRenderer *TemplateRenderer
|
||||
var cachedManifest PWAManifest // generated at startup
|
||||
|
||||
type TemplateRenderer struct {
|
||||
templates *template.Template
|
||||
@@ -23,10 +25,18 @@ type TemplateRenderer struct {
|
||||
|
||||
// Render renders a template document with headers and data
|
||||
func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data interface{}) error {
|
||||
// If in dev mode, reload templates on every render.
|
||||
templates := t.templates
|
||||
// If in dev mode, reload templates on every render by creating a fresh instance
|
||||
if t.devMode {
|
||||
var err error
|
||||
t.templates, err = t.templates.ParseFS(assetFs, "public/index.html")
|
||||
templates = template.New("").Funcs(template.FuncMap{
|
||||
"marshal": func(v interface{}) (string, error) {
|
||||
var a []byte
|
||||
a, err = json.Marshal(v)
|
||||
return string(a), err
|
||||
},
|
||||
})
|
||||
templates, err = templates.ParseFS(assetFs, "public/index.html")
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reloading template: %w", err)
|
||||
}
|
||||
@@ -37,7 +47,7 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
|
||||
w.Header().Set("X-Accel-Expires", "0")
|
||||
w.Header().Set("Transfer-Encoding", "identity")
|
||||
// Execute the template with the provided data
|
||||
return t.templates.ExecuteTemplate(w, name, data)
|
||||
return templates.ExecuteTemplate(w, name, data)
|
||||
}
|
||||
|
||||
func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestContext, file, contentType string) (int, error) {
|
||||
@@ -73,11 +83,16 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
staticURL := config.Server.BaseURL + "public/static"
|
||||
description := config.Frontend.Description
|
||||
title := config.Frontend.Name
|
||||
banner := staticURL + "/img/icons/android-chrome-512x512.png"
|
||||
banner := staticURL + "/pwa-icon-512.png" // Use largest generated icon for best quality
|
||||
disableSidebar := false
|
||||
|
||||
// Use custom favicon if configured and validated, otherwise fall back to default
|
||||
favicon := staticURL + "/favicon"
|
||||
// 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
|
||||
shareHash := ""
|
||||
data := make(map[string]interface{})
|
||||
disableNavButtons := settings.Config.Frontend.DisableNavButtons
|
||||
@@ -104,7 +119,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
if d.share.DisableSidebar {
|
||||
disableSidebar = true
|
||||
}
|
||||
if strings.HasPrefix(d.share.Banner, "http") {
|
||||
if strings.HasPrefix(strings.ToLower(d.share.Banner), "http") {
|
||||
banner = d.share.Banner
|
||||
} else {
|
||||
_, _, err := getShareImagePartsHelper(d.share, true)
|
||||
@@ -112,7 +127,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(d.share.Favicon, "http") {
|
||||
if strings.HasPrefix(strings.ToLower(d.share.Favicon), "http") {
|
||||
favicon = d.share.Favicon
|
||||
} else {
|
||||
_, _, err := getShareImagePartsHelper(d.share, false)
|
||||
@@ -127,18 +142,17 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
|
||||
// Load loading spinners CSS from static files
|
||||
loadingSpinnersCSS := ""
|
||||
cssPath := "static/css/loadingSpinners.css"
|
||||
cssPath := "css/loadingSpinners.css"
|
||||
cssContent, err := fs.ReadFile(assetFs, cssPath)
|
||||
if err == nil {
|
||||
loadingSpinnersCSS = string(cssContent)
|
||||
}
|
||||
|
||||
// Determine OpenGraph image: use banner if set, otherwise use dedicated OG image or fallback to app icon
|
||||
// Determine OpenGraph image: use banner if set, otherwise use largest available icon (512x512)
|
||||
ogImage := banner
|
||||
if banner == staticURL+"/img/icons/android-chrome-512x512.png" {
|
||||
// No custom banner set - check if dedicated OG image exists, otherwise use 512x512 icon
|
||||
// Note: 512x512 is square; OpenGraph prefers 1200x630 (1.91:1 ratio) for optimal display
|
||||
ogImage = staticURL + "/img/icons/android-chrome-512x512.png"
|
||||
if banner == staticURL+"/pwa-icon-512.png" {
|
||||
// Note: 512x512 is square; OpenGraph prefers 1200x630 (1.91:1 ratio) but square works fine
|
||||
ogImage = staticURL + "/pwa-icon-512.png"
|
||||
}
|
||||
|
||||
// Construct the full URL for the current request
|
||||
@@ -155,10 +169,27 @@ 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,
|
||||
"userSelectedTheme": userSelectedTheme,
|
||||
"customCSS": template.CSS(config.Frontend.Styling.CustomCSSRaw),
|
||||
"userSelectedTheme": template.CSS(userSelectedTheme),
|
||||
"lightBackground": config.Frontend.Styling.LightBackground,
|
||||
"darkBackground": config.Frontend.Styling.DarkBackground,
|
||||
"staticURL": staticURL,
|
||||
@@ -166,13 +197,17 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"favicon": favicon,
|
||||
"loginIcon": loginIcon,
|
||||
"color": defaultThemeColor,
|
||||
"winIcon": staticURL + "/img/icons/mstile-144x144.png",
|
||||
"appIcon": staticURL + "/img/icons/android-chrome-512x512.png",
|
||||
"winIcon": staticURL + "/mstile-256x256.png",
|
||||
"appIcon": staticURL + "/apple-touch-icon.png",
|
||||
"description": description,
|
||||
"loadingSpinnersCSS": loadingSpinnersCSS,
|
||||
"loadingSpinnersCSS": template.CSS(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{}{
|
||||
@@ -197,11 +232,12 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"updateAvailable": utils.GetUpdateAvailableUrl(),
|
||||
"disableNavButtons": disableNavButtons,
|
||||
"userSelectableThemes": config.Frontend.Styling.CustomThemeOptions,
|
||||
"enableHeicConversion": config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview] && settings.MediaEnabled(),
|
||||
"enableHeicConversion": settings.CanConvertImage("heic"),
|
||||
"eventBasedThemes": !config.Frontend.Styling.DisableEventBasedThemes,
|
||||
"loginIcon": loginIcon,
|
||||
"disableSidebar": disableSidebar,
|
||||
"shareHash": shareHash,
|
||||
"oidcLoginButtonText": config.Frontend.OIDCLoginButtonText,
|
||||
}
|
||||
|
||||
// Marshal each variable to JSON strings for direct template usage
|
||||
@@ -210,8 +246,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
return http.StatusInternalServerError, fmt.Errorf("error marshaling globalVars: %w", err)
|
||||
}
|
||||
|
||||
// Replace with JSON strings for direct template usage
|
||||
data["globalVars"] = string(globalVarsJSON)
|
||||
// Mark as JS for html/template to avoid escaping
|
||||
data["globalVars"] = template.JS(globalVarsJSON)
|
||||
|
||||
// Render the template with global variables
|
||||
if err := templateRenderer.Render(w, file, data); err != nil {
|
||||
@@ -249,6 +285,16 @@ 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
|
||||
@@ -264,18 +310,47 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Handle special routes that need path mapping
|
||||
var assetPath string
|
||||
switch path {
|
||||
case "favicon.svg", "favicon":
|
||||
// Handle custom favicon from filesystem
|
||||
if settings.Env.FaviconIsCustom {
|
||||
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" {
|
||||
http.ServeFile(w, r, settings.Env.FaviconPath)
|
||||
return
|
||||
}
|
||||
// Use embedded default
|
||||
// Use embedded default SVG
|
||||
assetPath = settings.Env.FaviconEmbeddedPath
|
||||
case "manifest.json":
|
||||
assetPath = "img/icons/manifest.json"
|
||||
case "site.webmanifest":
|
||||
assetPath = "img/icons/site.webmanifest"
|
||||
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 "loginIcon":
|
||||
// Handle custom login icon from filesystem
|
||||
if settings.Env.LoginIconIsCustom {
|
||||
@@ -328,3 +403,87 @@ 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,8 +21,18 @@ import (
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/auth/otp/generate [post]
|
||||
func generateOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
logger.Debug("Generating OTP for user:", d.user.Username)
|
||||
url, err := auth.GenerateOtpForUser(d.user, store.Users)
|
||||
username := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
username = d.user.Username
|
||||
}
|
||||
if username != d.user.Username && !d.user.Permissions.Admin {
|
||||
return http.StatusForbidden, fmt.Errorf("you are not authorized to generate OTP for this user")
|
||||
}
|
||||
targetUser, err := store.Users.Get(username)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
url, err := auth.GenerateOtpForUser(targetUser, store.Users)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error generating OTP secret: %w", err)
|
||||
}
|
||||
@@ -51,7 +61,21 @@ func verifyOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
if code == "" {
|
||||
return http.StatusUnauthorized, fmt.Errorf("code is required")
|
||||
}
|
||||
err := auth.VerifyTotpCode(d.user, code, store.Users)
|
||||
|
||||
targetUser := d.user
|
||||
username := r.URL.Query().Get("username")
|
||||
|
||||
// If admin is verifying for another user
|
||||
if username != "" && username != d.user.Username && d.user.Permissions.Admin {
|
||||
var err error
|
||||
targetUser, err = store.Users.Get(username)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
logger.Debugf("Admin %s verifying OTP for user: %s", d.user.Username, targetUser.Username)
|
||||
}
|
||||
|
||||
err := auth.VerifyTotpCode(targetUser, code, store.Users)
|
||||
if err != nil {
|
||||
return http.StatusUnauthorized, fmt.Errorf("invalid OTP token")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
|
||||
@@ -91,6 +92,83 @@ func prepForFrontend(u *users.User) {
|
||||
u.TOTPNonce = ""
|
||||
u.Scopes = u.GetFrontendScopes()
|
||||
u.SidebarLinks = u.GetFrontendSidebarLinks()
|
||||
u.Locale = normalizeLocale(u.Locale)
|
||||
}
|
||||
|
||||
// normalizeLocale converts various locale formats (xx_xx, xx-xx, xxxx) to camelCase format (xxXX)
|
||||
// Frontend expects camelCase for compound locales (zhCN, ptBR, etc.) as shown in select option values
|
||||
func normalizeLocale(locale string) string {
|
||||
if locale == "" {
|
||||
return locale
|
||||
}
|
||||
|
||||
// Convert to lowercase for processing
|
||||
lower := strings.ToLower(locale)
|
||||
|
||||
// Special case mappings (standard locale codes to frontend camelCase format)
|
||||
specialCases := map[string]string{
|
||||
"cs": "cz", // Czech
|
||||
"uk": "ua", // Ukrainian
|
||||
"zh-cn": "zhCN",
|
||||
"zh_cn": "zhCN",
|
||||
"zhcn": "zhCN",
|
||||
"zh-tw": "zhTW",
|
||||
"zh_tw": "zhTW",
|
||||
"zhtw": "zhTW",
|
||||
"pt-br": "ptBR",
|
||||
"pt_br": "ptBR",
|
||||
"ptbr": "ptBR",
|
||||
"sv-se": "svSE",
|
||||
"sv_se": "svSE",
|
||||
"svse": "svSE",
|
||||
"nl-be": "nlBE",
|
||||
"nl_be": "nlBE",
|
||||
"nlbe": "nlBE",
|
||||
}
|
||||
|
||||
// Check special cases first
|
||||
if normalized, ok := specialCases[lower]; ok {
|
||||
return normalized
|
||||
}
|
||||
|
||||
// If already in camelCase format (4+ chars, has uppercase), return as-is
|
||||
if len(locale) >= 4 {
|
||||
// Check if it's a known camelCase locale
|
||||
knownCamelCase := map[string]bool{
|
||||
"zhCN": true, "zhTW": true, "ptBR": true, "svSE": true, "nlBE": true,
|
||||
}
|
||||
if knownCamelCase[locale] {
|
||||
return locale
|
||||
}
|
||||
}
|
||||
|
||||
// Handle xx_xx or xx-xx format: convert to xxXX (camelCase)
|
||||
parts := strings.FieldsFunc(lower, func(r rune) bool {
|
||||
return r == '_' || r == '-'
|
||||
})
|
||||
|
||||
if len(parts) == 2 {
|
||||
// Convert to camelCase: first part lowercase, second part capitalized
|
||||
first := parts[0]
|
||||
second := parts[1]
|
||||
if len(second) > 0 {
|
||||
second = strings.ToUpper(second[:1]) + second[1:]
|
||||
}
|
||||
normalized := first + second
|
||||
|
||||
// Check if this matches a known compound locale
|
||||
knownCompound := map[string]string{
|
||||
"zhcn": "zhCN", "zhtw": "zhTW", "ptbr": "ptBR",
|
||||
"svse": "svSE", "nlbe": "nlBE",
|
||||
}
|
||||
if normalizedVal, ok := knownCompound[normalized]; ok {
|
||||
return normalizedVal
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// Single part locale (en, fr, de, etc.) - return as-is (lowercase is fine)
|
||||
return lower
|
||||
}
|
||||
|
||||
// userDeleteHandler deletes a user by ID.
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
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...")
|
||||
// Set PWA icons directory in cache
|
||||
settings.Env.PWAIconsDir = filepath.Join(settings.Config.Server.CacheDir, "icons")
|
||||
|
||||
// Initialize with default paths (all icons will be generated)
|
||||
settings.Env.PWAIcon192 = "icons/pwa-icon-192.png"
|
||||
settings.Env.PWAIcon256 = "icons/pwa-icon-256.png"
|
||||
settings.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(settings.Env.PWAIconsDir, fileutils.PermDir); err != nil {
|
||||
logger.Warningf("Failed to create PWA icons directory %s: %v", settings.Env.PWAIconsDir, err)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
@@ -17,7 +17,7 @@ func setupTestIndexForDeletion(t *testing.T) *Index {
|
||||
// Initialize the database if not already done
|
||||
if indexDB == nil {
|
||||
var err error
|
||||
indexDB, err = dbsql.NewIndexDB("test_deletion", "OFF", 1000, 32, false)
|
||||
indexDB, _, err = dbsql.NewIndexDB("test_deletion", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ func TestProcessDirectoryItem(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
itemInfo, size, shouldCount := idx.processDirectoryItem(
|
||||
tt.file, tt.combinedPath,
|
||||
@@ -212,6 +212,7 @@ func TestProcessDirectoryItem(t *testing.T) {
|
||||
|
||||
if itemInfo == nil {
|
||||
t.Fatalf("expected non-nil itemInfo")
|
||||
return
|
||||
}
|
||||
|
||||
if size != tt.expectSize {
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
indexingdb "github.com/gtsteffaniak/filebrowser/backend/database/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
|
||||
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
@@ -118,8 +118,8 @@ type Index struct {
|
||||
var (
|
||||
indexes map[string]*Index
|
||||
indexesMutex sync.RWMutex
|
||||
indexDB *dbsql.IndexDB // Shared database for all indexes
|
||||
indexingStorage *indexingdb.Storage // Persistent storage for index metadata
|
||||
indexDB *dbsql.IndexDB // Shared database for all indexes
|
||||
indexingStorage *dbindex.Storage // Persistent storage for index metadata
|
||||
)
|
||||
|
||||
type IndexStatus string
|
||||
@@ -321,7 +321,8 @@ func (idx *Index) getStatusUnlocked() IndexStatus {
|
||||
}
|
||||
|
||||
// InitializeIndexDB creates the shared index database for all sources.
|
||||
func InitializeIndexDB() error {
|
||||
// Returns true if the database was recreated (either fresh or after corruption).
|
||||
func InitializeIndexDB() (bool, error) {
|
||||
var err error
|
||||
journalMode := "OFF"
|
||||
if settings.Config.Server.IndexSqlConfig.WalMode {
|
||||
@@ -330,13 +331,14 @@ func InitializeIndexDB() error {
|
||||
batchSize := settings.Config.Server.IndexSqlConfig.BatchSize
|
||||
cacheSizeMB := settings.Config.Server.IndexSqlConfig.CacheSizeMB
|
||||
disableReuse := settings.Config.Server.IndexSqlConfig.DisableReuse
|
||||
indexDB, err = dbsql.NewIndexDB("all", journalMode, batchSize, cacheSizeMB, disableReuse)
|
||||
var isNewDb bool
|
||||
indexDB, isNewDb, err = dbsql.NewIndexDB("all", journalMode, batchSize, cacheSizeMB, disableReuse)
|
||||
if err != nil {
|
||||
logger.Fatalf("failed to initialize index database: %v", err)
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
|
||||
return nil
|
||||
return isNewDb, nil
|
||||
}
|
||||
|
||||
// GetIndexDB returns the shared index database.
|
||||
@@ -364,11 +366,11 @@ func SetIndexDBForTesting(db *dbsql.IndexDB) {
|
||||
}
|
||||
|
||||
// SetIndexingStorage sets the persistent storage for index metadata.
|
||||
func SetIndexingStorage(storage *indexingdb.Storage) {
|
||||
func SetIndexingStorage(storage *dbindex.Storage) {
|
||||
indexingStorage = storage
|
||||
}
|
||||
|
||||
func Initialize(source *settings.Source, mock bool) {
|
||||
func Initialize(source *settings.Source, mock bool, isNewDb bool) {
|
||||
indexesMutex.Lock()
|
||||
// Use shared database - all sources are differentiated by the source column
|
||||
if indexDB == nil {
|
||||
@@ -397,7 +399,8 @@ func Initialize(source *settings.Source, mock bool) {
|
||||
if !newIndex.Config.DisableIndexing {
|
||||
logger.Infof("initializing index: [%v]", newIndex.Name)
|
||||
// Start multi-scanner system (each scanner will do its own initial scan)
|
||||
go newIndex.setupIndexingScanners()
|
||||
// Pass isNewDb flag so setupMultiScanner knows whether to load persisted complexity
|
||||
go newIndex.setupMultiScanner(isNewDb)
|
||||
} else {
|
||||
newIndex.Status = "ready"
|
||||
logger.Debug("indexing disabled for source: " + newIndex.Name)
|
||||
@@ -652,12 +655,12 @@ func (idx *Index) GetFsInfoCore(indexPath string, opts Options) (*iteminfo.FileI
|
||||
if !isViewable {
|
||||
return nil, errors.ErrNotIndexed
|
||||
}
|
||||
} else if isSkipped {
|
||||
return nil, errors.ErrNotIndexed
|
||||
}
|
||||
} else if isSkipped {
|
||||
return nil, errors.ErrNotIndexed
|
||||
}
|
||||
|
||||
realSize, _ := idx.handleFile(dirInfo, indexPath, realPath, false, nil)
|
||||
fileInfo := &iteminfo.FileInfo{
|
||||
realSize, _ := idx.handleFile(dirInfo, indexPath, realPath, false, nil)
|
||||
fileInfo := &iteminfo.FileInfo{
|
||||
Path: indexPath,
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: baseName,
|
||||
@@ -1289,11 +1292,19 @@ func setFilePreviewFlags(fileInfo *iteminfo.ItemInfo, realPath string) {
|
||||
simpleType := strings.Split(fileInfo.Type, "/")[0]
|
||||
switch fileInfo.Type {
|
||||
case "image/heic", "image/heif":
|
||||
if fileInfo.Size > iteminfo.LargeFileSizeThreshold {
|
||||
fileInfo.HasPreview = false
|
||||
return
|
||||
}
|
||||
fileInfo.HasPreview = settings.CanConvertImage("heic")
|
||||
return
|
||||
}
|
||||
switch simpleType {
|
||||
case "image":
|
||||
if fileInfo.Size > iteminfo.LargeFileSizeThreshold {
|
||||
fileInfo.HasPreview = false
|
||||
return
|
||||
}
|
||||
fileInfo.HasPreview = true
|
||||
return
|
||||
case "video":
|
||||
@@ -1613,9 +1624,9 @@ func (idx *Index) Save() error {
|
||||
|
||||
idx.mu.RLock()
|
||||
// Collect scanner information
|
||||
scanners := make(map[string]*indexingdb.PersistedScannerInfo)
|
||||
scanners := make(map[string]*dbindex.PersistedScannerInfo)
|
||||
for path, scanner := range idx.scanners {
|
||||
scanners[path] = &indexingdb.PersistedScannerInfo{
|
||||
scanners[path] = &dbindex.PersistedScannerInfo{
|
||||
Path: path,
|
||||
Complexity: scanner.complexity,
|
||||
CurrentSchedule: scanner.currentSchedule,
|
||||
@@ -1634,7 +1645,7 @@ func (idx *Index) Save() error {
|
||||
idx.mu.RUnlock()
|
||||
|
||||
// Create IndexInfo for persistence
|
||||
info := &indexingdb.IndexInfo{
|
||||
info := &dbindex.IndexInfo{
|
||||
Path: idx.Path, // Use real filesystem path as key
|
||||
Source: idx.Name,
|
||||
Complexity: complexity,
|
||||
@@ -1643,7 +1654,17 @@ func (idx *Index) Save() error {
|
||||
Scanners: scanners,
|
||||
}
|
||||
|
||||
return indexingStorage.Save(info)
|
||||
err := indexingStorage.Save(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Send SSE update event after successful save
|
||||
if err := idx.SendSourceUpdateEvent(); err != nil {
|
||||
logger.Errorf("[%s] Failed to send source update event: %v", idx.Name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load restores index and scanner information from the database
|
||||
|
||||
@@ -16,7 +16,7 @@ func setupTestIndex(t *testing.T) (*Index, string, func()) {
|
||||
// Initialize the database if not already done
|
||||
if indexDB == nil {
|
||||
var err error
|
||||
indexDB, err = dbsql.NewIndexDB("test_indexing", "OFF", 1000, 32, false)
|
||||
indexDB, _, err = dbsql.NewIndexDB("test_indexing", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
|
||||
@@ -395,7 +395,7 @@ func (s *Scanner) checkForNewChildDirectories() {
|
||||
_, exists := existingScanners[dirPath]
|
||||
if !exists && dirPath != "/" {
|
||||
logger.Debugf("Detected new directory, creating scanner: [%s]", dirPath)
|
||||
newScanner := s.idx.createChildScanner(dirPath)
|
||||
newScanner := s.idx.createScanner(dirPath)
|
||||
|
||||
s.idx.mu.Lock()
|
||||
s.idx.scanners[dirPath] = newScanner
|
||||
@@ -507,6 +507,7 @@ func (s *Scanner) updateSchedule() {
|
||||
// 0: unknown
|
||||
func (s *Scanner) updateComplexity() {
|
||||
s.complexity = calculateComplexity(s.fullScanTime, s.numDirs)
|
||||
|
||||
// Set smartModifier based on complexity level
|
||||
if modifier, ok := complexityModifier[s.complexity]; ok {
|
||||
s.smartModifier = modifier
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
indexingdb "github.com/gtsteffaniak/filebrowser/backend/database/indexing"
|
||||
indexingdb "github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/events"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
@@ -179,16 +179,11 @@ func (idx *Index) PostScan() error {
|
||||
logger.Errorf("Failed to shrink memory: %v", err)
|
||||
}
|
||||
|
||||
// Persist index and scanner information
|
||||
// Persist index and scanner information (SSE event will be sent by Save())
|
||||
if err := idx.Save(); err != nil {
|
||||
logger.Errorf("Failed to save index persistence: %v", err)
|
||||
}
|
||||
|
||||
// Send update event to notify frontend that stats may have changed
|
||||
if err := idx.SendSourceUpdateEvent(); err != nil {
|
||||
logger.Errorf("Error sending source update event: %v", err)
|
||||
}
|
||||
|
||||
// Clear scan session tracking when all scanners complete
|
||||
idx.mu.Lock()
|
||||
idx.scanSessionStartTime = 0
|
||||
@@ -202,7 +197,6 @@ func (idx *Index) PostScan() error {
|
||||
|
||||
func (idx *Index) SendSourceUpdateEvent() error {
|
||||
if idx.mock {
|
||||
logger.Debug("Skipping source update event for mock index.")
|
||||
return nil
|
||||
}
|
||||
reducedIndex, err := GetIndexInfo(idx.Name, true)
|
||||
@@ -224,7 +218,8 @@ func (idx *Index) SendSourceUpdateEvent() error {
|
||||
}
|
||||
|
||||
// setupMultiScanner creates and starts the multi-scanner system
|
||||
func (idx *Index) setupMultiScanner() {
|
||||
// isNewDb: if true, skip loading persisted complexity values (database is new or recreated)
|
||||
func (idx *Index) setupMultiScanner(isNewDb bool) {
|
||||
// Load persisted index and scanner information
|
||||
if err := idx.Load(); err != nil {
|
||||
logger.Errorf("Failed to load persisted index data for [%v]: %v", idx.Name, err)
|
||||
@@ -246,7 +241,7 @@ func (idx *Index) setupMultiScanner() {
|
||||
|
||||
// Load persisted scanner info if available
|
||||
var persistedScanners map[string]*indexingdb.PersistedScannerInfo
|
||||
if indexingStorage != nil {
|
||||
if indexingStorage != nil && !isNewDb {
|
||||
info, err := indexingStorage.GetByPath(idx.Path)
|
||||
if err == nil && info != nil {
|
||||
persistedScanners = info.Scanners
|
||||
@@ -254,8 +249,7 @@ func (idx *Index) setupMultiScanner() {
|
||||
}
|
||||
|
||||
// Create and start root scanner
|
||||
rootScanner := idx.createRootScanner()
|
||||
// Restore persisted stats for root scanner if available
|
||||
rootScanner := idx.createScanner("/")
|
||||
if persistedScanners != nil {
|
||||
if rootInfo, ok := persistedScanners["/"]; ok {
|
||||
rootScanner.complexity = rootInfo.Complexity
|
||||
@@ -277,9 +271,9 @@ func (idx *Index) setupMultiScanner() {
|
||||
|
||||
// Create child scanner for each top-level directory
|
||||
for _, dirPath := range topLevelDirs {
|
||||
childScanner := idx.createChildScanner(dirPath)
|
||||
childScanner := idx.createScanner(dirPath)
|
||||
|
||||
// Restore persisted stats for child scanner if available
|
||||
// Restore persisted stats for child scanner if available (and DB is not new)
|
||||
if persistedScanners != nil {
|
||||
if childInfo, ok := persistedScanners[dirPath]; ok {
|
||||
childScanner.complexity = childInfo.Complexity
|
||||
@@ -302,20 +296,8 @@ func (idx *Index) setupMultiScanner() {
|
||||
logger.Debugf("Created %d scanners for [%v] (1 root + %d children)", len(topLevelDirs)+1, idx.Name, len(topLevelDirs))
|
||||
}
|
||||
|
||||
// createRootScanner creates a scanner for the root directory (non-recursive)
|
||||
func (idx *Index) createRootScanner() *Scanner {
|
||||
return &Scanner{
|
||||
scanPath: "/",
|
||||
idx: idx,
|
||||
stopChan: make(chan struct{}),
|
||||
currentSchedule: 0,
|
||||
fullScanCounter: 0,
|
||||
complexity: 0, // 0 = unknown until first full scan completes
|
||||
}
|
||||
}
|
||||
|
||||
// createChildScanner creates a scanner for a specific child directory (recursive)
|
||||
func (idx *Index) createChildScanner(dirPath string) *Scanner {
|
||||
func (idx *Index) createScanner(dirPath string) *Scanner {
|
||||
return &Scanner{
|
||||
scanPath: dirPath,
|
||||
idx: idx,
|
||||
@@ -326,12 +308,6 @@ func (idx *Index) createChildScanner(dirPath string) *Scanner {
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy function kept for backwards compatibility - now deprecated
|
||||
func (idx *Index) setupIndexingScanners() {
|
||||
// Use new multi-scanner system
|
||||
idx.setupMultiScanner()
|
||||
}
|
||||
|
||||
// GetScannerStatus returns detailed information about all active scanners
|
||||
func (idx *Index) GetScannerStatus() map[string]interface{} {
|
||||
idx.mu.RLock()
|
||||
|
||||
@@ -13,7 +13,7 @@ func BenchmarkFillIndex(b *testing.B) {
|
||||
Initialize(&settings.Source{
|
||||
Name: "test",
|
||||
Path: "/srv",
|
||||
}, true)
|
||||
}, true, false) // mock=true, isNewDb=false
|
||||
idx := GetIndex("test")
|
||||
if idx == nil {
|
||||
b.Fatal("index is nil")
|
||||
@@ -58,7 +58,7 @@ func TestMultiScannerMutex(t *testing.T) {
|
||||
// Initialize the database if not already done
|
||||
if indexDB == nil {
|
||||
var err error
|
||||
indexDB, err = dbsql.NewIndexDB("test_multiscanner", "OFF", 1000, 32, false)
|
||||
indexDB, _, err = dbsql.NewIndexDB("test_multiscanner", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
@@ -79,9 +79,9 @@ func TestMultiScannerMutex(t *testing.T) {
|
||||
}
|
||||
|
||||
// Create multiple scanners
|
||||
scanner1 := idx.createRootScanner()
|
||||
scanner2 := idx.createChildScanner("/test1/")
|
||||
scanner3 := idx.createChildScanner("/test2/")
|
||||
scanner1 := idx.createScanner("/")
|
||||
scanner2 := idx.createScanner("/test1/")
|
||||
scanner3 := idx.createScanner("/test2/")
|
||||
|
||||
idx.scanners["/"] = scanner1
|
||||
idx.scanners["/test1/"] = scanner2
|
||||
|
||||
@@ -9,6 +9,10 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
)
|
||||
|
||||
const (
|
||||
LargeFileSizeThreshold = 75 * 1024 * 1024 // 75MB
|
||||
)
|
||||
|
||||
var AllFiletypeOptions = []string{
|
||||
"image",
|
||||
"audio",
|
||||
@@ -56,6 +60,11 @@ var ResizableImageTypes = map[string]bool{
|
||||
".tif": true,
|
||||
".heic": true,
|
||||
".heif": true,
|
||||
".webp": true,
|
||||
".pbm": true,
|
||||
".pgm": true,
|
||||
".ppm": true,
|
||||
".pam": true,
|
||||
".other": false,
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package iteminfo
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
)
|
||||
|
||||
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 string `json:"albumArt,omitempty"` // base64 encoded album art / video thumbnail
|
||||
AlbumArt []byte `json:"albumArt,omitempty"` // album art image data (automatically base64-encoded in JSON)
|
||||
}
|
||||
|
||||
// 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 []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:"-"`
|
||||
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:"-"`
|
||||
}
|
||||
@@ -8,19 +8,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
)
|
||||
|
||||
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"`
|
||||
@@ -31,20 +21,55 @@ type FFProbeOutput struct {
|
||||
} `json:"streams"`
|
||||
}
|
||||
|
||||
// detects subtitles for video files.
|
||||
func (i *ExtendedFileInfo) DetectSubtitles(parentInfo *FileInfo) {
|
||||
// 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) {
|
||||
if !strings.HasPrefix(i.Type, "video") {
|
||||
logger.Debug("subtitles are not supported for this file : " + i.Name)
|
||||
return
|
||||
}
|
||||
// Use unified subtitle detection that finds both embedded and external
|
||||
parentDir := filepath.Dir(i.RealPath)
|
||||
i.Subtitles = ffmpeg.DetectAllSubtitles(i.RealPath, parentDir, i.ModTime)
|
||||
}
|
||||
ext := filepath.Ext(i.Name)
|
||||
baseWithoutExt := strings.TrimSuffix(i.Name, ext)
|
||||
|
||||
// LoadSubtitleContent loads the actual content for all detected subtitle tracks
|
||||
func (i *ExtendedFileInfo) LoadSubtitleContent() error {
|
||||
return ffmpeg.LoadAllSubtitleContent(i.RealPath, i.Subtitles, 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
|
||||
}
|
||||
|
||||
func (info *FileInfo) SortItems() {
|
||||
|
||||
@@ -28,7 +28,7 @@ func setupMutateTestIndex(t *testing.T) *Index {
|
||||
}
|
||||
|
||||
var err error
|
||||
testDB, err := dbsql.NewIndexDB("test_init", "OFF", 1000, 32, false)
|
||||
testDB, _, err := dbsql.NewIndexDB("test_init", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
@@ -214,7 +214,7 @@ func TestUpdateFileMetadata(t *testing.T) {
|
||||
// Initialize the database if not already done
|
||||
if indexDB == nil {
|
||||
var err error
|
||||
indexDB, err = dbsql.NewIndexDB("test_mutate", "OFF", 1000, 32, false)
|
||||
indexDB, _, err = dbsql.NewIndexDB("test_mutate", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
@@ -274,7 +274,7 @@ func TestSetDirectoryInfo(t *testing.T) {
|
||||
// Initialize the database if not already done
|
||||
if indexDB == nil {
|
||||
var err error
|
||||
indexDB, err = dbsql.NewIndexDB("test_mutate", "OFF", 1000, 32, false)
|
||||
indexDB, _, err = dbsql.NewIndexDB("test_mutate", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestMain(m *testing.M) {
|
||||
}
|
||||
|
||||
func BenchmarkSearchAllIndexes(b *testing.B) {
|
||||
Initialize(&settings.Source{Name: "test", Path: "/srv"}, true)
|
||||
Initialize(&settings.Source{Name: "test", Path: "/srv"}, true, false) // mock=true, isNewDb=false
|
||||
idx := GetIndex("test")
|
||||
|
||||
idx.CreateMockData(50, 3) // 50 dirs, 3 files per dir
|
||||
@@ -118,7 +118,7 @@ func TestSearchWhileIndexing(t *testing.T) {
|
||||
// Initialize the database if not already done
|
||||
if indexDB == nil {
|
||||
var err error
|
||||
indexDB, err = dbsql.NewIndexDB("test_search_while", "OFF", 1000, 32, false)
|
||||
indexDB, _, err = dbsql.NewIndexDB("test_search_while", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
@@ -143,7 +143,7 @@ func TestSearchWhileIndexing(t *testing.T) {
|
||||
IndexingDisabled: false,
|
||||
}
|
||||
|
||||
Initialize(source, true)
|
||||
Initialize(source, true, false) // mock=true, isNewDb=false
|
||||
idx := GetIndex("test")
|
||||
if idx == nil {
|
||||
t.Fatal("Failed to get test index")
|
||||
@@ -181,7 +181,7 @@ func TestSearchIndexes(t *testing.T) {
|
||||
// Initialize the database if not already done
|
||||
if indexDB == nil {
|
||||
var err error
|
||||
indexDB, err = dbsql.NewIndexDB("test_search", "OFF", 1000, 32, false)
|
||||
indexDB, _, err = dbsql.NewIndexDB("test_search", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
@@ -462,7 +462,7 @@ func TestSearchLargestModeExcludesRoot(t *testing.T) {
|
||||
// Initialize the database if not already done
|
||||
if indexDB == nil {
|
||||
var err error
|
||||
indexDB, err = dbsql.NewIndexDB("test_search_largest", "OFF", 1000, 32, false)
|
||||
indexDB, _, err = dbsql.NewIndexDB("test_search_largest", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
@@ -558,7 +558,7 @@ func TestSearchLargestModeExcludesScopeDirectory(t *testing.T) {
|
||||
// Initialize the database if not already done
|
||||
if indexDB == nil {
|
||||
var err error
|
||||
indexDB, err = dbsql.NewIndexDB("test_search_scope", "OFF", 1000, 32, false)
|
||||
indexDB, _, err = dbsql.NewIndexDB("test_search_scope", "OFF", 1000, 32, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ userDefaults:
|
||||
deleteWithoutConfirming: false # delete files without confirmation
|
||||
disableOnlyOfficeExt: ".md .txt .pdf" # list of file extensions to disable onlyoffice editor for
|
||||
showSelectMultiple: false # show select multiple files on desktop
|
||||
defaultLandingPage: "" # default landing page to use if no redirect is specified: eg. /files/mysource/mysubpath, /settings, etc.
|
||||
|
||||
#office:
|
||||
# url: "http://localhost:9052"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 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
|
||||
return bytes.NewBuffer(make([]byte, 0, 128*1024))
|
||||
},
|
||||
}
|
||||
|
||||
// getBuffer gets a buffer from the pool
|
||||
func getBuffer() *bytes.Buffer {
|
||||
buf, ok := bufferPool.Get().(*bytes.Buffer)
|
||||
if !ok {
|
||||
// This should never happen, but provide a fallback
|
||||
buf = bytes.NewBuffer(make([]byte, 0, 128*1024))
|
||||
}
|
||||
buf.Reset()
|
||||
return buf
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
buf.Reset()
|
||||
bufferPool.Put(buf)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestBufferPool_GetPutBalanced verifies that getBuffer/putBuffer can be used
|
||||
// in a loop without leaking or panicking, and that buffers are returned to the pool.
|
||||
func TestBufferPool_GetPutBalanced(t *testing.T) {
|
||||
const iterations = 1000
|
||||
for i := 0; i < iterations; i++ {
|
||||
buf := getBuffer()
|
||||
require.NotNil(t, buf)
|
||||
require.NotNil(t, buf.Bytes())
|
||||
putBuffer(buf)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBufferPool_Reuse verifies that put buffers are reused (same capacity after get).
|
||||
func TestBufferPool_Reuse(t *testing.T) {
|
||||
buf1 := getBuffer()
|
||||
cap1 := buf1.Cap()
|
||||
putBuffer(buf1)
|
||||
|
||||
buf2 := getBuffer()
|
||||
cap2 := buf2.Cap()
|
||||
putBuffer(buf2)
|
||||
|
||||
require.Equal(t, cap1, cap2, "pool should return buffers with same capacity")
|
||||
require.GreaterOrEqual(t, cap1, 128*1024, "default buffer should be at least 128KB")
|
||||
}
|
||||
|
||||
// TestBufferPool_LargeBufferNotReused verifies that buffers that grew beyond 1MB
|
||||
// are not put back in the pool (to avoid retaining large allocations).
|
||||
func TestBufferPool_LargeBufferNotReused(t *testing.T) {
|
||||
buf := getBuffer()
|
||||
// Grow the buffer past the 1MB threshold
|
||||
growth := make([]byte, 2*1024*1024)
|
||||
_, _ = buf.Write(growth)
|
||||
require.Greater(t, buf.Cap(), 1024*1024)
|
||||
putBuffer(buf)
|
||||
|
||||
// After putBuffer, a large buffer is not pooled. Get several buffers and
|
||||
// ensure we still get valid 128KB-capacity buffers (pool wasn't polluted).
|
||||
for i := 0; i < 10; i++ {
|
||||
b := getBuffer()
|
||||
require.NotNil(t, b)
|
||||
// Pool buffers are 128KB cap; if we got a fresh one it's 128KB
|
||||
require.GreaterOrEqual(t, b.Cap(), 128*1024, "getBuffer should return usable buffer")
|
||||
putBuffer(b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBufferPool_ConcurrentGetPut stresses the pool under concurrent use (as in Resize).
|
||||
func TestBufferPool_ConcurrentGetPut(t *testing.T) {
|
||||
const goroutines = 50
|
||||
const perGoroutine = 20
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < perGoroutine; i++ {
|
||||
buf := getBuffer()
|
||||
_, _ = buf.Write([]byte("test"))
|
||||
putBuffer(buf)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -28,13 +28,7 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
// Acquire document semaphore
|
||||
if err := s.acquireDoc(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.releaseDoc()
|
||||
|
||||
// 1. Serialize access to the entire go-fitz operation block
|
||||
// Serialize access to the entire go-fitz operation block (required for CGO thread safety)
|
||||
s.docGenMutex.Lock()
|
||||
defer s.docGenMutex.Unlock()
|
||||
|
||||
|
||||
@@ -15,14 +15,7 @@ func docEnabled() bool {
|
||||
}
|
||||
|
||||
func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int) ([]byte, error) {
|
||||
// Acquire document semaphore
|
||||
if err := s.acquireDoc(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.releaseDoc()
|
||||
|
||||
s.docGenMutex.Lock()
|
||||
defer s.docGenMutex.Unlock()
|
||||
|
||||
// Reference it to prevent unused field warning when building without mupdf
|
||||
_ = &s.docGenMutex
|
||||
return nil, nil
|
||||
}
|
||||
@@ -8,21 +8,21 @@ import (
|
||||
// convertHEICToJPEGWithFFmpeg converts a HEIC file to JPEG format using FFmpeg
|
||||
// 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 {
|
||||
return nil, fmt.Errorf("FFmpeg is not available")
|
||||
}
|
||||
|
||||
// Check if context is cancelled before starting
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
// Use the shared image service
|
||||
if s.imageService == nil {
|
||||
return nil, fmt.Errorf("image service not available")
|
||||
}
|
||||
|
||||
// Acquire image service semaphore
|
||||
if err := s.imageService.Acquire(ctx); err != nil {
|
||||
// Acquire FFmpeg service semaphore
|
||||
if err := s.ffmpegService.Acquire(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.imageService.Release()
|
||||
defer s.ffmpegService.Release()
|
||||
|
||||
// Determine target dimensions and quality based on preview size
|
||||
var width, height int
|
||||
@@ -39,10 +39,46 @@ func (s *Service) convertHEICToJPEGWithFFmpeg(ctx context.Context, filePath stri
|
||||
width, height = 256, 256
|
||||
quality = "5" // Medium quality
|
||||
}
|
||||
|
||||
// Use tile-based conversion for correct full-resolution image reconstruction
|
||||
result, err := s.imageService.ConvertHEICToJPEG(ctx, filePath, width, height, quality)
|
||||
if err != nil {
|
||||
return s.ffmpegService.ConvertHEICToJPEG(ctx, filePath, width, height, quality)
|
||||
}
|
||||
|
||||
// convertImageWithFFmpeg converts any image file (including problematic JPEGs) to resized JPEG using FFmpeg
|
||||
// This is used as a fallback for JPEG files that Go's standard decoder can't handle (extended sequential, etc.)
|
||||
func (s *Service) convertImageWithFFmpeg(ctx context.Context, filePath string, previewSize string) ([]byte, error) {
|
||||
// Check if FFmpeg service is available
|
||||
if s.ffmpegService == nil {
|
||||
return nil, fmt.Errorf("FFmpeg is not available for JPEG fallback")
|
||||
}
|
||||
|
||||
// Check if context is cancelled before starting
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
// Acquire FFmpeg service semaphore
|
||||
if err := s.ffmpegService.Acquire(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
defer s.ffmpegService.Release()
|
||||
|
||||
// Determine target dimensions and quality based on preview size
|
||||
var width, height int
|
||||
var quality string
|
||||
switch previewSize {
|
||||
case "large":
|
||||
width, height = 640, 640
|
||||
quality = "2" // High quality for FFmpeg -q:v
|
||||
case "original":
|
||||
// For original size - no scaling, maximum quality
|
||||
width, height = 0, 0 // Signal to not apply scaling
|
||||
quality = "1" // Maximum quality for original
|
||||
default:
|
||||
width, height = 256, 256
|
||||
quality = "5" // Medium quality
|
||||
}
|
||||
|
||||
// Use direct conversion which works for all image formats including problematic JPEGs
|
||||
return s.ffmpegService.ConvertImageToJPEG(ctx, filePath, width, height, quality)
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
|
||||
exif "github.com/dsoprea/go-exif/v3"
|
||||
@@ -21,6 +20,11 @@ gif
|
||||
tiff
|
||||
bmp
|
||||
heic
|
||||
webp
|
||||
pbm
|
||||
pgm
|
||||
ppm
|
||||
pam
|
||||
)
|
||||
*/
|
||||
type Format int
|
||||
@@ -39,6 +43,16 @@ func (x Format) toImaging() imaging.Format {
|
||||
return imaging.BMP
|
||||
case FormatHeic:
|
||||
return imaging.JPEG
|
||||
case FormatWebp:
|
||||
return imaging.WEBP
|
||||
case FormatPbm:
|
||||
return imaging.PBM
|
||||
case FormatPgm:
|
||||
return imaging.PGM
|
||||
case FormatPpm:
|
||||
return imaging.PPM
|
||||
case FormatPam:
|
||||
return imaging.PAM
|
||||
default:
|
||||
return imaging.JPEG
|
||||
}
|
||||
@@ -56,7 +70,8 @@ type Quality int
|
||||
func (x Quality) resampleFilter() imaging.ResampleFilter {
|
||||
switch x {
|
||||
case QualityHigh:
|
||||
return imaging.Lanczos
|
||||
// Use CatmullRom instead of Lanczos - faster with similar quality for thumbnails
|
||||
return imaging.CatmullRom
|
||||
case QualityMedium:
|
||||
return imaging.Box
|
||||
case QualityLow:
|
||||
@@ -66,6 +81,20 @@ func (x Quality) resampleFilter() imaging.ResampleFilter {
|
||||
}
|
||||
}
|
||||
|
||||
// jpegQuality returns JPEG quality (1-100) based on Quality setting
|
||||
func (x Quality) jpegQuality() int {
|
||||
switch x {
|
||||
case QualityHigh:
|
||||
return 85 // Good quality, faster than 95+
|
||||
case QualityMedium:
|
||||
return 75 // Balanced quality/speed
|
||||
case QualityLow:
|
||||
return 65 // Lower quality, faster encoding
|
||||
default:
|
||||
return 75
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
ENUM(
|
||||
fit
|
||||
@@ -96,68 +125,65 @@ func (s *Service) FormatFromExtension(ext string) (Format, error) {
|
||||
return FormatTiff, nil
|
||||
case imaging.BMP:
|
||||
return FormatBmp, nil
|
||||
case imaging.WEBP:
|
||||
return FormatWebp, nil
|
||||
case imaging.PBM:
|
||||
return FormatPbm, nil
|
||||
case imaging.PGM:
|
||||
return FormatPgm, nil
|
||||
case imaging.PPM:
|
||||
return FormatPpm, nil
|
||||
case imaging.PAM:
|
||||
return FormatPam, nil
|
||||
default:
|
||||
return -1, ErrUnsupportedFormat
|
||||
}
|
||||
}
|
||||
|
||||
type resizeConfig struct {
|
||||
format Format
|
||||
resizeMode ResizeMode
|
||||
quality Quality
|
||||
type ResizeOptions struct {
|
||||
Width int
|
||||
Height int
|
||||
Format Format
|
||||
ResizeMode ResizeMode
|
||||
Quality Quality
|
||||
JpegQuality int // JPEG encoding quality (1-100), 0 means use Quality default
|
||||
}
|
||||
|
||||
type Option func(*resizeConfig)
|
||||
|
||||
func WithFormat(format Format) Option {
|
||||
return func(config *resizeConfig) {
|
||||
config.format = format
|
||||
}
|
||||
func (s *Service) Resize(in io.Reader, out io.Writer, opts ResizeOptions) error {
|
||||
return s.ResizeWithSize(in, out, 0, opts)
|
||||
}
|
||||
|
||||
func WithMode(mode ResizeMode) Option {
|
||||
return func(config *resizeConfig) {
|
||||
config.resizeMode = mode
|
||||
// ResizeWithSize resizes an image with file size information for appropriate semaphore selection
|
||||
func (s *Service) ResizeWithSize(in io.Reader, out io.Writer, fileSize int64, opts ResizeOptions) error {
|
||||
// Set defaults
|
||||
if opts.ResizeMode == 0 {
|
||||
opts.ResizeMode = ResizeModeFit
|
||||
}
|
||||
}
|
||||
|
||||
func WithQuality(quality Quality) Option {
|
||||
return func(config *resizeConfig) {
|
||||
config.quality = quality
|
||||
if opts.Quality == 0 {
|
||||
opts.Quality = QualityMedium
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Resize(in io.Reader, width, height int, out io.Writer, options ...Option) error {
|
||||
// Use image service semaphore for image processing
|
||||
if s.imageService != nil {
|
||||
if err := s.imageService.Acquire(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.imageService.Release()
|
||||
if opts.JpegQuality == 0 {
|
||||
opts.JpegQuality = opts.Quality.jpegQuality()
|
||||
}
|
||||
|
||||
format, wrappedReader, err := s.detectFormat(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
|
||||
|
||||
if opts.Format == FormatHeic {
|
||||
opts.Format = FormatJpeg
|
||||
}
|
||||
// Ensure JPEG quality is set based on quality setting
|
||||
if opts.JpegQuality == 0 {
|
||||
opts.JpegQuality = opts.Quality.jpegQuality()
|
||||
}
|
||||
|
||||
config := resizeConfig{
|
||||
format: format,
|
||||
resizeMode: ResizeModeFit,
|
||||
quality: QualityMedium,
|
||||
}
|
||||
for _, option := range options {
|
||||
option(&config)
|
||||
}
|
||||
|
||||
if format == FormatHeic {
|
||||
config.format = FormatJpeg
|
||||
}
|
||||
|
||||
if config.quality == QualityLow && format == FormatJpeg {
|
||||
// Try to use embedded EXIF thumbnail for JPEGs (only for low quality to keep it simple)
|
||||
if opts.Format == FormatJpeg && opts.Quality == QualityLow {
|
||||
thm, newWrappedReader, errThm := getEmbeddedThumbnail(wrappedReader)
|
||||
wrappedReader = newWrappedReader
|
||||
if errThm == nil {
|
||||
if errThm == nil && len(thm) > 0 {
|
||||
_, err = out.Write(thm)
|
||||
if err == nil {
|
||||
return nil
|
||||
@@ -165,78 +191,100 @@ func (s *Service) Resize(in io.Reader, width, height int, out io.Writer, options
|
||||
}
|
||||
}
|
||||
|
||||
// For HEIC files, try without AutoOrientation first since it might not work properly
|
||||
img, err := imaging.Decode(wrappedReader)
|
||||
// Decode the image - try imaging library first, fall back to format-specific decoder if it fails
|
||||
img, err := imaging.Decode(wrappedReader, imaging.AutoOrientation(true))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode image: %w", err)
|
||||
// Imaging library failed, try format-specific standard decoder as fallback
|
||||
// Reset reader if possible
|
||||
if seeker, ok := wrappedReader.(io.Seeker); ok {
|
||||
_, _ = seeker.Seek(0, io.SeekStart)
|
||||
}
|
||||
|
||||
// Use format-specific decoder (more reliable than image.Decode auto-detection)
|
||||
if opts.Format == FormatJpeg {
|
||||
img, err = jpeg.Decode(wrappedReader)
|
||||
if err != nil {
|
||||
// Return error with JPEG-specific terms so FFmpeg fallback can catch it
|
||||
return fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
} else {
|
||||
// For other formats, try auto-detection
|
||||
img, _, err = image.Decode(wrappedReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode image: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: For HEIC files processed via FFmpeg, orientation is handled automatically
|
||||
|
||||
switch config.resizeMode {
|
||||
// Resize
|
||||
resampleFilter := opts.Quality.resampleFilter()
|
||||
switch opts.ResizeMode {
|
||||
case ResizeModeFill:
|
||||
img = imaging.Fill(img, width, height, imaging.Center, config.quality.resampleFilter())
|
||||
img = imaging.Fill(img, opts.Width, opts.Height, imaging.Center, resampleFilter)
|
||||
case ResizeModeFit:
|
||||
img = imaging.Fit(img, width, height, config.quality.resampleFilter())
|
||||
fallthrough
|
||||
default:
|
||||
img = imaging.Fit(img, width, height, config.quality.resampleFilter())
|
||||
img = imaging.Fit(img, opts.Width, opts.Height, resampleFilter)
|
||||
}
|
||||
|
||||
return imaging.Encode(out, img, config.format.toImaging())
|
||||
}
|
||||
|
||||
func (s *Service) detectFormat(in io.Reader) (Format, io.Reader, error) {
|
||||
// Read all data into a buffer first to avoid consuming the reader twice
|
||||
allData, err := io.ReadAll(in)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("failed to read image data: %w", err)
|
||||
// Encode
|
||||
if opts.Format == FormatJpeg {
|
||||
return jpeg.Encode(out, img, &jpeg.Options{Quality: opts.JpegQuality})
|
||||
}
|
||||
|
||||
// Create a reader for format detection
|
||||
reader := bytes.NewReader(allData)
|
||||
_, imgFormat, err := image.DecodeConfig(reader)
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("image.DecodeConfig failed (data size: %d bytes): %s: %w", len(allData), err.Error(), ErrUnsupportedFormat)
|
||||
}
|
||||
|
||||
if imgFormat == "heif" {
|
||||
imgFormat = "heic"
|
||||
}
|
||||
|
||||
format, err := ParseFormat(imgFormat)
|
||||
if err != nil {
|
||||
return 0, nil, ErrUnsupportedFormat
|
||||
}
|
||||
|
||||
// Return a new reader with all the data for subsequent operations
|
||||
return format, bytes.NewReader(allData), nil
|
||||
return imaging.Encode(out, img, opts.Format.toImaging())
|
||||
}
|
||||
|
||||
func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
|
||||
// Read all data to avoid partial consumption issues
|
||||
allData, err := io.ReadAll(in)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
// Optimize memory: only read enough bytes for EXIF parsing (max 64KB)
|
||||
// EXIF data is typically in the first 64KB of JPEG files
|
||||
const maxExifSize = 0xffff // 64KB
|
||||
|
||||
var headerBuf []byte
|
||||
var wrappedReader io.Reader
|
||||
|
||||
// Check if reader is seekable - if so, we can peek and seek back (most efficient)
|
||||
if seeker, ok := in.(io.Seeker); ok {
|
||||
originalPos, err := seeker.Seek(0, io.SeekCurrent)
|
||||
if err == nil {
|
||||
// Use buffer pool for temporary storage
|
||||
buf := getBuffer()
|
||||
defer putBuffer(buf)
|
||||
|
||||
// Read just the header
|
||||
limitedReader := io.LimitReader(in, maxExifSize)
|
||||
_, readErr := buf.ReadFrom(limitedReader)
|
||||
if readErr == nil || readErr == io.EOF {
|
||||
// Copy to permanent storage for EXIF parsing
|
||||
headerBuf = make([]byte, buf.Len())
|
||||
copy(headerBuf, buf.Bytes())
|
||||
// Seek back to start for main processing
|
||||
_, _ = seeker.Seek(originalPos, io.SeekStart)
|
||||
wrappedReader = in
|
||||
} else {
|
||||
// Read error - cannot extract thumbnail, return error to fall back to full decode
|
||||
_, _ = seeker.Seek(originalPos, io.SeekStart)
|
||||
return nil, in, fmt.Errorf("failed to read header for thumbnail extraction: %w", readErr)
|
||||
}
|
||||
} 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")
|
||||
}
|
||||
} 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")
|
||||
}
|
||||
|
||||
// Create a reader that can be returned for fallback processing
|
||||
wrappedReader := bytes.NewReader(allData)
|
||||
|
||||
// Try to read up to 0xffff bytes for EXIF header parsing
|
||||
// Use the actual data size if file is smaller
|
||||
headSize := 0xffff
|
||||
if len(allData) < headSize {
|
||||
headSize = len(allData)
|
||||
}
|
||||
|
||||
head := allData[:headSize]
|
||||
// Try to find EXIF header
|
||||
offsets := []int{12, 30}
|
||||
|
||||
var offset int
|
||||
var exifErr error
|
||||
for _, offset = range offsets {
|
||||
if _, exifErr = exif.ParseExifHeader(head[offset:]); exifErr == nil {
|
||||
break
|
||||
if offset < len(headerBuf) {
|
||||
if _, exifErr = exif.ParseExifHeader(headerBuf[offset:]); exifErr == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +297,7 @@ func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
|
||||
return nil, wrappedReader, err
|
||||
}
|
||||
|
||||
_, index, err := exif.Collect(im, exif.NewTagIndex(), head[offset:])
|
||||
_, index, err := exif.Collect(im, exif.NewTagIndex(), headerBuf[offset:])
|
||||
if err != nil {
|
||||
return nil, wrappedReader, err
|
||||
}
|
||||
|
||||
@@ -33,17 +33,13 @@ func TestMain(m *testing.M) {
|
||||
|
||||
func TestService_Resize(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
options []Option
|
||||
width int
|
||||
height int
|
||||
options ResizeOptions
|
||||
source func(t *testing.T) afero.File
|
||||
matcher func(t *testing.T, reader io.Reader)
|
||||
wantErr bool
|
||||
}{
|
||||
"fill upscale": {
|
||||
options: []Option{WithMode(ResizeModeFill)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 50, 20)
|
||||
@@ -51,9 +47,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"fill downscale": {
|
||||
options: []Option{WithMode(ResizeModeFill)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -61,9 +55,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"fit upscale": {
|
||||
options: []Option{WithMode(ResizeModeFit)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFit},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 50, 20)
|
||||
@@ -71,9 +63,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(50, 20),
|
||||
},
|
||||
"fit downscale": {
|
||||
options: []Option{WithMode(ResizeModeFit)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFit},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -81,9 +71,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 75),
|
||||
},
|
||||
"keep original format": {
|
||||
options: []Option{},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, Format: FormatPng},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayPng(t, 200, 150)
|
||||
@@ -91,9 +79,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: formatMatcher(FormatPng),
|
||||
},
|
||||
"convert to jpeg": {
|
||||
options: []Option{WithFormat(FormatJpeg)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, Format: FormatJpeg},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -101,9 +87,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: formatMatcher(FormatJpeg),
|
||||
},
|
||||
"convert to png": {
|
||||
options: []Option{WithFormat(FormatPng)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, Format: FormatPng},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -111,9 +95,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: formatMatcher(FormatPng),
|
||||
},
|
||||
"convert to gif": {
|
||||
options: []Option{WithFormat(FormatGif)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, Format: FormatGif},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -121,9 +103,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: formatMatcher(FormatGif),
|
||||
},
|
||||
"convert to tiff": {
|
||||
options: []Option{WithFormat(FormatTiff)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, Format: FormatTiff},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -131,9 +111,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: formatMatcher(FormatTiff),
|
||||
},
|
||||
"convert to bmp": {
|
||||
options: []Option{WithFormat(FormatBmp)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, Format: FormatBmp},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -141,9 +119,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: formatMatcher(FormatBmp),
|
||||
},
|
||||
"convert to unknown": {
|
||||
options: []Option{WithFormat(Format(-1))},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, Format: Format(-1)},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -151,9 +127,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: formatMatcher(FormatJpeg),
|
||||
},
|
||||
"resize png": {
|
||||
options: []Option{WithMode(ResizeModeFill)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayPng(t, 200, 150)
|
||||
@@ -161,9 +135,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"resize gif": {
|
||||
options: []Option{WithMode(ResizeModeFill)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayGif(t, 200, 150)
|
||||
@@ -171,9 +143,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"resize tiff": {
|
||||
options: []Option{WithMode(ResizeModeFill)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayTiff(t, 200, 150)
|
||||
@@ -181,9 +151,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"resize bmp": {
|
||||
options: []Option{WithMode(ResizeModeFill)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayBmp(t, 200, 150)
|
||||
@@ -191,9 +159,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"resize with high quality": {
|
||||
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityHigh)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityHigh},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -201,9 +167,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"resize with medium quality": {
|
||||
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityMedium)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityMedium},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -211,9 +175,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"resize with low quality": {
|
||||
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -221,9 +183,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"resize with unknown quality": {
|
||||
options: []Option{WithMode(ResizeModeFill), WithQuality(Quality(-1))},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: Quality(-1)},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return newGrayJpeg(t, 200, 150)
|
||||
@@ -231,9 +191,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"get thumbnail from file with APP0 JFIF": {
|
||||
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return openFile(t, "testdata/gray-sample.jpg")
|
||||
@@ -241,9 +199,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(125, 128),
|
||||
},
|
||||
"get thumbnail from file without APP0 JFIF": {
|
||||
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return openFile(t, "testdata/20130612_142406.jpg")
|
||||
@@ -251,9 +207,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(320, 240),
|
||||
},
|
||||
"resize from file without IFD1 thumbnail": {
|
||||
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityLow)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityLow},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return openFile(t, "testdata/IMG_2578.JPG")
|
||||
@@ -261,9 +215,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"resize for higher quality levels": {
|
||||
options: []Option{WithMode(ResizeModeFill), WithQuality(QualityMedium)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFill, Quality: QualityMedium},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
return openFile(t, "testdata/gray-sample.jpg")
|
||||
@@ -271,9 +223,7 @@ func TestService_Resize(t *testing.T) {
|
||||
matcher: sizeMatcher(100, 100),
|
||||
},
|
||||
"broken file": {
|
||||
options: []Option{WithMode(ResizeModeFit)},
|
||||
width: 100,
|
||||
height: 100,
|
||||
options: ResizeOptions{Width: 100, Height: 100, ResizeMode: ResizeModeFit},
|
||||
source: func(t *testing.T) afero.File {
|
||||
t.Helper()
|
||||
fs := afero.NewMemMapFs()
|
||||
@@ -291,14 +241,13 @@ func TestService_Resize(t *testing.T) {
|
||||
|
||||
for name, test := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
// Use a temporary directory for cache to avoid creating directories in the source tree
|
||||
tmpDir := t.TempDir()
|
||||
svc := NewPreviewGenerator(1, tmpDir)
|
||||
source := test.source(t)
|
||||
defer source.Close()
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
err := svc.Resize(source, test.width, test.height, buf, test.options...)
|
||||
err := svc.Resize(source, buf, test.options)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("GetMarketSpecs() error = %v, wantErr %v", err, test.wantErr)
|
||||
}
|
||||
@@ -439,10 +388,26 @@ func TestService_FormatFromExtension(t *testing.T) {
|
||||
ext: ".tiff",
|
||||
want: FormatTiff,
|
||||
},
|
||||
"tif": {
|
||||
ext: ".tif",
|
||||
want: FormatTiff,
|
||||
},
|
||||
"bmp": {
|
||||
ext: ".bmp",
|
||||
want: FormatBmp,
|
||||
},
|
||||
"heic": {
|
||||
ext: ".heic",
|
||||
want: FormatHeic,
|
||||
},
|
||||
"heif": {
|
||||
ext: ".heif",
|
||||
want: FormatHeic,
|
||||
},
|
||||
"webp": {
|
||||
ext: ".webp",
|
||||
want: FormatWebp,
|
||||
},
|
||||
"unknown": {
|
||||
ext: ".mov",
|
||||
wantErr: ErrUnsupportedFormat,
|
||||
@@ -451,7 +416,6 @@ func TestService_FormatFromExtension(t *testing.T) {
|
||||
|
||||
for name, test := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
// Use a temporary directory for cache to avoid creating directories in the source tree
|
||||
tmpDir := t.TempDir()
|
||||
svc := NewPreviewGenerator(1, tmpDir)
|
||||
got, err := svc.FormatFromExtension(test.ext)
|
||||
@@ -484,3 +448,58 @@ 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)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// MemoryTracker wraps the existing FFmpegService to add memory tracking
|
||||
type MemoryTracker struct {
|
||||
maxMemoryBytes int64
|
||||
usedMemoryBytes int64
|
||||
semaphore chan struct{}
|
||||
}
|
||||
|
||||
// NewMemoryTracker creates a memory-aware concurrency limiter
|
||||
func NewMemoryTracker(maxConcurrent int, maxMemoryMB int) *MemoryTracker {
|
||||
if maxConcurrent < 1 {
|
||||
maxConcurrent = 1
|
||||
}
|
||||
if maxMemoryMB < 50 {
|
||||
maxMemoryMB = 500 // Default 500MB
|
||||
}
|
||||
return &MemoryTracker{
|
||||
maxMemoryBytes: int64(maxMemoryMB) * 1024 * 1024,
|
||||
semaphore: make(chan struct{}, maxConcurrent),
|
||||
}
|
||||
}
|
||||
|
||||
// TryAcquire attempts to acquire resources for processing an image
|
||||
// Returns false if memory limit would be exceeded
|
||||
func (mt *MemoryTracker) TryAcquire(ctx context.Context, estimatedBytes int64) bool {
|
||||
// Check if we have memory headroom
|
||||
currentUsed := atomic.LoadInt64(&mt.usedMemoryBytes)
|
||||
if currentUsed+estimatedBytes > mt.maxMemoryBytes {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to acquire semaphore slot (non-blocking check first)
|
||||
select {
|
||||
case mt.semaphore <- struct{}{}:
|
||||
// Successfully acquired slot, now add memory
|
||||
atomic.AddInt64(&mt.usedMemoryBytes, estimatedBytes)
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Acquire waits to acquire resources
|
||||
func (mt *MemoryTracker) Acquire(ctx context.Context, estimatedBytes int64) error {
|
||||
// Wait for semaphore slot
|
||||
select {
|
||||
case mt.semaphore <- struct{}{}:
|
||||
atomic.AddInt64(&mt.usedMemoryBytes, estimatedBytes)
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Release frees up resources
|
||||
func (mt *MemoryTracker) Release(estimatedBytes int64) {
|
||||
<-mt.semaphore
|
||||
atomic.AddInt64(&mt.usedMemoryBytes, -estimatedBytes)
|
||||
}
|
||||
|
||||
// GetMemoryUsageMB returns current memory usage in MB
|
||||
func (mt *MemoryTracker) GetMemoryUsageMB() int64 {
|
||||
return atomic.LoadInt64(&mt.usedMemoryBytes) / (1024 * 1024)
|
||||
}
|
||||
@@ -21,11 +21,8 @@ type officePreviewResponse struct {
|
||||
}
|
||||
|
||||
// GenerateOfficePreview generates a preview for an office document using OnlyOffice.
|
||||
// Note: Global image processor semaphore is acquired at GeneratePreviewWithMD5 level
|
||||
func (s *Service) GenerateOfficePreview(ctx context.Context, filetype, key, title, url string) ([]byte, error) {
|
||||
if err := s.acquireOffice(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer s.releaseOffice()
|
||||
data := []byte{}
|
||||
// Create the request payload
|
||||
requestPayload := map[string]interface{}{
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -29,21 +29,27 @@ var (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
fileCache diskcache.Interface
|
||||
cacheDir string // Cache directory used for thumbnails and temp files
|
||||
debug bool
|
||||
docGenMutex sync.Mutex // Mutex to serialize access to doc generation
|
||||
docSemaphore chan struct{} // Semaphore for document generation
|
||||
officeSem chan struct{} // Semaphore for office document processing
|
||||
videoService *ffmpeg.FFmpegService
|
||||
imageService *ffmpeg.FFmpegService
|
||||
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
|
||||
}
|
||||
|
||||
// Calculate split between small and large imaging library processors
|
||||
// Distribution formula:
|
||||
//
|
||||
// 1 processor: single image processor for all sizes
|
||||
// 2 processors: 1 large, 1 small
|
||||
// 3-6 processors: 2 large, rest small
|
||||
// 7+ processors: 3 large, rest small
|
||||
func NewPreviewGenerator(concurrencyLimit int, cacheDir string) *Service {
|
||||
if concurrencyLimit < 1 {
|
||||
concurrencyLimit = 1
|
||||
}
|
||||
// get round up half value of concurrencyLimit
|
||||
// get round up half value of concurrencyLimit for FFmpeg operations
|
||||
ffmpegConcurrencyLimit := (concurrencyLimit + 1) / 2
|
||||
|
||||
actualCacheDir := cacheDir
|
||||
@@ -73,49 +79,81 @@ func NewPreviewGenerator(concurrencyLimit int, cacheDir string) *Service {
|
||||
logger.Error(err)
|
||||
}
|
||||
|
||||
videoService := ffmpeg.NewFFmpegService(ffmpegConcurrencyLimit, settings.Config.Integrations.Media.Debug, "")
|
||||
imageService := ffmpeg.NewFFmpegService(concurrencyLimit, settings.Config.Integrations.Media.Debug, filepath.Join(actualCacheDir, "heic"))
|
||||
// Single FFmpeg service shared by video preview and HEIC/JPEG fallback
|
||||
ffmpegService := ffmpeg.NewFFmpegService(ffmpegConcurrencyLimit, settings.Config.Integrations.Media.Debug, filepath.Join(actualCacheDir, "heic"))
|
||||
|
||||
var imageSem, imageLargeSem chan struct{}
|
||||
|
||||
if concurrencyLimit == 1 {
|
||||
// Single processor: no split, imageLargeSem will be nil
|
||||
imageSem = make(chan struct{}, 1)
|
||||
imageLargeSem = nil
|
||||
} else {
|
||||
var largeLimit int
|
||||
if concurrencyLimit >= 7 {
|
||||
largeLimit = 3
|
||||
} else if concurrencyLimit >= 3 {
|
||||
largeLimit = 2
|
||||
} else {
|
||||
largeLimit = 1
|
||||
}
|
||||
smallLimit := concurrencyLimit - largeLimit
|
||||
|
||||
imageSem = make(chan struct{}, smallLimit)
|
||||
imageLargeSem = make(chan struct{}, largeLimit)
|
||||
|
||||
logger.Debugf("Image processor split: %d small, %d large (total: %d)", smallLimit, largeLimit, concurrencyLimit)
|
||||
}
|
||||
|
||||
// Total max memory = concurrencyLimit × 50MB
|
||||
// Example: concurrencyLimit=10 → ~500MB max
|
||||
settings.Env.MuPdfAvailable = docEnabled()
|
||||
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
|
||||
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
|
||||
|
||||
return &Service{
|
||||
fileCache: fileCache,
|
||||
cacheDir: actualCacheDir,
|
||||
debug: settings.Config.Integrations.Media.Debug,
|
||||
docSemaphore: make(chan struct{}, 1), // must be 1 because cgo thread limit
|
||||
officeSem: make(chan struct{}, concurrencyLimit),
|
||||
videoService: videoService,
|
||||
imageService: imageService,
|
||||
fileCache: fileCache,
|
||||
cacheDir: actualCacheDir,
|
||||
debug: settings.Config.Integrations.Media.Debug,
|
||||
ffmpegService: ffmpegService,
|
||||
imageSem: imageSem,
|
||||
imageLargeSem: imageLargeSem,
|
||||
}
|
||||
}
|
||||
|
||||
// Document semaphore methods
|
||||
func (s *Service) acquireDoc(ctx context.Context) error {
|
||||
// Global image processor semaphore methods
|
||||
// These are used to ensure FFmpeg and document operations also respect the global image processor limit
|
||||
func (s *Service) acquireImageSem(ctx context.Context) error {
|
||||
select {
|
||||
case s.docSemaphore <- struct{}{}:
|
||||
case s.imageSem <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) releaseDoc() {
|
||||
<-s.docSemaphore
|
||||
func (s *Service) releaseImageSem() {
|
||||
<-s.imageSem
|
||||
}
|
||||
|
||||
// Office semaphore methods
|
||||
func (s *Service) acquireOffice(ctx context.Context) error {
|
||||
func (s *Service) acquireImageLargeSem(ctx context.Context) error {
|
||||
if s.imageLargeSem == nil {
|
||||
// Fall back to imageSem if imageLargeSem is not available (single processor case)
|
||||
return s.acquireImageSem(ctx)
|
||||
}
|
||||
select {
|
||||
case s.officeSem <- struct{}{}:
|
||||
case s.imageLargeSem <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) releaseOffice() {
|
||||
<-s.officeSem
|
||||
func (s *Service) releaseImageLargeSem() {
|
||||
if s.imageLargeSem == nil {
|
||||
// Fall back to imageSem if imageLargeSem is not available (single processor case)
|
||||
s.releaseImageSem()
|
||||
return
|
||||
}
|
||||
<-s.imageLargeSem
|
||||
}
|
||||
|
||||
func StartPreviewGenerator(concurrencyLimit int, cacheDir string) error {
|
||||
@@ -126,6 +164,11 @@ 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
|
||||
@@ -138,10 +181,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 && file.Metadata.AlbumArt != "" {
|
||||
if file.Metadata != nil && len(file.Metadata.AlbumArt) > 0 {
|
||||
// For audio with album art, hash the album art content
|
||||
hasher := md5.New()
|
||||
_, _ = hasher.Write([]byte(file.Metadata.AlbumArt))
|
||||
_, _ = hasher.Write(file.Metadata.AlbumArt)
|
||||
cacheHash = hex.EncodeToString(hasher.Sum(nil))
|
||||
} else {
|
||||
// For all other files, use fast metadata-based hash
|
||||
@@ -160,6 +203,191 @@ 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
|
||||
@@ -174,73 +402,61 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(file.Name))
|
||||
var (
|
||||
err error
|
||||
imageBytes []byte
|
||||
)
|
||||
// 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()
|
||||
}
|
||||
|
||||
// Generate thumbnail image from video
|
||||
// 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
|
||||
hasher := md5.New()
|
||||
_, _ = hasher.Write([]byte(CacheKey(fileMD5, previewSize, seekPercentage)))
|
||||
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||
convertHEIC := strings.HasPrefix(file.Type, "image/heic") && settings.Config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview]
|
||||
// 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
|
||||
|
||||
// Generate raw preview based on file type
|
||||
imageBytes, err := service.generateRawPreview(ctx, file, previewSize, officeUrl, seekPercentage, hash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// For HEIC files, we've already done the resize/conversion, cache and return directly
|
||||
previewType := determinePreviewType(file)
|
||||
if previewType == previewTypeHEIC {
|
||||
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
|
||||
} 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)
|
||||
}
|
||||
|
||||
// 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, "video") {
|
||||
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)
|
||||
return imageBytes, nil
|
||||
}
|
||||
|
||||
// Check if context was cancelled during processing
|
||||
@@ -248,32 +464,47 @@ 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" {
|
||||
// resize image
|
||||
resizedBytes, err := service.CreatePreview(imageBytes, previewSize)
|
||||
options, err := getPreviewOptions(previewSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resize preview image: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
// Cache and return
|
||||
|
||||
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
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to resize preview image: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Cache and return resized image
|
||||
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) {
|
||||
@@ -286,31 +517,44 @@ func GeneratePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previe
|
||||
return GeneratePreviewWithMD5(ctx, file, previewSize, officeUrl, seekPercentage, cacheHash)
|
||||
}
|
||||
|
||||
func (s *Service) CreatePreview(data []byte, previewSize string) ([]byte, error) {
|
||||
var (
|
||||
width int
|
||||
height int
|
||||
options []Option
|
||||
)
|
||||
|
||||
// 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":
|
||||
width, height = 640, 640
|
||||
options = []Option{WithMode(ResizeModeFit), WithQuality(QualityHigh), WithFormat(FormatJpeg)}
|
||||
return ResizeOptions{
|
||||
Width: 640,
|
||||
Height: 640,
|
||||
ResizeMode: ResizeModeFit,
|
||||
Quality: QualityHigh,
|
||||
Format: FormatJpeg,
|
||||
}, nil
|
||||
case "small":
|
||||
width, height = 256, 256
|
||||
options = []Option{WithMode(ResizeModeFit), WithQuality(QualityMedium), WithFormat(FormatJpeg)}
|
||||
return ResizeOptions{
|
||||
Width: 256,
|
||||
Height: 256,
|
||||
ResizeMode: ResizeModeFit,
|
||||
Quality: QualityMedium,
|
||||
Format: FormatJpeg,
|
||||
}, nil
|
||||
default:
|
||||
return nil, ErrUnsupportedFormat
|
||||
return ResizeOptions{}, ErrUnsupportedFormat
|
||||
}
|
||||
}
|
||||
|
||||
input := bytes.NewReader(data)
|
||||
// CreatePreview resizes an image from a reader with the given options
|
||||
func (s *Service) CreatePreview(reader io.Reader, fileSize int64, options ResizeOptions) ([]byte, error) {
|
||||
output := &bytes.Buffer{}
|
||||
|
||||
if err := s.Resize(input, width, height, output, options...); err != nil {
|
||||
if err := s.ResizeWithSize(reader, output, fileSize, options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return output.Bytes(), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
// percentageSeek: percentage of video duration to seek to (0–100).
|
||||
// Returns: JPEG image bytes.
|
||||
func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath string, percentageSeek int) ([]byte, error) {
|
||||
if s.videoService == nil {
|
||||
logger.Errorf("Video service not available for file '%s'", videoPath)
|
||||
return nil, fmt.Errorf("video service not available")
|
||||
if s.ffmpegService == nil {
|
||||
logger.Errorf("FFmpeg service not available for file '%s'", videoPath)
|
||||
return nil, fmt.Errorf("FFmpeg service not available")
|
||||
}
|
||||
|
||||
// Validate file exists before processing
|
||||
@@ -26,14 +26,14 @@ func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath string, pe
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := s.videoService.GenerateVideoPreviewStreaming(ctx, videoPath, percentageSeek, &buf)
|
||||
err := s.ffmpegService.GenerateVideoPreviewStreaming(ctx, videoPath, percentageSeek, &buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
previewBytes := buf.Bytes()
|
||||
if len(previewBytes) == 0 {
|
||||
logger.Errorf("Video service returned empty result for '%s'", videoPath)
|
||||
logger.Errorf("FFmpeg service returned empty result for '%s'", videoPath)
|
||||
return nil, fmt.Errorf("video preview generation returned empty result")
|
||||
}
|
||||
|
||||
|
||||
@@ -1105,17 +1105,17 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/media/subtitles": {
|
||||
"get": {
|
||||
"description": "Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content",
|
||||
"description": "Returns raw subtitle content from external files or embedded streams",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"text/vtt"
|
||||
"text/plain"
|
||||
],
|
||||
"tags": [
|
||||
"Subtitles"
|
||||
],
|
||||
"summary": "Extract embedded subtitles",
|
||||
"summary": "Get subtitle content",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
@@ -1132,15 +1132,22 @@ const docTemplate = `{
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Stream index for embedded subtitle extraction, defaults to 0",
|
||||
"name": "index",
|
||||
"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",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Raw WebVTT subtitle content",
|
||||
"description": "Raw subtitle content in original format",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -1278,7 +1285,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/api/raw": {
|
||||
"get": {
|
||||
"description": "Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.\n\n**Filename Encoding:**\n- The Content-Disposition header will always include both:\n1. ` + "`" + `filename=\"...\"` + "`" + `: An ASCII-safe version of the filename for compatibility.\n2. ` + "`" + `filename*=utf-8\"...` + "`" + `: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.",
|
||||
"description": "Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.\n\n**Filename Encoding:**\n- The Content-Disposition header will always include both:\n1. ` + "`" + `filename=\"...\"` + "`" + `: An ASCII-safe version of the filename for compatibility.\n2. ` + "`" + `filename*=utf-8\"...` + "`" + `: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.\n\n**Multiple Files:**\n- Use repeated query parameters: ` + "`" + `?file=file1.txt\u0026file=file2.txt\u0026file=file3.txt` + "`" + `\n- This supports filenames containing commas and special characters",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1295,9 +1302,13 @@ const docTemplate = `{
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Comma-separated list of file paths (required)",
|
||||
"name": "files",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"collectionFormat": "csv",
|
||||
"description": "File path (can be repeated for multiple files)",
|
||||
"name": "file",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
@@ -1560,7 +1571,7 @@ const docTemplate = `{
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory",
|
||||
"description": "url encoded destination path where to place the files inside the destination source",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
@@ -2769,7 +2780,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/public/api/raw": {
|
||||
"get": {
|
||||
"description": "Downloads raw content from a public share. Supports single files, multiple files, or directories as archives. Enforces download limits (global or per-user) and blocks anonymous users when per-user limits are enabled.",
|
||||
"description": "Downloads raw content from a public share. Supports single files, multiple files, or directories as archives. Enforces download limits (global or per-user) and blocks anonymous users when per-user limits are enabled.\n\n**Multiple Files:**\n- Use repeated query parameters: ` + "`" + `?file=file1.txt\u0026file=file2.txt\u0026file=file3.txt` + "`" + `\n- This supports filenames containing commas and special characters",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -2789,9 +2800,13 @@ const docTemplate = `{
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Comma-separated list of file paths. Example: 'file1.txt,folder/file2.txt'",
|
||||
"name": "files",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"collectionFormat": "csv",
|
||||
"description": "File path (can be repeated for multiple files)",
|
||||
"name": "file",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
@@ -3055,8 +3070,8 @@ const docTemplate = `{
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Target path within the share to upload to. Must be relative to share root.",
|
||||
"name": "targetPath",
|
||||
"description": "path within the share to upload to. Must be relative to share root.",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
@@ -3304,14 +3319,14 @@ const docTemplate = `{
|
||||
},
|
||||
"/public/api/share/image": {
|
||||
"get": {
|
||||
"description": "Serves the banner or favicon file for a share",
|
||||
"description": "Returns a resizable preview (large size) for the banner or favicon file of a share",
|
||||
"produces": [
|
||||
"application/octet-stream"
|
||||
"image/jpeg"
|
||||
],
|
||||
"tags": [
|
||||
"Public Shares"
|
||||
],
|
||||
"summary": "Get share image (banner or favicon)",
|
||||
"summary": "Get share image (banner or favicon) as preview",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
@@ -3335,7 +3350,7 @@ const docTemplate = `{
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Image file content",
|
||||
"description": "Preview image content (JPEG)",
|
||||
"schema": {
|
||||
"type": "file"
|
||||
}
|
||||
@@ -3599,6 +3614,9 @@ const docTemplate = `{
|
||||
"bannerUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"canEdit": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3710,6 +3728,9 @@ const docTemplate = `{
|
||||
"description": "Override embedded field to show source name",
|
||||
"type": "string"
|
||||
},
|
||||
"sourceURL": {
|
||||
"type": "string"
|
||||
},
|
||||
"themeColor": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -4067,8 +4088,11 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
},
|
||||
"albumArt": {
|
||||
"description": "base64 encoded album art / video thumbnail",
|
||||
"type": "string"
|
||||
"description": "album art image data (automatically base64-encoded in JSON)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"description": "track artist",
|
||||
@@ -4255,7 +4279,7 @@ const docTemplate = `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"imagePreview": {
|
||||
"description": "supported image preview formats. defaults to false for all types unless explicitly enabled.",
|
||||
"description": "supported image preview formats. defaults vary by type (see individual type docs)",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
@@ -4324,6 +4348,10 @@ const docTemplate = `{
|
||||
"description": "display name",
|
||||
"type": "string"
|
||||
},
|
||||
"oidcLoginButtonText": {
|
||||
"description": "text to display on the OIDC login button",
|
||||
"type": "string"
|
||||
},
|
||||
"styling": {
|
||||
"$ref": "#/definitions/settings.StylingConfig"
|
||||
}
|
||||
@@ -4824,10 +4852,6 @@ const docTemplate = `{
|
||||
"description": "debug onlyoffice editor",
|
||||
"type": "boolean"
|
||||
},
|
||||
"defaultLandingPage": {
|
||||
"description": "deprecated: determined by sidebar link order since 1.1.0",
|
||||
"type": "string"
|
||||
},
|
||||
"deleteWithoutConfirming": {
|
||||
"description": "delete files without confirmation",
|
||||
"type": "boolean"
|
||||
@@ -5044,6 +5068,9 @@ const docTemplate = `{
|
||||
"bannerUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"canEdit": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -5140,6 +5167,9 @@ const docTemplate = `{
|
||||
"description": "backend source is path to maintain between name changes",
|
||||
"type": "string"
|
||||
},
|
||||
"sourceURL": {
|
||||
"type": "string"
|
||||
},
|
||||
"themeColor": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -5182,6 +5212,9 @@ const docTemplate = `{
|
||||
"bannerUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"canEdit": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -5287,6 +5320,9 @@ const docTemplate = `{
|
||||
"description": "backend source is path to maintain between name changes",
|
||||
"type": "string"
|
||||
},
|
||||
"sourceURL": {
|
||||
"type": "string"
|
||||
},
|
||||
"themeColor": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -5332,6 +5368,9 @@ const docTemplate = `{
|
||||
"bannerUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"canEdit": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -5440,6 +5479,9 @@ const docTemplate = `{
|
||||
"description": "backend source is path to maintain between name changes",
|
||||
"type": "string"
|
||||
},
|
||||
"sourceURL": {
|
||||
"type": "string"
|
||||
},
|
||||
"themeColor": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -5670,10 +5712,6 @@ const docTemplate = `{
|
||||
"description": "debug onlyoffice editor",
|
||||
"type": "boolean"
|
||||
},
|
||||
"defaultLandingPage": {
|
||||
"description": "deprecated: determined by sidebar link order instead",
|
||||
"type": "string"
|
||||
},
|
||||
"deleteWithoutConfirming": {
|
||||
"description": "delete files without confirmation",
|
||||
"type": "boolean"
|
||||
|
||||
@@ -1094,17 +1094,17 @@
|
||||
},
|
||||
"/api/media/subtitles": {
|
||||
"get": {
|
||||
"description": "Extracts embedded subtitle content from video files by stream index and returns raw WebVTT content",
|
||||
"description": "Returns raw subtitle content from external files or embedded streams",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"text/vtt"
|
||||
"text/plain"
|
||||
],
|
||||
"tags": [
|
||||
"Subtitles"
|
||||
],
|
||||
"summary": "Extract embedded subtitles",
|
||||
"summary": "Get subtitle content",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
@@ -1121,15 +1121,22 @@
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Stream index for embedded subtitle extraction, defaults to 0",
|
||||
"name": "index",
|
||||
"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",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Raw WebVTT subtitle content",
|
||||
"description": "Raw subtitle content in original format",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -1267,7 +1274,7 @@
|
||||
},
|
||||
"/api/raw": {
|
||||
"get": {
|
||||
"description": "Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.\n\n**Filename Encoding:**\n- The Content-Disposition header will always include both:\n1. `filename=\"...\"`: An ASCII-safe version of the filename for compatibility.\n2. `filename*=utf-8\"...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.",
|
||||
"description": "Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.\n\n**Filename Encoding:**\n- The Content-Disposition header will always include both:\n1. `filename=\"...\"`: An ASCII-safe version of the filename for compatibility.\n2. `filename*=utf-8\"...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.\n\n**Multiple Files:**\n- Use repeated query parameters: `?file=file1.txt\u0026file=file2.txt\u0026file=file3.txt`\n- This supports filenames containing commas and special characters",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -1284,9 +1291,13 @@
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Comma-separated list of file paths (required)",
|
||||
"name": "files",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"collectionFormat": "csv",
|
||||
"description": "File path (can be repeated for multiple files)",
|
||||
"name": "file",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
@@ -1549,7 +1560,7 @@
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory",
|
||||
"description": "url encoded destination path where to place the files inside the destination source",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
@@ -2758,7 +2769,7 @@
|
||||
},
|
||||
"/public/api/raw": {
|
||||
"get": {
|
||||
"description": "Downloads raw content from a public share. Supports single files, multiple files, or directories as archives. Enforces download limits (global or per-user) and blocks anonymous users when per-user limits are enabled.",
|
||||
"description": "Downloads raw content from a public share. Supports single files, multiple files, or directories as archives. Enforces download limits (global or per-user) and blocks anonymous users when per-user limits are enabled.\n\n**Multiple Files:**\n- Use repeated query parameters: `?file=file1.txt\u0026file=file2.txt\u0026file=file3.txt`\n- This supports filenames containing commas and special characters",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -2778,9 +2789,13 @@
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Comma-separated list of file paths. Example: 'file1.txt,folder/file2.txt'",
|
||||
"name": "files",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"collectionFormat": "csv",
|
||||
"description": "File path (can be repeated for multiple files)",
|
||||
"name": "file",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
@@ -3044,8 +3059,8 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Target path within the share to upload to. Must be relative to share root.",
|
||||
"name": "targetPath",
|
||||
"description": "path within the share to upload to. Must be relative to share root.",
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"required": true
|
||||
},
|
||||
@@ -3293,14 +3308,14 @@
|
||||
},
|
||||
"/public/api/share/image": {
|
||||
"get": {
|
||||
"description": "Serves the banner or favicon file for a share",
|
||||
"description": "Returns a resizable preview (large size) for the banner or favicon file of a share",
|
||||
"produces": [
|
||||
"application/octet-stream"
|
||||
"image/jpeg"
|
||||
],
|
||||
"tags": [
|
||||
"Public Shares"
|
||||
],
|
||||
"summary": "Get share image (banner or favicon)",
|
||||
"summary": "Get share image (banner or favicon) as preview",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
@@ -3324,7 +3339,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Image file content",
|
||||
"description": "Preview image content (JPEG)",
|
||||
"schema": {
|
||||
"type": "file"
|
||||
}
|
||||
@@ -3588,6 +3603,9 @@
|
||||
"bannerUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"canEdit": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3699,6 +3717,9 @@
|
||||
"description": "Override embedded field to show source name",
|
||||
"type": "string"
|
||||
},
|
||||
"sourceURL": {
|
||||
"type": "string"
|
||||
},
|
||||
"themeColor": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -4056,8 +4077,11 @@
|
||||
"type": "string"
|
||||
},
|
||||
"albumArt": {
|
||||
"description": "base64 encoded album art / video thumbnail",
|
||||
"type": "string"
|
||||
"description": "album art image data (automatically base64-encoded in JSON)",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"description": "track artist",
|
||||
@@ -4244,7 +4268,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"imagePreview": {
|
||||
"description": "supported image preview formats. defaults to false for all types unless explicitly enabled.",
|
||||
"description": "supported image preview formats. defaults vary by type (see individual type docs)",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
@@ -4313,6 +4337,10 @@
|
||||
"description": "display name",
|
||||
"type": "string"
|
||||
},
|
||||
"oidcLoginButtonText": {
|
||||
"description": "text to display on the OIDC login button",
|
||||
"type": "string"
|
||||
},
|
||||
"styling": {
|
||||
"$ref": "#/definitions/settings.StylingConfig"
|
||||
}
|
||||
@@ -4813,10 +4841,6 @@
|
||||
"description": "debug onlyoffice editor",
|
||||
"type": "boolean"
|
||||
},
|
||||
"defaultLandingPage": {
|
||||
"description": "deprecated: determined by sidebar link order since 1.1.0",
|
||||
"type": "string"
|
||||
},
|
||||
"deleteWithoutConfirming": {
|
||||
"description": "delete files without confirmation",
|
||||
"type": "boolean"
|
||||
@@ -5033,6 +5057,9 @@
|
||||
"bannerUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"canEdit": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -5129,6 +5156,9 @@
|
||||
"description": "backend source is path to maintain between name changes",
|
||||
"type": "string"
|
||||
},
|
||||
"sourceURL": {
|
||||
"type": "string"
|
||||
},
|
||||
"themeColor": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -5171,6 +5201,9 @@
|
||||
"bannerUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"canEdit": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -5276,6 +5309,9 @@
|
||||
"description": "backend source is path to maintain between name changes",
|
||||
"type": "string"
|
||||
},
|
||||
"sourceURL": {
|
||||
"type": "string"
|
||||
},
|
||||
"themeColor": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -5321,6 +5357,9 @@
|
||||
"bannerUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"canEdit": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -5429,6 +5468,9 @@
|
||||
"description": "backend source is path to maintain between name changes",
|
||||
"type": "string"
|
||||
},
|
||||
"sourceURL": {
|
||||
"type": "string"
|
||||
},
|
||||
"themeColor": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -5659,10 +5701,6 @@
|
||||
"description": "debug onlyoffice editor",
|
||||
"type": "boolean"
|
||||
},
|
||||
"defaultLandingPage": {
|
||||
"description": "deprecated: determined by sidebar link order instead",
|
||||
"type": "string"
|
||||
},
|
||||
"deleteWithoutConfirming": {
|
||||
"description": "delete files without confirmation",
|
||||
"type": "boolean"
|
||||
|
||||
@@ -122,6 +122,8 @@ definitions:
|
||||
type: string
|
||||
bannerUrl:
|
||||
type: string
|
||||
canEdit:
|
||||
type: boolean
|
||||
description:
|
||||
type: string
|
||||
disableAnonymous:
|
||||
@@ -199,6 +201,8 @@ definitions:
|
||||
source:
|
||||
description: Override embedded field to show source name
|
||||
type: string
|
||||
sourceURL:
|
||||
type: string
|
||||
themeColor:
|
||||
type: string
|
||||
title:
|
||||
@@ -447,8 +451,10 @@ definitions:
|
||||
description: album name
|
||||
type: string
|
||||
albumArt:
|
||||
description: base64 encoded album art / video thumbnail
|
||||
type: string
|
||||
description: album art image data (automatically base64-encoded in JSON)
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
artist:
|
||||
description: track artist
|
||||
type: string
|
||||
@@ -606,8 +612,8 @@ definitions:
|
||||
imagePreview:
|
||||
additionalProperties:
|
||||
type: boolean
|
||||
description: supported image preview formats. defaults to false for all types
|
||||
unless explicitly enabled.
|
||||
description: supported image preview formats. defaults vary by type (see individual
|
||||
type docs)
|
||||
type: object
|
||||
videoPreview:
|
||||
additionalProperties:
|
||||
@@ -655,6 +661,9 @@ definitions:
|
||||
name:
|
||||
description: display name
|
||||
type: string
|
||||
oidcLoginButtonText:
|
||||
description: text to display on the OIDC login button
|
||||
type: string
|
||||
styling:
|
||||
$ref: '#/definitions/settings.StylingConfig'
|
||||
type: object
|
||||
@@ -1035,9 +1044,6 @@ definitions:
|
||||
debugOffice:
|
||||
description: debug onlyoffice editor
|
||||
type: boolean
|
||||
defaultLandingPage:
|
||||
description: 'deprecated: determined by sidebar link order since 1.1.0'
|
||||
type: string
|
||||
deleteWithoutConfirming:
|
||||
description: delete files without confirmation
|
||||
type: boolean
|
||||
@@ -1197,6 +1203,8 @@ definitions:
|
||||
type: string
|
||||
bannerUrl:
|
||||
type: string
|
||||
canEdit:
|
||||
type: boolean
|
||||
description:
|
||||
type: string
|
||||
disableAnonymous:
|
||||
@@ -1264,6 +1272,8 @@ definitions:
|
||||
source:
|
||||
description: backend source is path to maintain between name changes
|
||||
type: string
|
||||
sourceURL:
|
||||
type: string
|
||||
themeColor:
|
||||
type: string
|
||||
title:
|
||||
@@ -1294,6 +1304,8 @@ definitions:
|
||||
type: string
|
||||
bannerUrl:
|
||||
type: string
|
||||
canEdit:
|
||||
type: boolean
|
||||
description:
|
||||
type: string
|
||||
disableAnonymous:
|
||||
@@ -1367,6 +1379,8 @@ definitions:
|
||||
source:
|
||||
description: backend source is path to maintain between name changes
|
||||
type: string
|
||||
sourceURL:
|
||||
type: string
|
||||
themeColor:
|
||||
type: string
|
||||
title:
|
||||
@@ -1399,6 +1413,8 @@ definitions:
|
||||
type: string
|
||||
bannerUrl:
|
||||
type: string
|
||||
canEdit:
|
||||
type: boolean
|
||||
description:
|
||||
type: string
|
||||
disableAnonymous:
|
||||
@@ -1474,6 +1490,8 @@ definitions:
|
||||
source:
|
||||
description: backend source is path to maintain between name changes
|
||||
type: string
|
||||
sourceURL:
|
||||
type: string
|
||||
themeColor:
|
||||
type: string
|
||||
title:
|
||||
@@ -1641,9 +1659,6 @@ definitions:
|
||||
debugOffice:
|
||||
description: debug onlyoffice editor
|
||||
type: boolean
|
||||
defaultLandingPage:
|
||||
description: 'deprecated: determined by sidebar link order instead'
|
||||
type: string
|
||||
deleteWithoutConfirming:
|
||||
description: delete files without confirmation
|
||||
type: boolean
|
||||
@@ -2500,8 +2515,7 @@ paths:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Extracts embedded subtitle content from video files by stream index
|
||||
and returns raw WebVTT content
|
||||
description: Returns raw subtitle content from external files or embedded streams
|
||||
parameters:
|
||||
- description: Index path to the video file
|
||||
in: query
|
||||
@@ -2513,15 +2527,22 @@ paths:
|
||||
name: source
|
||||
required: true
|
||||
type: string
|
||||
- description: Stream index for embedded subtitle extraction, defaults to 0
|
||||
- description: Subtitle track name (filename for external, descriptive name
|
||||
for embedded)
|
||||
in: query
|
||||
name: index
|
||||
type: integer
|
||||
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
|
||||
produces:
|
||||
- text/vtt
|
||||
- text/plain
|
||||
responses:
|
||||
"200":
|
||||
description: Raw WebVTT subtitle content
|
||||
description: Raw subtitle content in original format
|
||||
schema:
|
||||
type: string
|
||||
"400":
|
||||
@@ -2548,7 +2569,7 @@ paths:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Extract embedded subtitles
|
||||
summary: Get subtitle content
|
||||
tags:
|
||||
- Subtitles
|
||||
/api/preview:
|
||||
@@ -2624,17 +2645,24 @@ paths:
|
||||
- The Content-Disposition header will always include both:
|
||||
1. `filename="..."`: An ASCII-safe version of the filename for compatibility.
|
||||
2. `filename*=utf-8"...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.
|
||||
|
||||
**Multiple Files:**
|
||||
- Use repeated query parameters: `?file=file1.txt&file=file2.txt&file=file3.txt`
|
||||
- This supports filenames containing commas and special characters
|
||||
parameters:
|
||||
- description: Source name for the files (required)
|
||||
in: query
|
||||
name: source
|
||||
required: true
|
||||
type: string
|
||||
- description: Comma-separated list of file paths (required)
|
||||
- collectionFormat: csv
|
||||
description: File path (can be repeated for multiple files)
|
||||
in: query
|
||||
name: files
|
||||
items:
|
||||
type: string
|
||||
name: file
|
||||
required: true
|
||||
type: string
|
||||
type: array
|
||||
- description: If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults
|
||||
to 'attachment'.
|
||||
in: query
|
||||
@@ -2851,7 +2879,7 @@ paths:
|
||||
Supports file uploads and directory creation.
|
||||
parameters:
|
||||
- description: url encoded destination path where to place the files inside
|
||||
the destination source, a directory must end in / to create a directory
|
||||
the destination source
|
||||
in: query
|
||||
name: path
|
||||
required: true
|
||||
@@ -3641,20 +3669,26 @@ paths:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Downloads raw content from a public share. Supports single files,
|
||||
multiple files, or directories as archives. Enforces download limits (global
|
||||
or per-user) and blocks anonymous users when per-user limits are enabled.
|
||||
description: |-
|
||||
Downloads raw content from a public share. Supports single files, multiple files, or directories as archives. Enforces download limits (global or per-user) and blocks anonymous users when per-user limits are enabled.
|
||||
|
||||
**Multiple Files:**
|
||||
- Use repeated query parameters: `?file=file1.txt&file=file2.txt&file=file3.txt`
|
||||
- This supports filenames containing commas and special characters
|
||||
parameters:
|
||||
- description: Share hash for authentication
|
||||
in: query
|
||||
name: hash
|
||||
required: true
|
||||
type: string
|
||||
- description: 'Comma-separated list of file paths. Example: ''file1.txt,folder/file2.txt'''
|
||||
- collectionFormat: csv
|
||||
description: File path (can be repeated for multiple files)
|
||||
in: query
|
||||
name: files
|
||||
items:
|
||||
type: string
|
||||
name: file
|
||||
required: true
|
||||
type: string
|
||||
type: array
|
||||
- description: If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults
|
||||
to 'attachment'.
|
||||
in: query
|
||||
@@ -3829,10 +3863,10 @@ paths:
|
||||
name: hash
|
||||
required: true
|
||||
type: string
|
||||
- description: Target path within the share to upload to. Must be relative to
|
||||
share root.
|
||||
- description: path within the share to upload to. Must be relative to share
|
||||
root.
|
||||
in: query
|
||||
name: targetPath
|
||||
name: path
|
||||
required: true
|
||||
type: string
|
||||
- description: If true, overwrite existing files/folders. Defaults to false.
|
||||
@@ -4008,7 +4042,8 @@ paths:
|
||||
- Public Shares
|
||||
/public/api/share/image:
|
||||
get:
|
||||
description: Serves the banner or favicon file for a share
|
||||
description: Returns a resizable preview (large size) for the banner or favicon
|
||||
file of a share
|
||||
parameters:
|
||||
- description: Share hash
|
||||
in: query
|
||||
@@ -4024,10 +4059,10 @@ paths:
|
||||
name: favicon
|
||||
type: boolean
|
||||
produces:
|
||||
- application/octet-stream
|
||||
- image/jpeg
|
||||
responses:
|
||||
"200":
|
||||
description: Image file content
|
||||
description: Preview image content (JPEG)
|
||||
schema:
|
||||
type: file
|
||||
"400":
|
||||
@@ -4048,7 +4083,7 @@ paths:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Get share image (banner or favicon)
|
||||
summary: Get share image (banner or favicon) as preview
|
||||
tags:
|
||||
- Public Shares
|
||||
/public/api/shareinfo:
|
||||
|
||||
@@ -22,12 +22,11 @@
|
||||
"dependencies": {
|
||||
"@onlyoffice/document-editor-vue": "^1.4.0",
|
||||
"ace-builds": "^1.24.2",
|
||||
"axios": "^1.7.9",
|
||||
"axios": "^1.13.5",
|
||||
"clipboard": "^2.0.4",
|
||||
"css-vars-ponyfill": "^2.4.3",
|
||||
"dompurify": "^3.2.4",
|
||||
"epubjs": "^0.3.93",
|
||||
"file-loader": "^6.2.0",
|
||||
"glob": "^9.3.5",
|
||||
"highlight.js": "^11.11.1",
|
||||
"mammoth": "^1.9.1",
|
||||
@@ -41,6 +40,9 @@
|
||||
"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",
|
||||
|
||||
@@ -27,6 +27,20 @@ server:
|
||||
private: false # designate as source as private -- currently just means no sharing permitted.
|
||||
disabled: false # disable the source, this is useful so you don't need to remove it from the config file
|
||||
rules: # list of item rules to apply to specific paths
|
||||
- neverWatchPath: "" # index the folder in the first pass to get included in search, but never re-indexed.
|
||||
includeRootItem: "" # include only these items at root folder level
|
||||
fileStartsWith: "" # (global) exclude files that start with these prefixes. Eg. "archive-" or "backup-"
|
||||
folderStartsWith: "" # (global) exclude folders that start with these prefixes. Eg. "archive-" or "backup-"
|
||||
fileEndsWith: "" # (global) exclude files that end with these suffixes. Eg. ".jpg" or ".txt"
|
||||
folderEndsWith: "" # (global) exclude folders that end with these suffixes. Eg. ".thumbnails" or ".git"
|
||||
folderPath: "" # (global) exclude folders that match this path. Eg. "/path/to/folder" or "/path/to/folder/subfolder"
|
||||
filePath: "" # (global) exclude files that match this path. Eg. "/path/to/file.txt" or "/path/to/file.txt/subfile.txt"
|
||||
fileName: "" # (global) exclude files that match these names. Eg. "file.txt" or "test.csv"
|
||||
folderName: "" # (global) exclude folders that match these names. Eg. "folder" or "subfolder"
|
||||
viewable: false # Enable viewing in UI but exclude from indexing
|
||||
ignoreHidden: false # Excludes only hidden files and folders
|
||||
ignoreZeroSizeFolders: false # Excludes only folders with 0 size
|
||||
ignoreSymlinks: false # Excludes symbolic links
|
||||
defaultUserScope: "/" # defaults to root of index "/" should match folders under path
|
||||
defaultEnabled: true # should be added as a default source for new users?
|
||||
createUserDir: false # create a user directory for each user under defaultUserScope + username
|
||||
@@ -105,6 +119,7 @@ frontend:
|
||||
favicon: "" # path to a favicon to use for the frontend
|
||||
description: "FileBrowser Quantum is a file manager for the web which can be used to manage files on your server" # description that shows up in html head meta description
|
||||
loginIcon: "" # path to an image file for the login page icon
|
||||
oidcLoginButtonText: "" # text to display on the OIDC login button
|
||||
userDefaults:
|
||||
editorQuickSave: false # show quick save button in editor
|
||||
hideSidebarFileActions: false # hide the file actions in the sidebar
|
||||
@@ -165,8 +180,9 @@ integrations:
|
||||
media: # validate:omitempty
|
||||
ffmpegPath: "" # path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
|
||||
convert: # config for ffmpeg conversion settings
|
||||
imagePreview: # supported image preview formats. defaults to false for all types unless explicitly enabled.
|
||||
imagePreview: # supported image preview formats. defaults vary by type (see individual type docs)
|
||||
heic: false
|
||||
jpeg: true
|
||||
videoPreview: # supported video preview formats. defaults to true for all types unless explicitly disabled.
|
||||
3g2: true
|
||||
3gp: true
|
||||
|
||||
|
Before Width: | Height: | Size: 949 B After Width: | Height: | Size: 949 B |
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 546 B |
|
Before Width: | Height: | Size: 969 B |
@@ -1,13 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Light background circle that matches light mode -->
|
||||
<circle cx="16" cy="16" r="16" fill="#f5f5f5"/>
|
||||
|
||||
<!-- Back folder (orange, barely higher and left) -->
|
||||
<path d="M5 8h6l2 2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2z"
|
||||
fill="#FF9800"/>
|
||||
|
||||
<!-- Front folder (blue, almost same position) -->
|
||||
<path d="M6 9h6l2 2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V11a2 2 0 0 1 2-2z"
|
||||
fill="#2196F3"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 527 B |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
@@ -1,10 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Back folder (orange, barely higher and left) -->
|
||||
<path d="M5 8h6l2 2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2z"
|
||||
fill="#FF9800"/>
|
||||
|
||||
<!-- Front folder (blue, almost same position) -->
|
||||
<path d="M6 9h6l2 2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V11a2 2 0 0 1 2-2z"
|
||||
fill="#2196F3"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 415 B |