Compare commits
57
Commits
main
...
beta/v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55dcd54917 | ||
|
|
3e9469c2d1 | ||
|
|
bfa8d0f1a2 | ||
|
|
815c060d55 | ||
|
|
8bd927ec6a | ||
|
|
f624fff88f | ||
|
|
494baf2c0b | ||
|
|
95ab565324 | ||
|
|
04135038c1 | ||
|
|
e72a8fece0 | ||
|
|
5bcbf450a8 | ||
|
|
252f329be8 | ||
|
|
f6c043382d | ||
|
|
690ef5d246 | ||
|
|
347ae9c430 | ||
|
|
63f09d6eaa | ||
|
|
9d084d4d2b | ||
|
|
33c7359f7c | ||
|
|
d5421886bd | ||
|
|
7f262b2a16 | ||
|
|
cd115ac6c6 | ||
|
|
b7ddd535a9 | ||
|
|
fc62c6c9c7 | ||
|
|
7d114bece0 | ||
|
|
b8d59cdb6a | ||
|
|
e9b8cbdedc | ||
|
|
65e7a50233 | ||
|
|
764131efeb | ||
|
|
14b16a86df | ||
|
|
0df891e566 | ||
|
|
56a0666daf | ||
|
|
0e56d8e241 | ||
|
|
a850132695 | ||
|
|
43c57cabf6 | ||
|
|
426381a8bb | ||
|
|
01c025f31d | ||
|
|
b3703698e4 | ||
|
|
c2e6065f05 | ||
|
|
f4814e6315 | ||
|
|
2cedc3d7d3 | ||
|
|
8994f01986 | ||
|
|
4bbf70ccb7 | ||
|
|
4bf4dac969 | ||
|
|
c000eef6e5 | ||
|
|
70082d25b4 | ||
|
|
5e6fb6b95a | ||
|
|
29605bcfb8 | ||
|
|
06ad835900 | ||
|
|
1b77c6816e | ||
|
|
97bd657751 | ||
|
|
625cc58e4c | ||
|
|
b2332db888 | ||
|
|
fa5be1aa70 | ||
|
|
fb8dcd8711 | ||
|
|
2b98c92d5c | ||
|
|
4a54ef8d16 | ||
|
|
19926422c2 |
No files matched your search
@@ -59,7 +59,7 @@ jobs:
|
||||
target_commitish: ${{ github.sha }}
|
||||
token: ${{ secrets.PAT }}
|
||||
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
|
||||
prerelease: false
|
||||
prerelease: true
|
||||
make_latest: false
|
||||
draft: false
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
name: dev release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "dev/v[0-9]+.[0-9]+.[0-9]+"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
push_release_to_registry:
|
||||
name: Push release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{github.actor}}
|
||||
password: ${{ secrets.PAT }}
|
||||
- name: Extract metadata (tags, labels) for Docker and GHCR
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
gtstef/filebrowser
|
||||
ghcr.io/gtsteffaniak/filebrowser
|
||||
- name: Modify version names
|
||||
id: modify-json
|
||||
run: |
|
||||
TAGS="${{ steps.meta.outputs.tags }}"
|
||||
# Apply modifications to both docker.io and ghcr.io tags
|
||||
CLEANED_TAGS=$(echo "$TAGS" | sed 's/filebrowser:dev-v/filebrowser:/g' | sed -E 's/(filebrowser:[0-9]+\.[0-9]+\.[0-9]+)/\1-dev/g')
|
||||
echo "cleaned_tag<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CLEANED_TAGS" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
|
||||
platforms: linux/amd64
|
||||
file: ./_docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
+4
-1
@@ -20,7 +20,6 @@ frontend/tsconfig.tsbuildinfo
|
||||
/frontend/screenshots
|
||||
/frontend/tests/playwright-files/demo*
|
||||
/frontend/loginAuth.json
|
||||
/frontend/package-lock.json
|
||||
/backend/vendor
|
||||
/backend/*.cov
|
||||
/backend/*test_config.yaml
|
||||
@@ -53,3 +52,7 @@ bin/
|
||||
build/
|
||||
*__debug*
|
||||
backend/custom.css
|
||||
|
||||
# Runtime cache directories created by tests/application
|
||||
backend/preview/heic/
|
||||
backend/preview/thumbnails/
|
||||
+47
-4
@@ -2,6 +2,53 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
|
||||
|
||||
## v1.1.0-beta
|
||||
|
||||
**New Features**:
|
||||
- Added tools
|
||||
- size analyzer -- quickly show largest files in interactive graphical chart
|
||||
- duplicate finder -- find duplicate files larger than 1MB
|
||||
- material icon picker and previewer
|
||||
- Listing View Style updates
|
||||
- Status bar for listing view #1459
|
||||
- Duration field shows up if media is shown with duration
|
||||
- More dynamic view modes with additional styling changes
|
||||
- Total display modes consolidated into 3 main groups: list, gallery, normal. "icon" and "compact" mode exist as views based on display size setting.
|
||||
- Customizable sidebar links
|
||||
- Logged in users can have their preferences saved and synced
|
||||
- Can rearrange, add custom links, and save your preferences.
|
||||
- Can customize shares with their own custom sidebar links.
|
||||
- Dynamic scopes for OIDC #1414 #1363
|
||||
- Share and Access Rules validation
|
||||
- If a file/path is moved/renamed in UI its rules and shares will always follow.
|
||||
- If a file/path is moved/renamed outside UI, a warning message will show and ability to associate to new path
|
||||
- Cascade delete access in Access Management when deleting a User #1347
|
||||
- Enhanced notifications #1331
|
||||
- Added "toast" notification type support -- most success messages show as toast now.
|
||||
- multiple stacked notifications possible
|
||||
- notification button support, such as click to open folder after moving file.
|
||||
- notification history (not including toasts)
|
||||
- Add swipe and visual timeout to notifications #1600
|
||||
- Choose different bind ip via `server.listen` #1573
|
||||
- Allow disabling clearing cache each startup via `server.cacheDirCleanup: false` in config #1576
|
||||
|
||||
**Notes**:
|
||||
- more efficient user update actions. Successful PUT actions return only 204 if successful.
|
||||
- Do not enter directory on "New Folder" action #1343
|
||||
- Improved indexing performance - individual scanners per directory at root.
|
||||
- Improved search memory efficiency
|
||||
- Improved scrolling performance
|
||||
- Static file handling has been streamlined, could potentially see some changes from this.
|
||||
- No longer automatically publishing any dev builds to docker
|
||||
- deprecated "DefaultLandingPage" option -- now determined by first source link in sidebar.
|
||||
- Improve rename prompt #1608 #1556
|
||||
|
||||
**BugFixes**:
|
||||
- uploading/editing/saving to password protected shares error
|
||||
- updating a share with password protection without a password removed the password.
|
||||
- per-user download counts were not persistant accross restarts
|
||||
- "Update" button in Chinese wraps incorrectely #1542
|
||||
|
||||
## v1.0.1-stable
|
||||
|
||||
**BugFixes**:
|
||||
@@ -38,11 +85,7 @@ All notable changes to this project will be documented in this file. For commit
|
||||
- fixed some condition that the halloween background doesn't load properly
|
||||
- some comments not showing up on config viewer in settings
|
||||
|
||||
<<<<<<< HEAD
|
||||
## v1.0.0-beta
|
||||
=======
|
||||
## v1.0.0
|
||||
>>>>>>> main
|
||||
|
||||
**Notes**:
|
||||
- Enhanced onlyoffice debugger with more wholistic backend logs
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
|
||||
## Pinned
|
||||
|
||||
:loudspeaker: [Stable Release & v1.0.0 Update](https://github.com/gtsteffaniak/filebrowser/discussions/1293)
|
||||
|
||||
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
|
||||
|
||||
## About
|
||||
@@ -35,26 +33,26 @@ Ready to try it out? See [Getting Started Docs](https://filebrowserquantum.com/e
|
||||
|
||||
FileBrowser Quantum is a massive fork of the file browser open-source project with the following changes:
|
||||
|
||||
1. ✅ Multiple sources support
|
||||
1. ✅ Add and configure multiple sources
|
||||
2. ✅ Login support for OIDC, password + 2FA, and proxy.
|
||||
3. ✅ Beautiful, Responsive, and Customizable user interface.
|
||||
4. ✅ Simplified configuration via `config.yaml` config file.
|
||||
4. ✅ Streamlined configuration via `config.yaml` config file.
|
||||
5. ✅ Ultra-efficient [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) and real-time updates
|
||||
- Real-time search results as you type.
|
||||
- Real-time monitoring and updates in the UI.
|
||||
- Search supports file and folder sizes, along with various filters.
|
||||
6. ✅ Better listing browsing
|
||||
- More file type previews, such as **office** and **video** file previews
|
||||
- Instantly switches view modes and sort order without reloading data.
|
||||
- Folder sizes are displayed.
|
||||
- Better thumbnail support including **office**, **video**, and **album artwork**
|
||||
- Faster and more responsive views with animations.
|
||||
- Folder sizes are displayed and support for thumbnails
|
||||
- Navigating remembers the last scroll position.
|
||||
7. ✅ Highly configurable and customizable sharing options, incuding:
|
||||
7. ✅ Highly configurable and customizable sharing options
|
||||
- share expiration time
|
||||
- users who can access share (including anonymous)
|
||||
- styling and themes
|
||||
- file viewing, editing, and uploading permissions
|
||||
8. ✅ Directory-level access control that can be scoped to user or group.
|
||||
8. ✅ Developer API support
|
||||
9. ✅ Developer API support
|
||||
- Ability to create long-lived API Tokens.
|
||||
- A helpful Swagger page is available at `/swagger` endpoint for API enabled users.
|
||||
|
||||
@@ -102,11 +100,11 @@ Linux | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Windows | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
|
||||
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
Has Stable Release? | :construction: | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||
webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||
Dedicated docs site? | :construction: | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||
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 |
|
||||
|
||||
+2
-1
@@ -33,8 +33,9 @@ RUN adduser -D -s /bin/true -u 1000 filebrowser
|
||||
USER filebrowser
|
||||
WORKDIR /home/filebrowser
|
||||
# copy config to two locations for compatibility
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "/app/config.yaml", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
|
||||
## sanity checks
|
||||
|
||||
@@ -30,6 +30,7 @@ USER filebrowser
|
||||
WORKDIR /home/filebrowser
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
|
||||
## sanity checks
|
||||
|
||||
@@ -98,11 +98,11 @@ func TestSortItems(t *testing.T) {
|
||||
{Name: "apple"},
|
||||
{Name: "Banana"},
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "File2.txt"},
|
||||
{Name: "File10.txt"},
|
||||
{Name: "File1"},
|
||||
{Name: "banana"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File2.txt"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File10.txt"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File1"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "banana"}},
|
||||
},
|
||||
},
|
||||
expected: iteminfo.FileInfo{
|
||||
@@ -112,11 +112,11 @@ func TestSortItems(t *testing.T) {
|
||||
{Name: "apple"},
|
||||
{Name: "Banana"},
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "banana"},
|
||||
{Name: "File1"},
|
||||
{Name: "File10.txt"},
|
||||
{Name: "File2.txt"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "banana"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File1"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File10.txt"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File2.txt"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -128,10 +128,10 @@ func TestSortItems(t *testing.T) {
|
||||
{Name: "Cat.txt"},
|
||||
{Name: "apple"},
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "Zebra"},
|
||||
{Name: "apple"},
|
||||
{Name: "cat"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "Zebra"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "apple"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "cat"}},
|
||||
},
|
||||
},
|
||||
expected: iteminfo.FileInfo{
|
||||
@@ -140,10 +140,10 @@ func TestSortItems(t *testing.T) {
|
||||
{Name: "Cat.txt"},
|
||||
{Name: "dog.txt"},
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "apple"},
|
||||
{Name: "cat"},
|
||||
{Name: "Zebra"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "apple"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "cat"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "Zebra"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -153,7 +153,7 @@ func TestSortItems(t *testing.T) {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
test.input.SortItems()
|
||||
|
||||
getNames := func(items []iteminfo.ItemInfo) []string {
|
||||
getFolderNames := func(items []iteminfo.ItemInfo) []string {
|
||||
names := []string{}
|
||||
for _, folder := range items {
|
||||
names = append(names, folder.Name)
|
||||
@@ -161,15 +161,23 @@ func TestSortItems(t *testing.T) {
|
||||
return names
|
||||
}
|
||||
|
||||
actualFolderNames := getNames(test.input.Folders)
|
||||
expectedFolderNames := getNames(test.expected.Folders)
|
||||
getFileNames := func(items []iteminfo.ExtendedItemInfo) []string {
|
||||
names := []string{}
|
||||
for _, file := range items {
|
||||
names = append(names, file.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
actualFolderNames := getFolderNames(test.input.Folders)
|
||||
expectedFolderNames := getFolderNames(test.expected.Folders)
|
||||
|
||||
if !reflect.DeepEqual(actualFolderNames, expectedFolderNames) {
|
||||
t.Errorf("Folders not sorted correctly.\nGot: %v\nExpected: %v", actualFolderNames, expectedFolderNames)
|
||||
}
|
||||
|
||||
actualFileNames := getNames(test.input.Files)
|
||||
expectedFileNames := getNames(test.expected.Files)
|
||||
actualFileNames := getFileNames(test.input.Files)
|
||||
expectedFileNames := getFileNames(test.expected.Files)
|
||||
|
||||
if !reflect.DeepEqual(actualFileNames, expectedFileNames) {
|
||||
t.Errorf("Files not sorted correctly.\nGot: %v\nExpected: %v", actualFileNames, expectedFileNames)
|
||||
@@ -178,73 +186,8 @@ func TestSortItems(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFilesCacheClearing(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "filebrowser_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create a test file
|
||||
testFile := filepath.Join(tempDir, "Test Object")
|
||||
err = os.WriteFile(testFile, []byte("test content"), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Initialize a mock index
|
||||
idx := indexing.Index{
|
||||
Source: settings.Source{
|
||||
Path: tempDir,
|
||||
},
|
||||
}
|
||||
|
||||
// Get the real path and cache it
|
||||
_, isDir, err := idx.GetRealPath("/Test Object")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get real path: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file is detected as a file (not directory)
|
||||
if isDir {
|
||||
t.Errorf("Expected file to be detected as file, but got directory")
|
||||
}
|
||||
|
||||
// Initialize the index in the indexing system
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: "test",
|
||||
Path: tempDir,
|
||||
}, true) // true for mock mode
|
||||
|
||||
// Delete the file
|
||||
err = DeleteFiles("test", testFile, tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete file: %v", err)
|
||||
}
|
||||
|
||||
// Create a directory with the same name
|
||||
testDir := filepath.Join(tempDir, "Test Object")
|
||||
err = os.Mkdir(testDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test directory: %v", err)
|
||||
}
|
||||
|
||||
// Get the real path again - it should now detect it as a directory
|
||||
// The cache should have been cleared, so it should re-detect the type
|
||||
realPath2, isDir2, err := idx.GetRealPath("/Test Object")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get real path after recreation: %v", err)
|
||||
}
|
||||
|
||||
// Verify the directory is detected as a directory
|
||||
if !isDir2 {
|
||||
t.Errorf("Expected directory to be detected as directory, but got file. Real path: %s", realPath2)
|
||||
}
|
||||
|
||||
// Clean up
|
||||
os.RemoveAll(tempDir)
|
||||
}
|
||||
// TestDeleteFilesCacheClearing was removed because cache clearing is not performed
|
||||
// per design decision - RealPathCache is auxiliary and doesn't need clearing
|
||||
|
||||
func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
// Initialize the index in mock mode (no filesystem operations)
|
||||
@@ -282,9 +225,11 @@ func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
}
|
||||
|
||||
// Add the file to the parent's Files slice
|
||||
rootInfo.Files = append(rootInfo.Files, iteminfo.ItemInfo{
|
||||
Name: "Test Object",
|
||||
Size: 25, // Length of "This is test file content"
|
||||
rootInfo.Files = append(rootInfo.Files, iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "Test Object",
|
||||
Size: 25, // Length of "This is test file content"
|
||||
},
|
||||
})
|
||||
|
||||
// Verify the directory was replaced with a file in the mock data
|
||||
@@ -341,8 +286,8 @@ func TestOverrideFileToDirectory(t *testing.T) {
|
||||
Name: "/",
|
||||
Type: "directory",
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "Test Object", Size: 12}, // Length of "test content"
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "Test Object", Size: 12}}, // Length of "test content"
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
@@ -80,9 +81,55 @@ func FileInfoFaster(opts utils.FileOptions, access *access.Storage) (*iteminfo.E
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
if opts.Content || opts.Metadata {
|
||||
|
||||
// For directories, populate metadata for audio/video files ONLY if explicitly requested
|
||||
// This avoids expensive ffprobe calls on every directory listing
|
||||
if isDir && opts.Metadata {
|
||||
startTime := time.Now()
|
||||
metadataCount := 0
|
||||
|
||||
for i := range response.Files {
|
||||
fileItem := &response.Files[i]
|
||||
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
|
||||
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
|
||||
|
||||
if isItemAudio || isItemVideo {
|
||||
// Get the real path for this file
|
||||
itemRealPath, _, _ := index.GetRealPath(opts.Path, fileItem.Name)
|
||||
|
||||
// Extract metadata for audio files (without album art for performance)
|
||||
if isItemAudio {
|
||||
err := extractAudioMetadata(context.Background(), fileItem, itemRealPath, opts.AlbumArt || opts.Content, opts.Metadata)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract metadata for file: "+fileItem.Name, err)
|
||||
} else {
|
||||
metadataCount++
|
||||
}
|
||||
} else if isItemVideo {
|
||||
// Extract duration for video files
|
||||
err := extractVideoMetadata(context.Background(), fileItem, itemRealPath)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: "+fileItem.Name, err)
|
||||
} else {
|
||||
metadataCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if metadataCount > 0 {
|
||||
elapsed := time.Since(startTime)
|
||||
logger.Debugf("Extracted metadata for %d audio/video files in %v (avg: %v per file)",
|
||||
metadataCount, elapsed, elapsed/time.Duration(metadataCount))
|
||||
}
|
||||
}
|
||||
|
||||
// Extract content/metadata when explicitly requested OR for single file audio/video requests
|
||||
isAudioVideo := strings.HasPrefix(info.Type, "audio") || strings.HasPrefix(info.Type, "video")
|
||||
if opts.Content || opts.Metadata || (!isDir && isAudioVideo) {
|
||||
processContent(response, index, opts)
|
||||
}
|
||||
|
||||
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
|
||||
response.OnlyOfficeId = generateOfficeId(realPath)
|
||||
}
|
||||
@@ -99,6 +146,18 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
}
|
||||
|
||||
if isVideo {
|
||||
// Extract duration for video
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
err := extractVideoMetadata(context.Background(), extItem, info.RealPath)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: "+info.RealPath, info.Name, err)
|
||||
} else {
|
||||
info.Metadata = extItem.Metadata
|
||||
}
|
||||
|
||||
// Handle subtitles if requested
|
||||
if opts.ExtractEmbeddedSubtitles {
|
||||
parentPath := filepath.Dir(info.Path)
|
||||
parentInfo, exists := idx.GetReducedMetadata(parentPath, true)
|
||||
@@ -114,11 +173,17 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
}
|
||||
|
||||
if isAudio {
|
||||
err := extractAudioMetadata(info)
|
||||
// Create an ExtendedItemInfo to hold the metadata
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
|
||||
} else {
|
||||
info.HasPreview = info.AudioMeta.AlbumArt != ""
|
||||
// Copy metadata to ExtendedFileInfo
|
||||
info.Metadata = extItem.Metadata
|
||||
info.HasPreview = extItem.Metadata != nil && extItem.Metadata.AlbumArt != ""
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -148,8 +213,9 @@ func generateOfficeId(realPath string) string {
|
||||
}
|
||||
|
||||
// extractAudioMetadata extracts metadata from an audio file using dhowden/tag
|
||||
func extractAudioMetadata(item *iteminfo.ExtendedFileInfo) error {
|
||||
file, err := os.Open(item.RealPath)
|
||||
// and optionally extracts duration using the ffmpeg service with concurrency control
|
||||
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool) error {
|
||||
file, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -172,7 +238,7 @@ func extractAudioMetadata(item *iteminfo.ExtendedFileInfo) error {
|
||||
return err
|
||||
}
|
||||
|
||||
item.AudioMeta = &iteminfo.AudioMetadata{
|
||||
item.Metadata = &iteminfo.MediaMetadata{
|
||||
Title: m.Title(),
|
||||
Artist: m.Artist(),
|
||||
Album: m.Album(),
|
||||
@@ -182,50 +248,96 @@ func extractAudioMetadata(item *iteminfo.ExtendedFileInfo) error {
|
||||
|
||||
// Extract track number
|
||||
track, _ := m.Track()
|
||||
item.AudioMeta.Track = track
|
||||
item.Metadata.Track = track
|
||||
|
||||
// Extract duration ONLY if explicitly requested using the ffmpeg VideoService
|
||||
// This respects concurrency limits and gracefully handles missing ffmpeg
|
||||
if getDuration {
|
||||
ffmpegService := ffmpeg.NewFFmpegService(5, false, "")
|
||||
if ffmpegService != nil {
|
||||
startTime := time.Now()
|
||||
if duration, err := ffmpegService.GetMediaDuration(ctx, realPath); err == nil {
|
||||
item.Metadata.Duration = int(duration)
|
||||
elapsed := time.Since(startTime)
|
||||
if elapsed > 50*time.Millisecond {
|
||||
logger.Debugf("Duration extraction took %v for file: %s", elapsed, item.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !getArt {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract album art and encode as base64 with strict size limits
|
||||
if picture := m.Picture(); picture != nil && picture.Data != nil {
|
||||
// More aggressive size limit to prevent memory issues (max 5MB)
|
||||
if len(picture.Data) <= 5*1024*1024 {
|
||||
item.AudioMeta.AlbumArt = base64.StdEncoding.EncodeToString(picture.Data)
|
||||
item.Metadata.AlbumArt = base64.StdEncoding.EncodeToString(picture.Data)
|
||||
} else {
|
||||
logger.Debugf("Skipping album art for %s: too large (%d bytes)", item.RealPath, len(picture.Data))
|
||||
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, len(picture.Data))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteFiles(source, absPath string, absDirPath string) error {
|
||||
err := os.RemoveAll(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
// extractVideoMetadata extracts duration from video files using ffprobe
|
||||
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string) error {
|
||||
// Extract duration using the ffmpeg VideoService with concurrency control
|
||||
videoService := ffmpeg.NewFFmpegService(10, false, "")
|
||||
if videoService != nil {
|
||||
duration, err := videoService.GetMediaDuration(ctx, realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if duration > 0 {
|
||||
item.Metadata = &iteminfo.MediaMetadata{
|
||||
Duration: int(duration),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteFiles(source, absPath string, absDirPath string, isDir bool) error {
|
||||
index := indexing.GetIndex(source)
|
||||
if index == nil {
|
||||
return fmt.Errorf("could not get index: %v ", source)
|
||||
}
|
||||
if index.Config.DisableIndexing {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear RealPathCache entries for the deleted path to prevent cache issues
|
||||
// when a folder with the same name is created later
|
||||
indexPath := index.MakeIndexPath(absPath)
|
||||
realPath, _, err := index.GetRealPath(indexPath)
|
||||
if err == nil {
|
||||
// Clear both the path and the isdir cache entries
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.RealPathCache.Delete(realPath + ":isdir")
|
||||
}
|
||||
if !index.Config.DisableIndexing {
|
||||
indexPath := index.MakeIndexPath(absPath)
|
||||
|
||||
refreshConfig := utils.FileOptions{Path: index.MakeIndexPath(absDirPath), IsDir: true}
|
||||
err = index.RefreshFileInfo(refreshConfig)
|
||||
if err != nil {
|
||||
// Perform the physical deletion
|
||||
err := os.RemoveAll(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove metadata from index
|
||||
if isDir {
|
||||
// Recursively remove directory and all subdirectories from index
|
||||
index.DeleteMetadata(indexPath, true, true)
|
||||
} else {
|
||||
// Remove file from parent's file list
|
||||
index.DeleteMetadata(indexPath, false, false)
|
||||
}
|
||||
|
||||
// Refresh the parent directory to recalculate sizes and update counts
|
||||
// This will traverse up the tree and update all parent sizes correctly
|
||||
refreshConfig := utils.FileOptions{
|
||||
Path: index.MakeIndexPath(absDirPath),
|
||||
IsDir: true,
|
||||
}
|
||||
err = index.RefreshFileInfo(refreshConfig)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
// Indexing disabled, just delete the file
|
||||
return os.RemoveAll(absPath)
|
||||
}
|
||||
|
||||
func RefreshIndex(source string, path string, isDir bool, recursive bool) error {
|
||||
@@ -249,6 +361,21 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// For directories, check if the path exists on disk
|
||||
// If it doesn't exist, remove it from the index
|
||||
if isDir {
|
||||
realPath, _, err := idx.GetRealPath(path)
|
||||
if err == nil {
|
||||
// Check if the directory exists on disk
|
||||
if !Exists(realPath) {
|
||||
// Directory no longer exists, remove it from the index
|
||||
// This clears both Directories and DirectoriesLedger maps
|
||||
idx.DeleteMetadata(path, true, false)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err := idx.RefreshFileInfo(utils.FileOptions{Path: path, IsDir: isDir, Recursive: recursive})
|
||||
return err
|
||||
}
|
||||
@@ -282,21 +409,17 @@ func validateMoveDestination(src, dst string, isSrcDir bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string, s *share.Storage) error {
|
||||
func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string, s *share.Storage, a *access.Storage) error {
|
||||
// Check if source and destination are the same file
|
||||
if realsrc == realdst {
|
||||
return fmt.Errorf("cannot move a file to itself: %s", realsrc)
|
||||
}
|
||||
|
||||
// Validate the move operation before executing
|
||||
if err := validateMoveDestination(realsrc, realdst, isSrcDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := fileutils.MoveFile(realsrc, realdst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// For move operations:
|
||||
// 1. Delete the source from the index (recursively if it's a directory)
|
||||
// 2. Recursively index the destination to capture the entire moved tree
|
||||
|
||||
// Get indexes for deletion and refresh operations
|
||||
srcIdx := indexing.GetIndex(sourceIndex)
|
||||
if srcIdx == nil {
|
||||
@@ -307,44 +430,71 @@ func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
|
||||
return fmt.Errorf("could not get destination index: %v", destIndex)
|
||||
}
|
||||
|
||||
// Delete from source index (recursively for directories)
|
||||
go srcIdx.DeleteMetadata(realsrc, isSrcDir, isSrcDir) //nolint:errcheck
|
||||
|
||||
// Clear RealPathCache entries for the moved path to prevent cache issues
|
||||
// when a file/folder with the same name is created later
|
||||
// Prepare paths for index operations
|
||||
srcIndexPath := srcIdx.MakeIndexPath(realsrc)
|
||||
srcRealPath, _, err := srcIdx.GetRealPath(srcIndexPath)
|
||||
if err == nil {
|
||||
// Clear both the path and the isdir cache entries
|
||||
indexing.RealPathCache.Delete(srcRealPath)
|
||||
indexing.RealPathCache.Delete(srcRealPath + ":isdir")
|
||||
srcParentPath := filepath.Dir(realsrc)
|
||||
|
||||
// Perform the physical move
|
||||
err := fileutils.MoveFile(realsrc, realdst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// For move operations, refresh the moved item and its new parent directory
|
||||
// Run all refresh operations in a goroutine to avoid blocking the API
|
||||
if isSrcDir {
|
||||
go func() {
|
||||
// When moving a folder, refresh the folder itself recursively FIRST
|
||||
// Must complete before parent refresh to avoid race condition
|
||||
RefreshIndex(destIndex, realdst, true, true) //nolint:errcheck
|
||||
// Handle SOURCE cleanup (treat as deletion)
|
||||
if !srcIdx.Config.DisableIndexing {
|
||||
// Remove metadata from source index
|
||||
if isSrcDir {
|
||||
// Recursively remove directory and all subdirectories from source index
|
||||
srcIdx.DeleteMetadata(srcIndexPath, true, true)
|
||||
} else {
|
||||
// Remove file from source parent's file list
|
||||
srcIdx.DeleteMetadata(srcIndexPath, false, false)
|
||||
}
|
||||
|
||||
// THEN refresh the parent directory so it sees the newly indexed child
|
||||
// Refresh the source parent directory to recalculate sizes and update counts
|
||||
go RefreshIndex(sourceIndex, srcParentPath, true, false) //nolint:errcheck
|
||||
}
|
||||
|
||||
// Handle DESTINATION indexing
|
||||
if !dstIdx.Config.DisableIndexing {
|
||||
if isSrcDir {
|
||||
go func() {
|
||||
// When moving a folder, refresh the folder itself recursively FIRST
|
||||
// Must complete before parent refresh to avoid race condition
|
||||
RefreshIndex(destIndex, realdst, true, true) //nolint:errcheck
|
||||
|
||||
// THEN refresh the parent directory so it sees the newly indexed child
|
||||
parentDir := filepath.Dir(realdst)
|
||||
RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
|
||||
}()
|
||||
} else {
|
||||
// If moving a file, just refresh the parent directory
|
||||
parentDir := filepath.Dir(realdst)
|
||||
RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
|
||||
}()
|
||||
} else {
|
||||
// If moving a file, just refresh the parent directory
|
||||
parentDir := filepath.Dir(realdst)
|
||||
go RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
|
||||
go RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// Use backend source paths to match how shares are stored
|
||||
go s.UpdateShares(srcIdx.Path, srcIdx.MakeIndexPath(realsrc), dstIdx.Path, dstIdx.MakeIndexPath(realdst)) //nolint:errcheck
|
||||
|
||||
// Update access rules for the moved path
|
||||
if a != nil {
|
||||
// If moving within the same source, update the rules
|
||||
if srcIdx.Path == dstIdx.Path {
|
||||
go a.UpdateRules(srcIdx.Path, srcIdx.MakeIndexPath(realsrc), dstIdx.MakeIndexPath(realdst)) //nolint:errcheck
|
||||
}
|
||||
// Cross-source moves don't preserve access rules (they're source-specific)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string) error {
|
||||
// Check if source and destination are the same file
|
||||
if realsrc == realdst {
|
||||
return fmt.Errorf("cannot copy a file to itself: %s", realsrc)
|
||||
}
|
||||
|
||||
// Validate the copy operation before executing
|
||||
if err := validateMoveDestination(realsrc, realdst, isSrcDir); err != nil {
|
||||
return err
|
||||
@@ -397,12 +547,6 @@ func WriteDirectory(opts utils.FileOptions) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not remove existing file to create directory: %v", err)
|
||||
}
|
||||
// Clear the cache for the removed file
|
||||
realPath, _, err = idx.GetRealPath(opts.Path)
|
||||
if err == nil {
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.RealPathCache.Delete(realPath + ":isdir")
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the parent directories exist
|
||||
@@ -442,9 +586,6 @@ func WriteFile(source, path string, in io.Reader) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not remove existing directory to create file: %v", err)
|
||||
}
|
||||
// Clear the cache for the removed directory (use realPath without re-fetching)
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.RealPathCache.Delete(realPath + ":isdir")
|
||||
}
|
||||
|
||||
// Open the file for writing (create if it doesn't exist, truncate if it does)
|
||||
|
||||
@@ -3,81 +3,106 @@
|
||||
/usr/local/go/bin/go
|
||||
? github.com/gtsteffaniak/filebrowser/backend [no test files]
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/diskcache 0.004s
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/diskcache 0.005s
|
||||
2025/11/07 17:48:07 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:07 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /tmp/filebrowser_test3280924123/: no such file or directory
|
||||
2025/11/07 17:48:07 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:07 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:07 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:07 [ERROR] Root scanner error: open /tmp/filebrowser_test3280924123/: no such file or directory
|
||||
2025/11/07 17:48:07 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /tmp/filebrowser_test3280924123/: no such file or directory
|
||||
2025/11/07 17:48:07 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:07 [DEBUG] Skipping source update event for mock index.
|
||||
2025/11/07 17:48:07 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
|
||||
2025/11/07 17:48:07 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:07 [ERROR] Root scanner error: open /mock/path/: no such file or directory
|
||||
2025/11/07 17:48:07 [ERROR] Root scanner error: open /mock/path/: no such file or directory
|
||||
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
|
||||
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
|
||||
2025/11/07 17:48:07 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:07 [DEBUG] Skipping source update event for mock index.
|
||||
2025/11/07 17:48:07 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:07 [DEBUG] Skipping source update event for mock index.
|
||||
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
|
||||
2025/11/07 17:48:07 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files 0.006s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils 0.003s
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils 0.002s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/auth [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/cmd [no test files]
|
||||
? github.com/gtsteffaniak/go-cache/cache [no test files]
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/cmd 0.014s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/common/errors [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/common/logger [no test files]
|
||||
2025/05/11 20:42:56 Using OnlyOffice secret from FILEBROWSER_ONLYOFFICE_SECRET environment variable
|
||||
2025/05/11 20:42:56 Using OnlyOffice secret from FILEBROWSER_ONLYOFFICE_SECRET environment variable
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
pkg: github.com/gtsteffaniak/filebrowser/backend/common/settings
|
||||
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
|
||||
10 374600 ns/op 192030 B/op 3237 allocs/op
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/common/settings 0.006s
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/common/settings 0.139s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/common/utils 0.003s
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/common/utils 0.004s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/common/version [no test files]
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/database/access 0.287s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/database/crud [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/database/share [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/database/storage [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt [no test files]
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/database/users 0.002s
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt 0.842s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/database/users 0.003s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/docs [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/events [no test files]
|
||||
2025/05/11 20:42:56 [DEBUG] Saving user [admin] changepass: true
|
||||
2025/05/11 20:42:56 [DEBUG] Saving user [non-admin] changepass: true
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
pkg: github.com/gtsteffaniak/filebrowser/backend/ffmpeg
|
||||
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
|
||||
BenchmarkGetOrientationFilter-8 10 63.00 ns/op 0 B/op 0 allocs/op
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/http 0.197s
|
||||
2025/05/11 20:42:57 initializing index: [test]
|
||||
2025/05/11 20:42:57 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:42:57 [DEBUG] Starting full scan for [test]
|
||||
2025/05/11 20:42:57 Index assessment : [test] complexity=simple directories=0 files=0
|
||||
2025/05/11 20:42:57 [DEBUG] Time spent indexing [test]: 0 seconds
|
||||
2025/05/11 20:42:57 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:42:57 [DEBUG] Next scan in 1m0s
|
||||
2025/05/11 20:42:58 initializing index: [test]
|
||||
2025/05/11 20:42:58 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:42:58 [DEBUG] Starting full scan for [test]
|
||||
2025/05/11 20:42:58 Index assessment : [test] complexity=simple directories=0 files=0
|
||||
2025/05/11 20:42:58 [DEBUG] Time spent indexing [test]: 0 seconds
|
||||
2025/05/11 20:42:58 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:42:58 [DEBUG] Next scan in 1m0s
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/ffmpeg 0.006s
|
||||
2025/11/07 17:48:08 [DEBUG] Saving user [admin] changepass: true
|
||||
2025/11/07 17:48:08 [DEBUG] Saving user [non-admin] changepass: true
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/http 0.248s
|
||||
2025/11/07 17:48:08 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
|
||||
2025/11/07 17:48:08 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
pkg: github.com/gtsteffaniak/filebrowser/backend/indexing
|
||||
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
|
||||
BenchmarkFillIndex-8 2025/05/11 20:42:59 initializing index: [test]
|
||||
2025/05/11 20:42:59 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:42:59 [DEBUG] Starting full scan for [test]
|
||||
2025/05/11 20:42:59 Index assessment : [test] complexity=simple directories=0 files=0
|
||||
2025/05/11 20:42:59 [DEBUG] Time spent indexing [test]: 0 seconds
|
||||
2025/05/11 20:42:59 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:42:59 [DEBUG] Next scan in 1m0s
|
||||
10 3548759 ns/op 38032 B/op 452 allocs/op
|
||||
BenchmarkCheckIndexExclude-8 10 172.6 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkCheckIndexConditionsInclude-8 10 118.2 ns/op 0 B/op 0 allocs/op
|
||||
2025/05/11 20:43:01 initializing index: [test]
|
||||
2025/05/11 20:43:01 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:43:01 [DEBUG] Starting full scan for [test]
|
||||
2025/05/11 20:43:01 Index assessment : [test] complexity=simple directories=0 files=0
|
||||
2025/05/11 20:43:01 [DEBUG] Time spent indexing [test]: 0 seconds
|
||||
2025/05/11 20:43:01 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:43:01 [DEBUG] Next scan in 1m0s
|
||||
BenchmarkSearchAllIndexes-8 2025/05/11 20:43:02 initializing index: [test]
|
||||
2025/05/11 20:43:02 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:43:02 [DEBUG] Starting full scan for [test]
|
||||
2025/05/11 20:43:02 Index assessment : [test] complexity=simple directories=0 files=0
|
||||
2025/05/11 20:43:02 [DEBUG] Time spent indexing [test]: 0 seconds
|
||||
2025/05/11 20:43:02 [DEBUG] Skipping source update event for mock index.
|
||||
2025/05/11 20:43:02 [DEBUG] Next scan in 1m0s
|
||||
10 781119 ns/op 38515 B/op 900 allocs/op
|
||||
BenchmarkFillIndex-8 2025/11/07 17:48:08 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
|
||||
10 3611835 ns/op 323676 B/op 1655 allocs/op
|
||||
2025/11/07 17:48:08 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
|
||||
BenchmarkSearchAllIndexes-8 2025/11/07 17:48:08 [INFO] initializing index: [test]
|
||||
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
|
||||
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
|
||||
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
|
||||
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
|
||||
10 784497 ns/op 97700 B/op 1061 allocs/op
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/indexing 5.097s
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/indexing 0.077s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo 0.005s
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo 0.006s
|
||||
PASS
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/preview 0.128s
|
||||
ok github.com/gtsteffaniak/filebrowser/backend/preview 0.130s
|
||||
? github.com/gtsteffaniak/filebrowser/backend/swagger/docs [no test files]
|
||||
? github.com/gtsteffaniak/filebrowser/backend/tools [no test files]
|
||||
+6
-2
@@ -110,7 +110,7 @@ func runCLI() bool {
|
||||
} else {
|
||||
logger.Infof("Creating non-admin user: %s\n", username)
|
||||
}
|
||||
newUser.Permissions = settings.Config.UserDefaults.Permissions
|
||||
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Permissions)
|
||||
newUser.Permissions.Admin = asAdmin
|
||||
err = storage.CreateUser(newUser, newUser.Permissions)
|
||||
if err != nil {
|
||||
@@ -128,6 +128,10 @@ func runCLI() bool {
|
||||
if asAdmin {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
// Ensure version is set for existing users being updated
|
||||
if user.Version == 0 {
|
||||
user.Version = 1
|
||||
}
|
||||
err = store.Users.Save(user, true, false)
|
||||
if err != nil {
|
||||
logger.Errorf("could not update user: %v", err)
|
||||
@@ -350,7 +354,7 @@ func createConfig(configpath string) {
|
||||
|
||||
func generateYaml() {
|
||||
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
|
||||
if generateConfig || settings.Config.Env.IsDevMode {
|
||||
if generateConfig || settings.Env.IsDevMode {
|
||||
logger.Info("Generating config.yaml")
|
||||
settings.GenerateYaml()
|
||||
}
|
||||
|
||||
+11
-6
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
|
||||
"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/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/preview"
|
||||
@@ -81,8 +82,8 @@ func StartFilebrowser() {
|
||||
// Dev mode enables development features like template hot-reloading
|
||||
_, err := os.Stat("http/dist")
|
||||
// In dev mode, always use filesystem assets. Otherwise, check if http/dist exists
|
||||
if !settings.Config.Env.IsDevMode {
|
||||
settings.Config.Env.EmbeddedFs = os.IsNotExist(err)
|
||||
if !settings.Env.IsDevMode {
|
||||
settings.Env.EmbeddedFs = os.IsNotExist(err)
|
||||
}
|
||||
|
||||
sourceList := []string{}
|
||||
@@ -94,7 +95,7 @@ func StartFilebrowser() {
|
||||
logger.Infof("Auth Methods : %v", settings.Config.Auth.AuthMethods)
|
||||
logger.Info(database)
|
||||
logger.Infof("Sources : %v", sourceList)
|
||||
logger.Debugf("Using Embedded FS : %v", settings.Config.Env.EmbeddedFs)
|
||||
logger.Debugf("Using Embedded FS : %v", settings.Env.EmbeddedFs)
|
||||
|
||||
serverConfig := settings.Config.Server
|
||||
swagInfo := docs.SwaggerInfo
|
||||
@@ -110,6 +111,7 @@ func StartFilebrowser() {
|
||||
validateUserInfo(!dbExists)
|
||||
validateOfficeIntegration()
|
||||
validateAccessRules()
|
||||
validateShareInfo()
|
||||
// Start the rootCMD in a goroutine
|
||||
go func() {
|
||||
if err := rootCMD(ctx, store, &serverConfig, shutdownComplete); err != nil {
|
||||
@@ -125,7 +127,9 @@ func StartFilebrowser() {
|
||||
case <-done:
|
||||
logger.Info("Server stopped unexpectedly. Shutting down...")
|
||||
}
|
||||
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
|
||||
if !*settings.Config.Server.CacheDirCleanup {
|
||||
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
|
||||
}
|
||||
|
||||
<-shutdownComplete // Ensure we don't exit prematurely
|
||||
// Wait for the server to stop
|
||||
@@ -138,9 +142,10 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
|
||||
}
|
||||
cacheDir := settings.Config.Server.CacheDir
|
||||
numWorkers := settings.Config.Server.NumImageProcessors
|
||||
ffpmpegPath := settings.Config.Integrations.Media.FfmpegPath
|
||||
ffmpeg.SetFFmpegPaths()
|
||||
|
||||
// setup disk cache
|
||||
err := preview.StartPreviewGenerator(numWorkers, ffpmpegPath, cacheDir)
|
||||
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
|
||||
if err != nil {
|
||||
logger.Fatalf("Error starting preview service: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// validateShareInfo migrates share links to add default sidebar links
|
||||
func validateShareInfo() {
|
||||
if store.Share == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get all shares
|
||||
shares, err := store.Share.All()
|
||||
if err != nil {
|
||||
logger.Debugf("No shares found or error getting shares: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
migratedCount := 0
|
||||
for _, link := range shares {
|
||||
// Check if this share needs migration (version not set or 0)
|
||||
if link.Version == 0 {
|
||||
// Add default sidebar links
|
||||
link.SidebarLinks = []users.SidebarLink{
|
||||
{
|
||||
Name: "Share QR Code and Info",
|
||||
Category: "shareInfo",
|
||||
Target: "#",
|
||||
Icon: "qr_code",
|
||||
},
|
||||
{
|
||||
Name: "Download",
|
||||
Category: "download",
|
||||
Target: "#",
|
||||
Icon: "download",
|
||||
},
|
||||
}
|
||||
|
||||
// Set version to 1 to indicate migration is complete
|
||||
link.Version = 1
|
||||
|
||||
// Save the updated share
|
||||
if err := store.Share.Save(link); err != nil {
|
||||
logger.Errorf("Failed to migrate share %s: %v", link.Hash, err)
|
||||
continue
|
||||
}
|
||||
|
||||
migratedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if migratedCount > 0 {
|
||||
logger.Infof("Migrated %d share links with default sidebar links", migratedCount)
|
||||
}
|
||||
}
|
||||
+6
-10
@@ -76,27 +76,23 @@ func updateUserScopes(user *users.User) bool {
|
||||
|
||||
// Preserve order by using Config.Server.Sources
|
||||
for _, src := range settings.Config.Server.Sources {
|
||||
realsource, ok := settings.Config.Server.NameToSource[src.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
existingScope, ok := existing[realsource.Path]
|
||||
existingScope, ok := existing[src.Path]
|
||||
if ok {
|
||||
// If scope is empty and there's a default, apply default
|
||||
if existingScope.Scope == "" {
|
||||
existingScope.Scope = src.Config.DefaultUserScope
|
||||
}
|
||||
} else if realsource.Config.DefaultEnabled {
|
||||
existingScope.Scope = realsource.Config.DefaultUserScope
|
||||
} else if src.Config.DefaultEnabled {
|
||||
existingScope.Scope = src.Config.DefaultUserScope
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: realsource.Path,
|
||||
Name: src.Path,
|
||||
Scope: existingScope.Scope,
|
||||
})
|
||||
seen[realsource.Path] = struct{}{}
|
||||
seen[src.Path] = struct{}{}
|
||||
}
|
||||
|
||||
// Preserve user-defined scopes not matching current sources, append to end
|
||||
@@ -111,7 +107,7 @@ func updateUserScopes(user *users.User) bool {
|
||||
}
|
||||
|
||||
func updateShowFirstLogin(user *users.User) bool {
|
||||
if user.ShowFirstLogin && !settings.Config.Env.IsFirstLoad {
|
||||
if user.ShowFirstLogin && !settings.Env.IsFirstLoad {
|
||||
user.ShowFirstLogin = false
|
||||
return true
|
||||
}
|
||||
|
||||
+125
-187
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
@@ -33,6 +34,9 @@ func Initialize(configFile string) {
|
||||
time.Sleep(5 * time.Second) // allow sleep time before exiting to give docker/kubernetes time before restarting
|
||||
logger.Fatal(err.Error())
|
||||
}
|
||||
setupLogging()
|
||||
// setup logging first to ensure we log any errors
|
||||
setupEnv()
|
||||
err = ValidateConfig(Config)
|
||||
if err != nil {
|
||||
errmsg := "The provided config file failed validation. "
|
||||
@@ -43,18 +47,30 @@ func Initialize(configFile string) {
|
||||
time.Sleep(5 * time.Second) // allow sleep time before exiting to give docker/kubernetes time before restarting
|
||||
logger.Fatal(err.Error())
|
||||
}
|
||||
Config.Env.IsPlaywright = os.Getenv("FILEBROWSER_PLAYWRIGHT_TEST") == "true"
|
||||
if Config.Env.IsPlaywright {
|
||||
logger.Warning("Running in playwright test mode. This is not recommended for production.")
|
||||
}
|
||||
// setup logging first to ensure we log any errors
|
||||
setupLogging()
|
||||
setupFs()
|
||||
setupServer()
|
||||
setupAuth(false)
|
||||
setupSources(false)
|
||||
setupUrls()
|
||||
setupFrontend(false)
|
||||
setupVideoPreview()
|
||||
setupMedia()
|
||||
}
|
||||
|
||||
func setupServer() {
|
||||
if Config.Server.ListenAddress == "" {
|
||||
Config.Server.ListenAddress = "0.0.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
func setupEnv() {
|
||||
Env.IsPlaywright = os.Getenv("FILEBROWSER_PLAYWRIGHT_TEST") == "true"
|
||||
if Env.IsPlaywright {
|
||||
logger.Warning("Running in playwright test mode. This is not recommended for production.")
|
||||
}
|
||||
Env.IsDevMode = os.Getenv("FILEBROWSER_DEVMODE") == "true"
|
||||
if Env.IsDevMode {
|
||||
logger.Warning("Running in dev mode. This is not recommended for production.")
|
||||
}
|
||||
}
|
||||
|
||||
func setupFs() {
|
||||
@@ -85,28 +101,8 @@ func setupFs() {
|
||||
}
|
||||
|
||||
func setupFrontend(generate bool) {
|
||||
if Config.Frontend.LoginIcon != "" {
|
||||
// check if file exists
|
||||
if _, err := os.Stat(Config.Frontend.LoginIcon); os.IsNotExist(err) {
|
||||
logger.Warningf("login icon file '%v' does not exist", Config.Frontend.LoginIcon)
|
||||
Config.Frontend.LoginIcon = ""
|
||||
} else {
|
||||
// validate image type
|
||||
validExtensions := []string{".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"}
|
||||
isValid := false
|
||||
lowerPath := strings.ToLower(Config.Frontend.LoginIcon)
|
||||
for _, ext := range validExtensions {
|
||||
if strings.HasSuffix(lowerPath, ext) {
|
||||
isValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isValid {
|
||||
logger.Warningf("login icon file '%v' is not a valid image type (supported: svg, png, jpg, jpeg, gif, webp, ico)", Config.Frontend.LoginIcon)
|
||||
Config.Frontend.LoginIcon = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
// Load login icon configuration at startup
|
||||
loadLoginIcon()
|
||||
if Config.Server.MinSearchLength == 0 {
|
||||
Config.Server.MinSearchLength = 3
|
||||
}
|
||||
@@ -162,7 +158,7 @@ func setupFrontend(generate bool) {
|
||||
loadCustomFavicon()
|
||||
}
|
||||
|
||||
func setupVideoPreview() {
|
||||
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)
|
||||
@@ -200,14 +196,6 @@ func setupVideoPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
func checkPathExists(realPath string) error {
|
||||
// check path exists
|
||||
if _, err := os.Stat(realPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("source path %v is currently not available", realPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupSources(generate bool) {
|
||||
if len(Config.Server.Sources) == 0 {
|
||||
logger.Fatal("There are no `server.sources` configured. If you have `server.root` configured, please update the config and add at least one `server.sources` with a `path` configured.")
|
||||
@@ -220,9 +208,9 @@ func setupSources(generate bool) {
|
||||
if err != nil {
|
||||
logger.Fatalf("error getting real path for source %v: %v", source.Path, err)
|
||||
}
|
||||
err = checkPathExists(realPath)
|
||||
if err != nil {
|
||||
logger.Warningf("error checking path exists: %v", err)
|
||||
exists := utils.CheckPathExists(realPath)
|
||||
if !exists {
|
||||
logger.Warningf("source path %v is currently not available", realPath)
|
||||
}
|
||||
name := filepath.Base(realPath)
|
||||
if name == "\\" {
|
||||
@@ -347,7 +335,7 @@ func setupLogging() {
|
||||
for _, logConfig := range Config.Server.Logging {
|
||||
// Enable debug logging automatically in dev mode
|
||||
levels := logConfig.Levels
|
||||
if Config.Env.IsDevMode {
|
||||
if os.Getenv("FILEBROWSER_DEVMODE") == "true" {
|
||||
levels = "info|warning|error|debug"
|
||||
}
|
||||
|
||||
@@ -365,10 +353,6 @@ func setupLogging() {
|
||||
log.Println("[ERROR] Failed to set up logger:", err)
|
||||
}
|
||||
}
|
||||
Config.Env.IsDevMode = os.Getenv("FILEBROWSER_DEVMODE") == "true"
|
||||
if Config.Env.IsDevMode {
|
||||
logger.Warning("Running in dev mode. This is not recommended for production.")
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfigWithDefaults(configFile string, generate bool) error {
|
||||
@@ -549,6 +533,7 @@ func setDefaults(generate bool) Settings {
|
||||
NameToSource: map[string]*Source{},
|
||||
MaxArchiveSizeGB: 50,
|
||||
CacheDir: "tmp",
|
||||
CacheDirCleanup: boolPtr(true),
|
||||
Filesystem: Filesystem{
|
||||
CreateFilePermission: "644",
|
||||
CreateDirectoryPermission: "755",
|
||||
@@ -570,21 +555,32 @@ func setDefaults(generate bool) Settings {
|
||||
},
|
||||
|
||||
UserDefaults: UserDefaults{
|
||||
DisableOnlyOfficeExt: ".md .txt .pdf",
|
||||
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
|
||||
StickySidebar: true,
|
||||
LockPassword: false,
|
||||
ShowHidden: false,
|
||||
DarkMode: true,
|
||||
DarkMode: boolPtr(true),
|
||||
DisableSettings: false,
|
||||
ViewMode: "normal",
|
||||
Locale: "en",
|
||||
GallerySize: 3,
|
||||
ThemeColor: "var(--blue)",
|
||||
Permissions: users.Permissions{
|
||||
Modify: false,
|
||||
Share: false,
|
||||
Admin: false,
|
||||
Api: false,
|
||||
Permissions: UserDefaultsPermissions{
|
||||
Modify: false,
|
||||
Share: false,
|
||||
Admin: false,
|
||||
Api: false,
|
||||
Download: boolPtr(true), // defaults to true
|
||||
},
|
||||
Preview: UserDefaultsPreview{
|
||||
HighQuality: boolPtr(true),
|
||||
Image: boolPtr(true),
|
||||
Video: boolPtr(true),
|
||||
MotionVideoPreview: boolPtr(true),
|
||||
Office: boolPtr(true),
|
||||
PopUp: boolPtr(true),
|
||||
AutoplayMedia: boolPtr(true),
|
||||
Folder: boolPtr(true),
|
||||
},
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 10,
|
||||
@@ -606,164 +602,106 @@ func setDefaults(generate bool) Settings {
|
||||
return s
|
||||
}
|
||||
|
||||
func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, error) {
|
||||
if len(scopes) == 0 {
|
||||
return Config.UserDefaults.DefaultScopes, nil
|
||||
// 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) {
|
||||
// Get absolute path
|
||||
absolutePath, err = filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not resolve path: %w", err)
|
||||
}
|
||||
newScopes := []users.SourceScope{}
|
||||
for _, scope := range scopes {
|
||||
|
||||
// first check if its already a path name and keep it
|
||||
source, ok := Config.Server.SourceMap[scope.Name]
|
||||
if ok {
|
||||
if scope.Scope == "" {
|
||||
scope.Scope = source.Config.DefaultUserScope
|
||||
}
|
||||
if !strings.HasPrefix(scope.Scope, "/") {
|
||||
scope.Scope = "/" + scope.Scope
|
||||
}
|
||||
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
|
||||
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
|
||||
}
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: source.Path, // backend name is path
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// check if its the name of a source and convert it to a path
|
||||
source, ok = Config.Server.NameToSource[scope.Name]
|
||||
if !ok {
|
||||
// source might no longer be configured
|
||||
continue
|
||||
}
|
||||
if scope.Scope == "" {
|
||||
scope.Scope = source.Config.DefaultUserScope
|
||||
}
|
||||
if !strings.HasPrefix(scope.Scope, "/") {
|
||||
scope.Scope = "/" + scope.Scope
|
||||
}
|
||||
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
|
||||
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
|
||||
}
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: source.Path, // backend name is path
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
// Check if file exists
|
||||
stat, err := os.Stat(absolutePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not access file: %w", err)
|
||||
}
|
||||
return newScopes, nil
|
||||
}
|
||||
|
||||
func ConvertToFrontendScopes(scopes []users.SourceScope) []users.SourceScope {
|
||||
newScopes := []users.SourceScope{}
|
||||
for _, scope := range scopes {
|
||||
if source, ok := Config.Server.SourceMap[scope.Name]; ok {
|
||||
// Replace scope.Name with source.Path while keeping the same Scope value
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: source.Name,
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
// 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
|
||||
for _, format := range allowedFormats {
|
||||
if ext == format {
|
||||
validFormat = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return newScopes
|
||||
}
|
||||
|
||||
func HasSourceByPath(scopes []users.SourceScope, sourcePath string) bool {
|
||||
for _, scope := range scopes {
|
||||
if scope.Name == sourcePath {
|
||||
return true
|
||||
}
|
||||
if !validFormat {
|
||||
return "", fmt.Errorf("unsupported format '%s', supported formats: %v", ext, allowedFormats)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GetScopeFromSourceName(scopes []users.SourceScope, sourceName string) (string, error) {
|
||||
source, ok := Config.Server.NameToSource[sourceName]
|
||||
if !ok {
|
||||
logger.Debug("Could not get scope from source name: ", sourceName)
|
||||
return "", fmt.Errorf("source with name not found %v", sourceName)
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
if scope.Name == source.Path {
|
||||
return scope.Scope, nil
|
||||
}
|
||||
}
|
||||
logger.Debugf("scope not found for source %v", sourceName)
|
||||
return "", fmt.Errorf("scope not found for source %v", sourceName)
|
||||
}
|
||||
|
||||
func GetScopeFromSourcePath(scopes []users.SourceScope, sourcePath string) (string, error) {
|
||||
for _, scope := range scopes {
|
||||
if scope.Name == sourcePath {
|
||||
return scope.Scope, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("scope not found for source %v", sourcePath)
|
||||
}
|
||||
|
||||
// assumes backend style scopes
|
||||
func GetSources(u *users.User) []string {
|
||||
sources := []string{}
|
||||
|
||||
// preserves order of sources
|
||||
for _, source := range Config.Server.Sources {
|
||||
_, err := GetScopeFromSourcePath(u.Scopes, source.Path)
|
||||
if err != nil {
|
||||
logger.Warningf("could not get scope for source %v: %v", source.Path, err)
|
||||
continue
|
||||
}
|
||||
sources = append(sources, source.Name)
|
||||
}
|
||||
return sources
|
||||
return absolutePath, nil
|
||||
}
|
||||
|
||||
func loadCustomFavicon() {
|
||||
const imageName = "favicon"
|
||||
const maxSize = 1024 * 1024 // 1MB
|
||||
allowedFormats := []string{".ico", ".png", ".svg"}
|
||||
|
||||
// Set default embedded favicon path
|
||||
Env.FaviconEmbeddedPath = "img/icons/favicon.ico"
|
||||
|
||||
// Check if a custom favicon path is configured
|
||||
if Config.Frontend.Favicon == "" {
|
||||
logger.Debug("No custom favicon configured, using default")
|
||||
Env.FaviconPath = Env.FaviconEmbeddedPath
|
||||
Env.FaviconIsCustom = false
|
||||
return
|
||||
}
|
||||
|
||||
// Get absolute path for the favicon
|
||||
faviconPath, err := filepath.Abs(Config.Frontend.Favicon)
|
||||
// Validate custom favicon
|
||||
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, maxSize, allowedFormats)
|
||||
if err != nil {
|
||||
logger.Warningf("Could not resolve favicon path '%v': %v", Config.Frontend.Favicon, err)
|
||||
Config.Frontend.Favicon = "" // Unset invalid path
|
||||
logger.Warningf("Custom favicon validation failed: %v, using default", err)
|
||||
Config.Frontend.Favicon = ""
|
||||
Env.FaviconPath = Env.FaviconEmbeddedPath
|
||||
Env.FaviconIsCustom = false
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the favicon file exists and get info
|
||||
stat, err := os.Stat(faviconPath)
|
||||
// Update to validated path and mark as custom
|
||||
Config.Frontend.Favicon = validatedPath
|
||||
Env.FaviconPath = validatedPath
|
||||
Env.FaviconIsCustom = true
|
||||
logger.Infof("Using custom favicon: %s", Env.FaviconPath)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Check if a custom login icon path is configured
|
||||
if Config.Frontend.LoginIcon == "" {
|
||||
Env.LoginIconPath = Env.LoginIconEmbeddedPath
|
||||
Env.LoginIconIsCustom = false
|
||||
return
|
||||
}
|
||||
|
||||
// Validate custom login icon
|
||||
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, maxSize, allowedFormats)
|
||||
if err != nil {
|
||||
logger.Warningf("Could not access custom favicon file '%v': %v", faviconPath, err)
|
||||
Config.Frontend.Favicon = "" // Unset invalid path
|
||||
logger.Warningf("Custom login icon validation failed: %v, using default", err)
|
||||
Env.LoginIconPath = Env.LoginIconEmbeddedPath
|
||||
Env.LoginIconIsCustom = false
|
||||
return
|
||||
}
|
||||
|
||||
// Check file size (limit to 1MB for security)
|
||||
const maxFaviconSize = 1024 * 1024 // 1MB
|
||||
if stat.Size() > maxFaviconSize {
|
||||
logger.Warningf("Favicon file '%v' is too large (%d bytes), maximum allowed is %d bytes", faviconPath, stat.Size(), maxFaviconSize)
|
||||
Config.Frontend.Favicon = "" // Unset invalid path
|
||||
return
|
||||
}
|
||||
|
||||
// Validate file format based on extension
|
||||
ext := strings.ToLower(filepath.Ext(faviconPath))
|
||||
switch ext {
|
||||
case ".ico", ".png", ".svg":
|
||||
// Valid favicon formats
|
||||
default:
|
||||
logger.Warningf("Unsupported favicon format '%v', supported formats: .ico, .png, .svg", ext)
|
||||
Config.Frontend.Favicon = "" // Unset invalid path
|
||||
return
|
||||
}
|
||||
|
||||
// Update to absolute path and mark as valid
|
||||
Config.Frontend.Favicon = faviconPath
|
||||
|
||||
logger.Infof("Successfully validated custom favicon at '%v' (%d bytes, %s)", faviconPath, stat.Size(), ext)
|
||||
// Update to validated path and mark as custom
|
||||
Env.LoginIconPath = validatedPath
|
||||
Env.LoginIconIsCustom = true
|
||||
logger.Infof("Using custom login icon: %s", Env.LoginIconPath)
|
||||
}
|
||||
|
||||
// setConditionalsMap builds optimized map structures from conditional rules for O(1) lookups
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package settings
|
||||
|
||||
func CanConvertImage(ext string) bool {
|
||||
return Config.Integrations.Media.FfmpegPath != "" && Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
|
||||
return MediaEnabled() && Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
|
||||
}
|
||||
|
||||
func CanConvertVideo(ext string) bool {
|
||||
return Config.Integrations.Media.FfmpegPath != "" && Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
|
||||
return MediaEnabled() && Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
|
||||
}
|
||||
|
||||
func MediaEnabled() bool {
|
||||
return Env.FFmpegPath != "" && Env.FFprobePath != ""
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, error) {
|
||||
if len(scopes) == 0 {
|
||||
return Config.UserDefaults.DefaultScopes, nil
|
||||
}
|
||||
newScopes := []users.SourceScope{}
|
||||
for _, scope := range scopes {
|
||||
|
||||
// first check if its already a path name and keep it
|
||||
source, ok := Config.Server.SourceMap[scope.Name]
|
||||
if ok {
|
||||
if scope.Scope == "" {
|
||||
scope.Scope = source.Config.DefaultUserScope
|
||||
}
|
||||
if !strings.HasPrefix(scope.Scope, "/") {
|
||||
scope.Scope = "/" + scope.Scope
|
||||
}
|
||||
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
|
||||
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
|
||||
}
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: source.Path, // backend name is path
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// check if its the name of a source and convert it to a path
|
||||
source, ok = Config.Server.NameToSource[scope.Name]
|
||||
if !ok {
|
||||
// source might no longer be configured
|
||||
continue
|
||||
}
|
||||
if scope.Scope == "" {
|
||||
scope.Scope = source.Config.DefaultUserScope
|
||||
}
|
||||
if !strings.HasPrefix(scope.Scope, "/") {
|
||||
scope.Scope = "/" + scope.Scope
|
||||
}
|
||||
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
|
||||
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
|
||||
}
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: source.Path, // backend name is path
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
}
|
||||
return newScopes, nil
|
||||
}
|
||||
|
||||
func ConvertToFrontendScopes(scopes []users.SourceScope) []users.SourceScope {
|
||||
newScopes := []users.SourceScope{}
|
||||
for _, scope := range scopes {
|
||||
if source, ok := Config.Server.SourceMap[scope.Name]; ok {
|
||||
// Replace scope.Name with source.Path while keeping the same Scope value
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: source.Name,
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
}
|
||||
}
|
||||
return newScopes
|
||||
}
|
||||
|
||||
func ConvertToFrontendSidebarLinks(links []users.SidebarLink) []users.SidebarLink {
|
||||
newLinks := []users.SidebarLink{}
|
||||
for _, link := range links {
|
||||
// For source links, validate that the source still exists
|
||||
if link.Category == "source" {
|
||||
if link.SourceName == "" {
|
||||
logger.Warningf("source link missing sourceName: %v", link.Name)
|
||||
continue
|
||||
}
|
||||
// Check if source exists
|
||||
sourceInfo, ok := Config.Server.SourceMap[link.SourceName]
|
||||
if !ok {
|
||||
logger.Warningf("source not found: %v (link name: %v)", link.SourceName, link.Name)
|
||||
continue
|
||||
}
|
||||
link.SourceName = sourceInfo.Name
|
||||
}
|
||||
// For share links, just pass through (shares are validated separately)
|
||||
// For all other links, pass through as-is
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
return newLinks
|
||||
}
|
||||
|
||||
func ConvertToBackendSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, error) {
|
||||
newLinks := []users.SidebarLink{}
|
||||
for _, link := range links {
|
||||
// For source links, validate that the source exists using SourceName
|
||||
if link.Category == "source" {
|
||||
if link.SourceName == "" {
|
||||
return nil, fmt.Errorf("source link missing sourceName (link name: %v)", link.Name)
|
||||
}
|
||||
// Validate source exists
|
||||
sourceInfo, ok := Config.Server.NameToSource[link.SourceName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("source not found: %v (link name: %v)", link.SourceName, link.Name)
|
||||
}
|
||||
link.SourceName = sourceInfo.Path // if name chages keep link alive
|
||||
}
|
||||
// Store the link as-is with all fields preserved
|
||||
newLinks = append(newLinks, link)
|
||||
}
|
||||
return newLinks, nil
|
||||
}
|
||||
|
||||
func HasSourceByPath(scopes []users.SourceScope, sourcePath string) bool {
|
||||
for _, scope := range scopes {
|
||||
if scope.Name == sourcePath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GetScopeFromSourceName(scopes []users.SourceScope, sourceName string) (string, error) {
|
||||
source, ok := Config.Server.NameToSource[sourceName]
|
||||
if !ok {
|
||||
logger.Debug("Could not get scope from source name: ", sourceName)
|
||||
return "", fmt.Errorf("source with name not found %v", sourceName)
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
if scope.Name == source.Path {
|
||||
return scope.Scope, nil
|
||||
}
|
||||
}
|
||||
logger.Debugf("scope not found for source %v", sourceName)
|
||||
return "", fmt.Errorf("scope not found for source %v", sourceName)
|
||||
}
|
||||
|
||||
func GetScopeFromSourcePath(scopes []users.SourceScope, sourcePath string) (string, error) {
|
||||
for _, scope := range scopes {
|
||||
if scope.Name == sourcePath {
|
||||
return scope.Scope, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("scope not found for source %v", sourcePath)
|
||||
}
|
||||
|
||||
// assumes backend style scopes
|
||||
func GetSources(u *users.User) []string {
|
||||
sources := []string{}
|
||||
|
||||
// preserves order of sources
|
||||
for _, source := range Config.Server.Sources {
|
||||
_, err := GetScopeFromSourcePath(u.Scopes, source.Path)
|
||||
if err != nil {
|
||||
logger.Warningf("could not get scope for source %v: %v", source.Path, err)
|
||||
continue
|
||||
}
|
||||
sources = append(sources, source.Name)
|
||||
}
|
||||
return sources
|
||||
}
|
||||
@@ -727,7 +727,7 @@ func GenerateYaml() {
|
||||
|
||||
setupSources(true)
|
||||
setupUrls()
|
||||
setupVideoPreview()
|
||||
setupMedia()
|
||||
setupFrontend(true)
|
||||
|
||||
// Restore original paths so the YAML output has the correct paths, not the placeholder
|
||||
|
||||
@@ -9,10 +9,11 @@ import (
|
||||
func TestGenerateConfigYaml_Basic(t *testing.T) {
|
||||
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
|
||||
// Test using the actual source directory structure
|
||||
trueVal := true
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
Locale: "es", // Non-default value
|
||||
DarkMode: true, // Default value
|
||||
DarkMode: &trueVal, // Default value
|
||||
DisableOfficePreviewExt: ".docx .xlsx", // This field is deprecated
|
||||
},
|
||||
Auth: Auth{
|
||||
@@ -104,6 +105,11 @@ func TestGenerateConfigYaml_Basic(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's null (should NOT be quoted - it's a YAML literal for nil)
|
||||
if value == "null" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Everything else should be a quoted string
|
||||
if !strings.HasPrefix(value, "\"") || !strings.HasSuffix(value, "\"") {
|
||||
// Add debug info to see what's wrong
|
||||
|
||||
@@ -108,6 +108,11 @@ type Auth struct {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's null (should NOT be quoted - it's a YAML literal for nil)
|
||||
if value == "null" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Everything else should be a quoted string
|
||||
if !strings.HasPrefix(value, "\"") || !strings.HasSuffix(value, "\"") {
|
||||
t.Errorf("String value should be quoted: %s (value: '%s')", line, value)
|
||||
@@ -204,10 +209,11 @@ type UserDefaults struct {
|
||||
sourcePath := tmpFile[:strings.LastIndex(tmpFile, "/")]
|
||||
|
||||
// Create a config with both default and non-default values
|
||||
trueVal := true
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
DarkMode: true, // This matches default
|
||||
Locale: "es", // This differs from default ("en")
|
||||
DarkMode: &trueVal, // This matches default
|
||||
Locale: "es", // This differs from default ("en")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -278,10 +284,11 @@ func TestGenerateConfigYaml_CommentsOnOff(t *testing.T) {
|
||||
func TestGenerateConfigYaml_IntegrationTest(t *testing.T) {
|
||||
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
|
||||
// Comprehensive test with all features
|
||||
trueVal := true
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
Locale: "es", // Non-default
|
||||
DarkMode: true, // Default
|
||||
DarkMode: &trueVal, // Default
|
||||
DisableOfficePreviewExt: ".docx .xlsx", // This is deprecated
|
||||
},
|
||||
Auth: Auth{
|
||||
@@ -400,8 +407,8 @@ func TestGenerateConfigYaml_IntegrationTest(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip arrays, objects, and empty values
|
||||
if value == "" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") || strings.Contains(value, ":") {
|
||||
// Skip arrays, objects, empty values, and null
|
||||
if value == "" || value == "null" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") || strings.Contains(value, ":") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -433,8 +440,8 @@ func TestGenerateConfigYaml_EdgeCases(t *testing.T) {
|
||||
name: "only_defaults",
|
||||
config: &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
DarkMode: true, // default value
|
||||
Locale: "en", // default value
|
||||
DarkMode: boolPtr(true), // default value
|
||||
Locale: "en", // default value
|
||||
},
|
||||
},
|
||||
desc: "Config with only default values",
|
||||
|
||||
@@ -214,7 +214,7 @@ frontend:
|
||||
}
|
||||
|
||||
// Verify user defaults
|
||||
if Config.UserDefaults.DarkMode != true {
|
||||
if Config.UserDefaults.DarkMode == nil || !*Config.UserDefaults.DarkMode {
|
||||
t.Errorf("Expected dark mode true, got %v", Config.UserDefaults.DarkMode)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,33 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// boolPtr returns a pointer to a bool value
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
// boolValueOrDefault returns the value of a bool pointer, or the default if nil
|
||||
func boolValueOrDefault(ptr *bool, defaultValue bool) bool {
|
||||
if ptr != nil {
|
||||
return *ptr
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// ConvertPermissionsToUsers converts UserDefaultsPermissions to users.Permissions
|
||||
func ConvertPermissionsToUsers(p UserDefaultsPermissions) users.Permissions {
|
||||
return users.Permissions{
|
||||
Api: p.Api,
|
||||
Admin: p.Admin,
|
||||
Modify: p.Modify,
|
||||
Share: p.Share,
|
||||
Realtime: p.Realtime,
|
||||
Delete: p.Delete,
|
||||
Create: p.Create,
|
||||
Download: boolValueOrDefault(p.Download, true),
|
||||
}
|
||||
}
|
||||
|
||||
const DefaultUsersHomeBasePath = "/users"
|
||||
|
||||
// AuthMethod describes an authentication method.
|
||||
@@ -41,12 +68,36 @@ func AdminPerms() users.Permissions {
|
||||
func ApplyUserDefaults(u *users.User) {
|
||||
u.StickySidebar = Config.UserDefaults.StickySidebar
|
||||
u.DisableSettings = Config.UserDefaults.DisableSettings
|
||||
u.DarkMode = Config.UserDefaults.DarkMode
|
||||
|
||||
// Handle DarkMode with default - dereference pointer from config
|
||||
u.DarkMode = boolValueOrDefault(Config.UserDefaults.DarkMode, true)
|
||||
|
||||
u.Locale = Config.UserDefaults.Locale
|
||||
u.ViewMode = Config.UserDefaults.ViewMode
|
||||
u.SingleClick = Config.UserDefaults.SingleClick
|
||||
u.Permissions = Config.UserDefaults.Permissions
|
||||
u.Preview = Config.UserDefaults.Preview
|
||||
|
||||
// Handle Permissions - convert from pointer-based defaults to regular bools
|
||||
u.Permissions.Api = Config.UserDefaults.Permissions.Api
|
||||
u.Permissions.Admin = Config.UserDefaults.Permissions.Admin
|
||||
u.Permissions.Modify = Config.UserDefaults.Permissions.Modify
|
||||
u.Permissions.Share = Config.UserDefaults.Permissions.Share
|
||||
u.Permissions.Realtime = Config.UserDefaults.Permissions.Realtime
|
||||
u.Permissions.Delete = Config.UserDefaults.Permissions.Delete
|
||||
u.Permissions.Create = Config.UserDefaults.Permissions.Create
|
||||
u.Permissions.Download = boolValueOrDefault(Config.UserDefaults.Permissions.Download, true)
|
||||
|
||||
// Handle Preview - convert from pointer-based defaults to regular bools
|
||||
u.Preview.DisableHideSidebar = Config.UserDefaults.Preview.DisableHideSidebar
|
||||
u.Preview.HighQuality = boolValueOrDefault(Config.UserDefaults.Preview.HighQuality, true)
|
||||
u.Preview.Image = boolValueOrDefault(Config.UserDefaults.Preview.Image, true)
|
||||
u.Preview.Video = boolValueOrDefault(Config.UserDefaults.Preview.Video, true)
|
||||
u.Preview.MotionVideoPreview = boolValueOrDefault(Config.UserDefaults.Preview.MotionVideoPreview, true)
|
||||
u.Preview.Office = boolValueOrDefault(Config.UserDefaults.Preview.Office, true)
|
||||
u.Preview.PopUp = boolValueOrDefault(Config.UserDefaults.Preview.PopUp, true)
|
||||
u.Preview.AutoplayMedia = boolValueOrDefault(Config.UserDefaults.Preview.AutoplayMedia, true)
|
||||
u.Preview.DefaultMediaPlayer = Config.UserDefaults.Preview.DefaultMediaPlayer
|
||||
u.Preview.Folder = boolValueOrDefault(Config.UserDefaults.Preview.Folder, true)
|
||||
|
||||
u.ShowHidden = Config.UserDefaults.ShowHidden
|
||||
u.DateFormat = Config.UserDefaults.DateFormat
|
||||
u.DisableViewingExt = Config.UserDefaults.DisableViewingExt
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
type AllowedMethods string
|
||||
|
||||
var Env Environment
|
||||
|
||||
const (
|
||||
ProxyAuth AllowedMethods = "proxyAuth"
|
||||
NoAuth AllowedMethods = "noAuth"
|
||||
@@ -13,7 +15,6 @@ const (
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
Env Env `json:"-"`
|
||||
Server Server `json:"server"`
|
||||
Auth Auth `json:"auth"`
|
||||
Frontend Frontend `json:"frontend"`
|
||||
@@ -21,12 +22,20 @@ type Settings struct {
|
||||
Integrations Integrations `json:"integrations"`
|
||||
}
|
||||
|
||||
type Env struct {
|
||||
IsPlaywright bool `json:"-"`
|
||||
IsDevMode bool `json:"-"`
|
||||
IsFirstLoad bool `json:"-"` // used internally to track if this is the first load of the application
|
||||
MuPdfAvailable bool `json:"-"` // used internally if compiled with mupdf support
|
||||
EmbeddedFs bool `json:"-"` // used internally if compiled with embedded fs support
|
||||
type Environment struct {
|
||||
IsPlaywright bool `json:"-"`
|
||||
IsDevMode bool `json:"-"`
|
||||
IsFirstLoad bool `json:"-"` // used internally to track if this is the first load of the application
|
||||
MuPdfAvailable bool `json:"-"` // used internally if compiled with mupdf support
|
||||
EmbeddedFs bool `json:"-"` // used internally if compiled with embedded fs support
|
||||
FFmpegPath string `json:"-"`
|
||||
FFprobePath string `json:"-"`
|
||||
LoginIconPath string `json:"-"` // resolved login icon path (filesystem or embedded)
|
||||
LoginIconIsCustom bool `json:"-"` // true if login icon is from custom filesystem path
|
||||
LoginIconEmbeddedPath string `json:"-"` // embedded asset path for default icon
|
||||
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
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -40,15 +49,17 @@ type Server struct {
|
||||
DisableResize bool `json:"disablePreviewResize"` // disable resizing of previews for faster loading over slow connections
|
||||
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
|
||||
Port int `json:"port"` // port to listen on
|
||||
ListenAddress string `json:"listen"` // address to listen on (default: 0.0.0.0)
|
||||
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
|
||||
Logging []LogConfig `json:"logging" yaml:"logging"`
|
||||
Database string `json:"database"` // path to the database file
|
||||
Sources []*Source `json:"sources" validate:"required,dive"`
|
||||
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
|
||||
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
|
||||
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
|
||||
Filesystem Filesystem `json:"filesystem"` // filesystem settings
|
||||
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
|
||||
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
|
||||
CacheDirCleanup *bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory (default: true)
|
||||
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
|
||||
Filesystem Filesystem `json:"filesystem"` // filesystem settings
|
||||
// not exposed to config
|
||||
SourceMap map[string]*Source `json:"-" validate:"omitempty"` // uses realpath as key
|
||||
NameToSource map[string]*Source `json:"-" validate:"omitempty"` // uses name as key
|
||||
@@ -170,12 +181,12 @@ type SourceConfig struct {
|
||||
DenyByDefault bool `json:"denyByDefault,omitempty"` // deny access unless an "allow" access rule was specifically created.
|
||||
Private bool `json:"private"` // designate as source as private -- currently just means no sharing permitted.
|
||||
Disabled bool `json:"disabled,omitempty"` // disable the source, this is useful so you don't need to remove it from the config file
|
||||
IndexingInterval uint32 `json:"indexingIntervalMinutes,omitempty"` // optional manual overide interval in minutes to re-index the source
|
||||
DisableIndexing bool `json:"disableIndexing,omitempty"` // disable the indexing of this source
|
||||
IndexingInterval uint32 `json:"indexingIntervalMinutes,omitempty"` // (optional) not recommended: manual overide interval in minutes to re-index the source
|
||||
DisableIndexing bool `json:"disableIndexing,omitempty"` // (optional) not recommended: disable the indexing of this source
|
||||
Conditionals ConditionalFilter `json:"conditionals"` // conditional rules to apply when indexing to include/exclude certain items
|
||||
DefaultUserScope string `json:"defaultUserScope"` // default "/" should match folders under path
|
||||
DefaultUserScope string `json:"defaultUserScope"` // defaults to root of index "/" should match folders under path
|
||||
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
|
||||
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user
|
||||
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user under defaultUserScope + username
|
||||
// hidden but used internally - optimized map lookups for conditional rules
|
||||
ResolvedConditionals *ResolvedConditionalsConfig `json:"-"`
|
||||
}
|
||||
@@ -261,38 +272,64 @@ type ExternalLink struct {
|
||||
Url string `json:"url" validate:"required"` // the url to link to
|
||||
}
|
||||
|
||||
// UserDefaultsPreview holds preview settings with pointer types for defaults
|
||||
type UserDefaultsPreview struct {
|
||||
DisableHideSidebar bool `json:"disableHideSidebar"` // keep sidebar open when previewing files
|
||||
HighQuality *bool `json:"highQuality"` // use high quality thumbnails
|
||||
Image *bool `json:"image"` // show thumbnails for image files
|
||||
Video *bool `json:"video"` // show thumbnails for video files
|
||||
MotionVideoPreview *bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
|
||||
Office *bool `json:"office"` // show thumbnails for office files
|
||||
PopUp *bool `json:"popup"` // show larger popup preview when hovering over thumbnail
|
||||
AutoplayMedia *bool `json:"autoplayMedia"` // autoplay media files in preview
|
||||
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable the styled feature-rich media player for browser default
|
||||
Folder *bool `json:"folder"` // show thumbnails for folders that have previewable contents
|
||||
}
|
||||
|
||||
// UserDefaultsPermissions holds permission settings with pointer types for defaults
|
||||
type UserDefaultsPermissions struct {
|
||||
Api bool `json:"api"` // allow api access
|
||||
Admin bool `json:"admin"` // allow admin access
|
||||
Modify bool `json:"modify"` // allow modifying files
|
||||
Share bool `json:"share"` // allow sharing files
|
||||
Realtime bool `json:"realtime"` // allow realtime updates
|
||||
Delete bool `json:"delete"` // allow deleting files
|
||||
Create bool `json:"create"` // allow creating or uploading files
|
||||
Download *bool `json:"download"` // allow downloading files
|
||||
}
|
||||
|
||||
// UserDefaults is a type that holds the default values
|
||||
// for some fields on User.
|
||||
type UserDefaults struct {
|
||||
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
|
||||
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
|
||||
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
|
||||
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
|
||||
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
|
||||
DarkMode bool `json:"darkMode"` // should dark mode be enabled
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
|
||||
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
|
||||
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
|
||||
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
|
||||
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
|
||||
DisableSettings bool `json:"disableSettings,omitempty"` // disable the user from viewing the settings page
|
||||
Preview users.Preview `json:"preview"`
|
||||
DefaultScopes []users.SourceScope `json:"-"`
|
||||
Permissions users.Permissions `json:"permissions"`
|
||||
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
FileLoading users.FileLoading `json:"fileLoading"` // upload and download settings
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated: use disablePreviewExt instead
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor for
|
||||
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"` // default landing page to use if no redirect is specified: eg. /files/mysource/mysubpath, /settings, etc.
|
||||
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
|
||||
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
|
||||
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
|
||||
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
|
||||
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
|
||||
DarkMode *bool `json:"darkMode"` // should dark mode be enabled
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
|
||||
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
|
||||
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
|
||||
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
|
||||
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
|
||||
DisableSettings bool `json:"disableSettings,omitempty"` // disable the user from viewing the settings page
|
||||
Preview UserDefaultsPreview `json:"preview"`
|
||||
DefaultScopes []users.SourceScope `json:"-"`
|
||||
Permissions UserDefaultsPermissions `json:"permissions"`
|
||||
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
FileLoading users.FileLoading `json:"fileLoading"` // upload and download settings
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated: use disablePreviewExt instead
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor for
|
||||
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
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"golang.org/x/mod/semver"
|
||||
@@ -31,7 +31,8 @@ func CheckForUpdates() (updateInfo, error) {
|
||||
repoOwner := "gtsteffaniak"
|
||||
repoName := "filebrowser"
|
||||
currentVersion := version.Version
|
||||
if currentVersion == "untracked" || currentVersion == "testing" || currentVersion == "" || settings.Config.Env.IsDevMode {
|
||||
isDevMode := os.Getenv("FILEBROWSER_DEVMODE") == "true"
|
||||
if currentVersion == "untracked" || currentVersion == "testing" || currentVersion == "" || isDevMode {
|
||||
return updateInfo{}, nil
|
||||
}
|
||||
splitVersion := strings.Split(currentVersion, "-")
|
||||
|
||||
@@ -54,4 +54,5 @@ type FileOptions struct {
|
||||
Recursive bool // whether to recursively index directories
|
||||
Metadata bool // whether to get metadata
|
||||
ExtractEmbeddedSubtitles bool // whether to extract embedded subtitles from media files
|
||||
AlbumArt bool // whether to get album art from media files
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
math "math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
@@ -128,3 +129,23 @@ func Ternary[T any](cond bool, vtrue, vfalse T) T {
|
||||
}
|
||||
return vfalse
|
||||
}
|
||||
|
||||
// NormalizeRulePath ensures directory paths have trailing slashes for consistent rule storage
|
||||
func AddTrailingSlashIfNotExists(indexPath string) string {
|
||||
// Root path stays as "/"
|
||||
if indexPath == "/" {
|
||||
return "/"
|
||||
}
|
||||
// For all other paths, ensure they have trailing slashes
|
||||
if !strings.HasSuffix(indexPath, "/") {
|
||||
return indexPath + "/"
|
||||
}
|
||||
return indexPath
|
||||
}
|
||||
|
||||
func CheckPathExists(realPath string) bool {
|
||||
if _, err := os.Stat(realPath); os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
+15
-14
@@ -11,7 +11,7 @@ userDefaults:
|
||||
disableQuickToggles: false # disable the quick toggles in the sidebar
|
||||
disableSearchOptions: false # disable the search options in the search bar
|
||||
stickySidebar: true # keep sidebar open when navigating
|
||||
darkMode: true # should dark mode be enabled
|
||||
darkMode: true # should dark mode be enabled (defaults to true if not set)
|
||||
viewMode: "normal" # view mode to use: eg. normal, list, grid, or compact
|
||||
singleClick: false # open directory on single click, also enables middle click to open in new tab
|
||||
showHidden: false # show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
@@ -24,16 +24,16 @@ userDefaults:
|
||||
lockPassword: false # disable the user from changing their password
|
||||
disableSettings: false # disable the user from viewing the settings page
|
||||
preview:
|
||||
disableHideSidebar: true # disable the hide sidebar preview for previews and editors
|
||||
highQuality: true # generate high quality thumbnail preview images
|
||||
image: true # show thumbnail preview image for image files
|
||||
video: true # show thumbnail preview image for video files
|
||||
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering
|
||||
office: true # show thumbnail preview image for office files
|
||||
popup: true # show larger popup preview when hovering over thumbnail
|
||||
autoplayMedia: true # autoplay media files in preview
|
||||
defaultMediaPlayer: true # disable html5 media player and use the default media player
|
||||
folder: true # show thumbnail preview image for folder files
|
||||
disableHideSidebar: false # disable the hide sidebar preview for previews and editors
|
||||
highQuality: true # generate high quality thumbnail preview images (defaults to true if not set)
|
||||
image: true # show thumbnail preview image for image files (defaults to true if not set)
|
||||
video: true # show thumbnail preview image for video files (defaults to true if not set)
|
||||
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering (defaults to true if not set)
|
||||
office: true # show thumbnail preview image for office files (defaults to true if not set)
|
||||
popup: true # show larger popup preview when hovering over thumbnail (defaults to true if not set)
|
||||
autoplayMedia: true # autoplay media files in preview (defaults to true if not set)
|
||||
defaultMediaPlayer: false # disable html5 media player and use the default media player
|
||||
folder: true # show thumbnail preview image for folder files (defaults to true if not set)
|
||||
permissions:
|
||||
api: false # allow api access
|
||||
admin: false # allow admin access
|
||||
@@ -42,9 +42,10 @@ userDefaults:
|
||||
realtime: false # allow realtime updates
|
||||
delete: false # allow deleting files
|
||||
create: false # allow creating or uploading files
|
||||
download: true # allow downloading files
|
||||
download: true # allow downloading files (defaults to true if not set)
|
||||
disableUpdateNotifications: false # disable update notifications banner for admin users
|
||||
deleteWithoutConfirming: false # delete files without confirmation
|
||||
disableOnlyOfficeExt: ".md .txt .pdf" # list of file extensions to disable onlyoffice editor for
|
||||
disableOnlyOfficeExt: ".md .txt .pdf .html .xml" # list of file extensions to disable onlyoffice editor for
|
||||
customTheme: "" # Name of theme to use chosen from custom themes config.
|
||||
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.
|
||||
debugOffice: false # debug onlyoffice editor
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -63,6 +64,7 @@ type FrontendAccessRule struct {
|
||||
Deny FrontendRuleSet `json:"deny"`
|
||||
Allow FrontendRuleSet `json:"allow"`
|
||||
SourceDenyDefault bool `json:"sourceDenyDefault"`
|
||||
PathExists bool `json:"pathExists"`
|
||||
}
|
||||
|
||||
// GroupMap maps group names to a set of usernames.
|
||||
@@ -197,24 +199,11 @@ func (s *Storage) RemoveRuleByPath(sourcePath, indexPath string) {
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeRulePath ensures directory paths have trailing slashes for consistent rule storage
|
||||
func normalizeRulePath(indexPath string) string {
|
||||
// Root path stays as "/"
|
||||
if indexPath == "/" {
|
||||
return "/"
|
||||
}
|
||||
// For all other paths, ensure they have trailing slashes
|
||||
if !strings.HasSuffix(indexPath, "/") {
|
||||
return indexPath + "/"
|
||||
}
|
||||
return indexPath
|
||||
}
|
||||
|
||||
// getOrCreateRuleNL ensures a rule exists for the given source and index path.
|
||||
// The caller must hold the lock.
|
||||
func (s *Storage) getOrCreateRuleNL(sourcePath, indexPath string) *AccessRule {
|
||||
// Normalize the path to ensure consistent rule storage
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
if _, ok := s.AllRules[sourcePath]; !ok {
|
||||
s.AllRules[sourcePath] = make(RuleMap)
|
||||
}
|
||||
@@ -412,7 +401,7 @@ func (s *Storage) getRuleAtExactPath(sourcePath, indexPath string) (*AccessRule,
|
||||
return nil, false
|
||||
}
|
||||
// Normalize the path to ensure consistent rule lookup
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rule, ok := rulesBySource[normalizedPath]
|
||||
return rule, ok
|
||||
}
|
||||
@@ -471,8 +460,12 @@ func (s *Storage) GetFrontendRules(sourcePath, indexPath string) (FrontendAccess
|
||||
sourceDenyDefault = sourceInfo.Config.DenyByDefault
|
||||
}
|
||||
|
||||
// Check if path exists on filesystem
|
||||
pathExists := utils.CheckPathExists(filepath.Join(sourcePath, indexPath))
|
||||
|
||||
frontendRules := FrontendAccessRule{
|
||||
SourceDenyDefault: sourceDenyDefault,
|
||||
PathExists: pathExists,
|
||||
Deny: FrontendRuleSet{
|
||||
Users: make([]string, 0),
|
||||
Groups: make([]string, 0),
|
||||
@@ -532,10 +525,14 @@ func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule,
|
||||
// This ensures consistency between internal storage and frontend display
|
||||
frontendPath := indexPath
|
||||
|
||||
// Check if path exists on filesystem
|
||||
pathExists := utils.CheckPathExists(filepath.Join(sourcePath, indexPath))
|
||||
|
||||
// Convert AccessRule to FrontendAccessRule
|
||||
frontendRules[frontendPath] = FrontendAccessRule{
|
||||
DenyAll: rule.DenyAll,
|
||||
SourceDenyDefault: sourceDenyDefault,
|
||||
PathExists: pathExists,
|
||||
Deny: FrontendRuleSet{
|
||||
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
|
||||
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
|
||||
@@ -653,7 +650,7 @@ func (s *Storage) RemoveUserFromGroup(group, username string) error {
|
||||
func (s *Storage) RemoveAllowUser(sourcePath, indexPath, username string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -686,7 +683,7 @@ func (s *Storage) RemoveAllowUser(sourcePath, indexPath, username string) (bool,
|
||||
func (s *Storage) RemoveAllowGroup(sourcePath, indexPath, groupname string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -719,7 +716,7 @@ func (s *Storage) RemoveAllowGroup(sourcePath, indexPath, groupname string) (boo
|
||||
func (s *Storage) RemoveDenyUser(sourcePath, indexPath, username string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -752,7 +749,7 @@ func (s *Storage) RemoveDenyUser(sourcePath, indexPath, username string) (bool,
|
||||
func (s *Storage) RemoveDenyGroup(sourcePath, indexPath, groupname string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -785,7 +782,7 @@ func (s *Storage) RemoveDenyGroup(sourcePath, indexPath, groupname string) (bool
|
||||
func (s *Storage) RemoveDenyAll(sourcePath, indexPath string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -1116,3 +1113,206 @@ func (s *Storage) HasAnyVisibleItems(sourcePath, parentPath string, itemNames []
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// RemoveUserCascade removes a user from either the allow or deny list for a given path and all its subpaths.
|
||||
// This is used for cascade delete operations when deleting user access from a directory tree.
|
||||
// The allow parameter determines which list to remove from: true for allow list, false for deny list.
|
||||
func (s *Storage) RemoveUserCascade(sourcePath, indexPath, username string, allow bool) (int, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
changed := false
|
||||
removedCount := 0
|
||||
|
||||
// Iterate through all rules for this source
|
||||
for rulePath, rule := range rulesBySource {
|
||||
// Check if this rule path matches or is a subpath of the target path
|
||||
if rulePath == normalizedPath || strings.HasPrefix(rulePath, normalizedPath) {
|
||||
if allow {
|
||||
// Remove user from allow list only
|
||||
if _, exists := rule.Allow.Users[username]; exists {
|
||||
delete(rule.Allow.Users, username)
|
||||
changed = true
|
||||
removedCount++
|
||||
}
|
||||
} else {
|
||||
// Remove user from deny list only
|
||||
if _, exists := rule.Deny.Users[username]; exists {
|
||||
delete(rule.Deny.Users, username)
|
||||
changed = true
|
||||
removedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// If rule is now empty, mark it for deletion
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 &&
|
||||
len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 && !rule.DenyAll {
|
||||
delete(s.AllRules[sourcePath], rulePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no rules left for this source, remove the source entry
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
|
||||
if changed {
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
return removedCount, s.SaveToDB()
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// RemoveGroupCascade removes a group from either the allow or deny list for a given path and all its subpaths.
|
||||
// This is used for cascade delete operations when deleting group access from a directory tree.
|
||||
// The allow parameter determines which list to remove from: true for allow list, false for deny list.
|
||||
func (s *Storage) RemoveGroupCascade(sourcePath, indexPath, groupname string, allow bool) (int, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
changed := false
|
||||
removedCount := 0
|
||||
|
||||
// Iterate through all rules for this source
|
||||
for rulePath, rule := range rulesBySource {
|
||||
// Check if this rule path matches or is a subpath of the target path
|
||||
if rulePath == normalizedPath || strings.HasPrefix(rulePath, normalizedPath) {
|
||||
if allow {
|
||||
// Remove group from allow list only
|
||||
if _, exists := rule.Allow.Groups[groupname]; exists {
|
||||
delete(rule.Allow.Groups, groupname)
|
||||
changed = true
|
||||
removedCount++
|
||||
}
|
||||
} else {
|
||||
// Remove group from deny list only
|
||||
if _, exists := rule.Deny.Groups[groupname]; exists {
|
||||
delete(rule.Deny.Groups, groupname)
|
||||
changed = true
|
||||
removedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// If rule is now empty, mark it for deletion
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 &&
|
||||
len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 && !rule.DenyAll {
|
||||
delete(s.AllRules[sourcePath], rulePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no rules left for this source, remove the source entry
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
|
||||
if changed {
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
return removedCount, s.SaveToDB()
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// UpdateRules updates all access rules that match oldPath to point to newPath.
|
||||
// Handles both exact matches and subdirectories. Similar to share.Storage.UpdateShares.
|
||||
func (s *Storage) UpdateRules(sourcePath, oldPath, newPath string) (int, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
return 0, nil // No rules for this source, not an error
|
||||
}
|
||||
|
||||
// Normalize paths (always add trailing slash for access rules)
|
||||
oldPath = utils.AddTrailingSlashIfNotExists(oldPath)
|
||||
newPath = utils.AddTrailingSlashIfNotExists(newPath)
|
||||
|
||||
updated := 0
|
||||
rulesToUpdate := make(map[string]string) // old path -> new path
|
||||
|
||||
// Find all rules that need to be updated
|
||||
for rulePath := range rulesBySource {
|
||||
if rulePath == oldPath {
|
||||
// Exact match
|
||||
rulesToUpdate[rulePath] = newPath
|
||||
} else if strings.HasPrefix(rulePath, oldPath) {
|
||||
// Subdirectory - replace prefix
|
||||
newRulePath := newPath + strings.TrimPrefix(rulePath, oldPath)
|
||||
rulesToUpdate[rulePath] = newRulePath
|
||||
}
|
||||
}
|
||||
|
||||
// Update all matched rules
|
||||
for oldRulePath, newRulePath := range rulesToUpdate {
|
||||
rule := rulesBySource[oldRulePath]
|
||||
delete(rulesBySource, oldRulePath)
|
||||
rulesBySource[newRulePath] = rule
|
||||
logger.Info("access rule updated", "source", sourcePath, "fromPath", oldRulePath, "toPath", newRulePath)
|
||||
updated++
|
||||
}
|
||||
|
||||
if updated > 0 {
|
||||
// Invalidate caches
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
|
||||
if err := s.SaveToDB(); err != nil {
|
||||
return updated, err
|
||||
}
|
||||
}
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// UpdateRulePath updates the path for a specific access rule (used by PATCH API endpoint).
|
||||
func (s *Storage) UpdateRulePath(sourcePath, oldPath, newPath string) error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
// Normalize paths
|
||||
oldPath = utils.AddTrailingSlashIfNotExists(oldPath)
|
||||
newPath = utils.AddTrailingSlashIfNotExists(newPath)
|
||||
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
return fmt.Errorf("no rules found for source: %s", sourcePath)
|
||||
}
|
||||
|
||||
rule, ok := rulesBySource[oldPath]
|
||||
if !ok {
|
||||
return fmt.Errorf("no rule found for path: %s", oldPath)
|
||||
}
|
||||
|
||||
// Remove the old rule and add it with the new path
|
||||
delete(rulesBySource, oldPath)
|
||||
rulesBySource[newPath] = rule
|
||||
|
||||
// Invalidate caches
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
|
||||
logger.Debugf("access rule path updated: source=%s, fromPath=%s, toPath=%s", sourcePath, oldPath, newPath)
|
||||
return s.SaveToDB()
|
||||
}
|
||||
@@ -1203,3 +1203,513 @@ func TestHasAnyVisibleItems(t *testing.T) {
|
||||
|
||||
t.Log("✓ HasAnyVisibleItems correctly checks access permissions for items")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_OnlyRemovesSpecificList tests that cascade delete only removes from the specified list
|
||||
func TestRemoveUserCascade_OnlyRemovesSpecificList(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "alice")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Set up rules: alice has both allow and deny rules on different paths
|
||||
if err = s.AllowUser("mnt/storage", "/docs/", "alice"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/docs/public/", "alice"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.DenyUser("mnt/storage", "/docs/", "alice"); err != nil {
|
||||
t.Fatalf("DenyUser failed: %v", err)
|
||||
}
|
||||
if err = s.DenyUser("mnt/storage", "/docs/private/", "alice"); err != nil {
|
||||
t.Fatalf("DenyUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete only allow rules
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/docs/", "alice", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 allow rules removed, got %d", count)
|
||||
}
|
||||
|
||||
// Verify allow rules are gone
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/docs/")
|
||||
if ok && len(rule.Allow.Users) != 0 {
|
||||
t.Error("Allow rule should be removed from /docs/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/docs/public/")
|
||||
if ok && len(rule.Allow.Users) != 0 {
|
||||
t.Error("Allow rule should be removed from /docs/public/")
|
||||
}
|
||||
|
||||
// Verify deny rules are still present
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/docs/")
|
||||
if !ok || len(rule.Deny.Users) == 0 {
|
||||
t.Error("Deny rule should still exist on /docs/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/docs/private/")
|
||||
if !ok || len(rule.Deny.Users) == 0 {
|
||||
t.Error("Deny rule should still exist on /docs/private/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete correctly removes only allow rules, leaving deny rules intact")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_DenyRules tests cascade delete for deny rules specifically
|
||||
func TestRemoveUserCascade_DenyRules(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "bob")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Set up both allow and deny rules
|
||||
if err = s.AllowUser("mnt/storage", "/projects/", "bob"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/projects/team/", "bob"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.DenyUser("mnt/storage", "/projects/", "bob"); err != nil {
|
||||
t.Fatalf("DenyUser failed: %v", err)
|
||||
}
|
||||
if err = s.DenyUser("mnt/storage", "/projects/secret/", "bob"); err != nil {
|
||||
t.Fatalf("DenyUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete only deny rules
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/projects/", "bob", false)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 deny rules removed, got %d", count)
|
||||
}
|
||||
|
||||
// Verify deny rules are gone
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/projects/")
|
||||
if !ok || len(rule.Deny.Users) != 0 {
|
||||
t.Error("Deny rule should be removed from /projects/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/projects/secret/")
|
||||
if ok && len(rule.Deny.Users) != 0 {
|
||||
t.Error("Deny rule should be removed from /projects/secret/")
|
||||
}
|
||||
|
||||
// Verify allow rules are still present
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/projects/")
|
||||
if !ok || len(rule.Allow.Users) == 0 {
|
||||
t.Error("Allow rule should still exist on /projects/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/projects/team/")
|
||||
if !ok || len(rule.Allow.Users) == 0 {
|
||||
t.Error("Allow rule should still exist on /projects/team/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete correctly removes only deny rules, leaving allow rules intact")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_MultipleSubpaths tests cascade delete across multiple subpath levels
|
||||
func TestRemoveUserCascade_MultipleSubpaths(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "carol")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Set up a deep hierarchy of allow rules
|
||||
paths := []string{
|
||||
"/data/",
|
||||
"/data/2024/",
|
||||
"/data/2024/q1/",
|
||||
"/data/2024/q1/jan/",
|
||||
"/data/2024/q1/feb/",
|
||||
"/data/2024/q2/",
|
||||
"/data/2024/q2/apr/",
|
||||
"/data/2025/",
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
if err = s.AllowUser("mnt/storage", path, "carol"); err != nil {
|
||||
t.Fatalf("AllowUser failed for %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Cascade delete from /data/2024/ should remove all 2024 subpaths
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/data/2024/", "carol", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 6 {
|
||||
t.Errorf("Expected 6 rules removed (2024 + 5 subpaths), got %d", count)
|
||||
}
|
||||
|
||||
// Verify 2024 paths are gone
|
||||
for _, path := range []string{"/data/2024/", "/data/2024/q1/", "/data/2024/q1/jan/", "/data/2024/q1/feb/", "/data/2024/q2/", "/data/2024/q2/apr/"} {
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", path)
|
||||
if ok && len(rule.Allow.Users) > 0 {
|
||||
t.Errorf("Rule should be removed from %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify other paths still exist
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/data/")
|
||||
if !ok || len(rule.Allow.Users) == 0 {
|
||||
t.Error("Rule should still exist on /data/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/data/2025/")
|
||||
if !ok || len(rule.Allow.Users) == 0 {
|
||||
t.Error("Rule should still exist on /data/2025/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete correctly removes rules from all subpath levels")
|
||||
}
|
||||
|
||||
// TestRemoveGroupCascade_OnlyRemovesSpecificList tests cascade delete for groups
|
||||
func TestRemoveGroupCascade_OnlyRemovesSpecificList(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "alice")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Create a group
|
||||
if err = s.AddUserToGroup("editors", "alice"); err != nil {
|
||||
t.Fatalf("AddUserToGroup failed: %v", err)
|
||||
}
|
||||
|
||||
// Set up both allow and deny rules for the group
|
||||
if err = s.AllowGroup("mnt/storage", "/content/", "editors"); err != nil {
|
||||
t.Fatalf("AllowGroup failed: %v", err)
|
||||
}
|
||||
if err = s.AllowGroup("mnt/storage", "/content/articles/", "editors"); err != nil {
|
||||
t.Fatalf("AllowGroup failed: %v", err)
|
||||
}
|
||||
if err = s.DenyGroup("mnt/storage", "/content/", "editors"); err != nil {
|
||||
t.Fatalf("DenyGroup failed: %v", err)
|
||||
}
|
||||
if err = s.DenyGroup("mnt/storage", "/content/drafts/", "editors"); err != nil {
|
||||
t.Fatalf("DenyGroup failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete only allow rules
|
||||
count, err := s.RemoveGroupCascade("mnt/storage", "/content/", "editors", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveGroupCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 allow rules removed, got %d", count)
|
||||
}
|
||||
|
||||
// Verify allow rules are gone
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/content/")
|
||||
if !ok || len(rule.Allow.Groups) != 0 {
|
||||
t.Error("Allow rule should be removed from /content/")
|
||||
}
|
||||
|
||||
// Verify deny rules are still present
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/content/")
|
||||
if !ok || len(rule.Deny.Groups) == 0 {
|
||||
t.Error("Deny rule should still exist on /content/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/content/drafts/")
|
||||
if !ok || len(rule.Deny.Groups) == 0 {
|
||||
t.Error("Deny rule should still exist on /content/drafts/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete for groups correctly removes only allow rules")
|
||||
}
|
||||
|
||||
// TestRemoveGroupCascade_DenyRules tests cascade delete for group deny rules specifically
|
||||
func TestRemoveGroupCascade_DenyRules(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "alice")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Create a group
|
||||
if err = s.AddUserToGroup("contractors", "alice"); err != nil {
|
||||
t.Fatalf("AddUserToGroup failed: %v", err)
|
||||
}
|
||||
|
||||
// Set up both allow and deny rules for the group
|
||||
if err = s.AllowGroup("mnt/storage", "/work/", "contractors"); err != nil {
|
||||
t.Fatalf("AllowGroup failed: %v", err)
|
||||
}
|
||||
if err = s.AllowGroup("mnt/storage", "/work/public/", "contractors"); err != nil {
|
||||
t.Fatalf("AllowGroup failed: %v", err)
|
||||
}
|
||||
if err = s.DenyGroup("mnt/storage", "/work/", "contractors"); err != nil {
|
||||
t.Fatalf("DenyGroup failed: %v", err)
|
||||
}
|
||||
if err = s.DenyGroup("mnt/storage", "/work/confidential/", "contractors"); err != nil {
|
||||
t.Fatalf("DenyGroup failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete only deny rules
|
||||
var count int
|
||||
count, err = s.RemoveGroupCascade("mnt/storage", "/work/", "contractors", false)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveGroupCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 deny rules removed, got %d", count)
|
||||
}
|
||||
|
||||
// Verify deny rules are gone
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/work/")
|
||||
if ok && len(rule.Deny.Groups) != 0 {
|
||||
t.Error("Deny rule should be removed from /work/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/work/confidential/")
|
||||
if ok && len(rule.Deny.Groups) != 0 {
|
||||
t.Error("Deny rule should be removed from /work/confidential/")
|
||||
}
|
||||
|
||||
// Verify allow rules are still present
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/work/")
|
||||
if !ok || len(rule.Allow.Groups) == 0 {
|
||||
t.Error("Allow rule should still exist on /work/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/work/public/")
|
||||
if !ok || len(rule.Allow.Groups) == 0 {
|
||||
t.Error("Allow rule should still exist on /work/public/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete for groups correctly removes only deny rules, leaving allow rules intact")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_EmptyResult tests cascade delete when no rules exist
|
||||
func TestRemoveUserCascade_EmptyResult(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "dave")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Try to cascade delete when no rules exist
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/nonexistent/", "dave", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade should not error on nonexistent rules: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("Expected 0 rules removed, got %d", count)
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete correctly returns 0 when no rules exist")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_ExactPathOnly tests that exact path is included in cascade
|
||||
func TestRemoveUserCascade_ExactPathOnly(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "eve")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Add rule only on exact path, no subpaths
|
||||
if err = s.AllowUser("mnt/storage", "/single/", "eve"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete should remove the exact path rule
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/single/", "eve", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("Expected 1 rule removed, got %d", count)
|
||||
}
|
||||
|
||||
// Verify rule is gone
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/single/")
|
||||
if ok && len(rule.Allow.Users) > 0 {
|
||||
t.Error("Rule should be removed from /single/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete correctly removes the exact path rule")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_DoesNotAffectParentPaths tests that parent paths are not affected
|
||||
func TestRemoveUserCascade_DoesNotAffectParentPaths(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "frank")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Set up rules on parent and child paths
|
||||
if err = s.AllowUser("mnt/storage", "/parent/", "frank"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/parent/child/", "frank"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/parent/child/grandchild/", "frank"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete from child path
|
||||
count, err := s.RemoveUserCascade("mnt/storage", "/parent/child/", "frank", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 rules removed (child + grandchild), got %d", count)
|
||||
}
|
||||
|
||||
// Verify parent rule still exists
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/parent/")
|
||||
if !ok || len(rule.Allow.Users) == 0 {
|
||||
t.Error("Parent rule should still exist")
|
||||
}
|
||||
|
||||
// Verify child rules are gone
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/parent/child/")
|
||||
if ok && len(rule.Allow.Users) > 0 {
|
||||
t.Error("Child rule should be removed")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete does not affect parent paths")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_CleanupEmptyRules tests that empty rules are cleaned up
|
||||
func TestRemoveUserCascade_CleanupEmptyRules(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "grace")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Add only allow rule (no deny rules)
|
||||
if err = s.AllowUser("mnt/storage", "/temp/", "grace"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/temp/files/", "grace"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify rules exist
|
||||
allRules, err := s.GetAllRules("mnt/storage")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllRules failed: %v", err)
|
||||
}
|
||||
if len(allRules) < 2 {
|
||||
t.Error("Expected at least 2 rules to exist")
|
||||
}
|
||||
|
||||
// Cascade delete - should remove rules and cleanup empty rule objects
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/temp/", "grace", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 rules removed, got %d", count)
|
||||
}
|
||||
|
||||
// Verify rules are completely gone (not just empty)
|
||||
allRules, err = s.GetAllRules("mnt/storage")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllRules failed: %v", err)
|
||||
}
|
||||
if _, exists := allRules["/temp/"]; exists {
|
||||
t.Error("Empty rule should be cleaned up from /temp/")
|
||||
}
|
||||
if _, exists := allRules["/temp/files/"]; exists {
|
||||
t.Error("Empty rule should be cleaned up from /temp/files/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete correctly cleans up empty rules")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_MixedUsers tests that cascade delete only affects the specified user
|
||||
func TestRemoveUserCascade_MixedUsers(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "henry")
|
||||
createTestUser(t, userStore, "iris")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Add rules for both users on same paths
|
||||
if err = s.AllowUser("mnt/storage", "/shared/", "henry"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/shared/", "iris"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/shared/docs/", "henry"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/shared/docs/", "iris"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete only henry's rules
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/shared/", "henry", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 rules removed for henry, got %d", count)
|
||||
}
|
||||
|
||||
// Verify henry's rules are gone
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/shared/")
|
||||
if !ok {
|
||||
t.Fatal("Rule should still exist (iris's rule)")
|
||||
}
|
||||
hasHenry := false
|
||||
for _, user := range rule.Allow.Users {
|
||||
if user == "henry" {
|
||||
hasHenry = true
|
||||
}
|
||||
}
|
||||
if hasHenry {
|
||||
t.Error("henry should be removed from allow list")
|
||||
}
|
||||
|
||||
// Verify iris's rules still exist
|
||||
hasIris := false
|
||||
for _, user := range rule.Allow.Users {
|
||||
if user == "iris" {
|
||||
hasIris = true
|
||||
}
|
||||
}
|
||||
if !hasIris {
|
||||
t.Error("iris should still be in allow list")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete only affects the specified user")
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func (s *Storage) CheckChildItemAccess(response *iteminfo.ExtendedFileInfo, inde
|
||||
|
||||
// Filter and return only the items the user has access to
|
||||
response.Folders = make([]iteminfo.ItemInfo, 0)
|
||||
response.Files = make([]iteminfo.ItemInfo, 0)
|
||||
response.Files = make([]iteminfo.ExtendedItemInfo, 0)
|
||||
|
||||
// Check each subfolder for access permissions
|
||||
for _, folder := range originalFolders {
|
||||
|
||||
@@ -1,39 +1,45 @@
|
||||
package share
|
||||
|
||||
import "sync"
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
type CommonShare struct {
|
||||
DownloadsLimit int `json:"downloadsLimit,omitempty"`
|
||||
ShareTheme string `json:"shareTheme,omitempty"`
|
||||
DisableAnonymous bool `json:"disableAnonymous,omitempty"`
|
||||
MaxBandwidth int `json:"maxBandwidth,omitempty"`
|
||||
DisableThumbnails bool `json:"disableThumbnails,omitempty"`
|
||||
KeepAfterExpiration bool `json:"keepAfterExpiration,omitempty"`
|
||||
AllowedUsernames []string `json:"allowedUsernames,omitempty"`
|
||||
ThemeColor string `json:"themeColor,omitempty"`
|
||||
Banner string `json:"banner,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Favicon string `json:"favicon,omitempty"`
|
||||
QuickDownload bool `json:"quickDownload,omitempty"`
|
||||
HideNavButtons bool `json:"hideNavButtons,omitempty"`
|
||||
DisableSidebar bool `json:"disableSidebar"`
|
||||
Source string `json:"source,omitempty"` // backend source is path to maintain between name changes
|
||||
Path string `json:"path,omitempty"`
|
||||
DownloadURL string `json:"downloadURL,omitempty"`
|
||||
DisableShareCard bool `json:"disableShareCard,omitempty"`
|
||||
EnforceDarkLightMode string `json:"enforceDarkLightMode,omitempty"` // "dark" or "light"
|
||||
ViewMode string `json:"viewMode,omitempty"` // default view mode for anonymous users: "list", "compact", "normal", "gallery"
|
||||
EnableOnlyOffice bool `json:"enableOnlyOffice,omitempty"`
|
||||
ShareType string `json:"shareType"` // type of share: normal, upload, max
|
||||
PerUserDownloadLimit bool `json:"perUserDownloadLimit,omitempty"`
|
||||
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles,omitempty"` // can be io intensive for large files and take 10-30 seconds.
|
||||
AllowDelete bool `json:"allowDelete,omitempty"`
|
||||
AllowCreate bool `json:"allowCreate,omitempty"` // allow creating files
|
||||
AllowModify bool `json:"allowModify,omitempty"` // allow modifying files
|
||||
DisableFileViewer bool `json:"disableFileViewer,omitempty"` // don't allow viewing files
|
||||
DisableDownload bool `json:"disableDownload,omitempty"` // don't allow downloading files
|
||||
AllowReplacements bool `json:"allowReplacements,omitempty"` // allow replacements of files
|
||||
DownloadsLimit int `json:"downloadsLimit,omitempty"`
|
||||
ShareTheme string `json:"shareTheme,omitempty"`
|
||||
DisableAnonymous bool `json:"disableAnonymous,omitempty"`
|
||||
MaxBandwidth int `json:"maxBandwidth,omitempty"`
|
||||
DisableThumbnails bool `json:"disableThumbnails,omitempty"`
|
||||
KeepAfterExpiration bool `json:"keepAfterExpiration,omitempty"`
|
||||
AllowedUsernames []string `json:"allowedUsernames,omitempty"`
|
||||
ThemeColor string `json:"themeColor,omitempty"`
|
||||
Banner string `json:"banner,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Favicon string `json:"favicon,omitempty"`
|
||||
QuickDownload bool `json:"quickDownload,omitempty"`
|
||||
HideNavButtons bool `json:"hideNavButtons,omitempty"`
|
||||
DisableSidebar bool `json:"disableSidebar"`
|
||||
Source string `json:"source,omitempty"` // backend source is path to maintain between name changes
|
||||
Path string `json:"path,omitempty"`
|
||||
DownloadURL string `json:"downloadURL,omitempty"`
|
||||
DisableShareCard bool `json:"disableShareCard,omitempty"`
|
||||
EnforceDarkLightMode string `json:"enforceDarkLightMode,omitempty"` // "dark" or "light"
|
||||
ViewMode string `json:"viewMode,omitempty"` // default view mode for anonymous users: "list", "compact", "normal", "gallery"
|
||||
EnableOnlyOffice bool `json:"enableOnlyOffice,omitempty"`
|
||||
ShareType string `json:"shareType"` // type of share: normal, upload, max
|
||||
PerUserDownloadLimit bool `json:"perUserDownloadLimit,omitempty"`
|
||||
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles,omitempty"` // can be io intensive for large files and take 10-30 seconds.
|
||||
AllowDelete bool `json:"allowDelete,omitempty"`
|
||||
AllowCreate bool `json:"allowCreate,omitempty"` // allow creating files
|
||||
AllowModify bool `json:"allowModify,omitempty"` // allow modifying files
|
||||
DisableFileViewer bool `json:"disableFileViewer,omitempty"` // don't allow viewing files
|
||||
DisableDownload bool `json:"disableDownload,omitempty"` // don't allow downloading files
|
||||
AllowReplacements bool `json:"allowReplacements,omitempty"` // allow replacements of files
|
||||
SidebarLinks []users.SidebarLink `json:"sidebarLinks"` // customizable sidebar links
|
||||
HasPassword bool `json:"hasPassword,omitempty"`
|
||||
}
|
||||
type CreateBody struct {
|
||||
CommonShare
|
||||
@@ -57,6 +63,6 @@ type Link struct {
|
||||
Token string `json:"token,omitempty"`
|
||||
|
||||
Mu sync.Mutex `json:"-"`
|
||||
UserDownloads map[string]int `json:"-"` // Track downloads per username (not persisted)
|
||||
|
||||
UserDownloads map[string]int `json:"userDownloads,omitempty"` // Track downloads per username
|
||||
Version int `json:"version,omitempty"`
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/crud"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
@@ -16,6 +17,7 @@ type StorageBackend interface {
|
||||
All() ([]*Link, error)
|
||||
FindByUserID(id uint) ([]*Link, error)
|
||||
GetByHash(hash string) (*Link, error)
|
||||
GetCommonShareByHash(hash string) (*CommonShare, error)
|
||||
GetPermanent(path, source string, id uint) (*Link, error)
|
||||
GetBySourcePath(path, source string) ([]*Link, error)
|
||||
Gets(path, source string, id uint) ([]*Link, error)
|
||||
@@ -220,51 +222,84 @@ func (s *Storage) GetBySourcePath(path, source string) ([]*Link, error) {
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// UpdateShares updates all shares that exactly match oldSource and oldPath
|
||||
// to point to newSource and newPath. Returns the number of updated shares.
|
||||
// UpdateShares updates all shares that match oldSource and oldPath to point to newSource and newPath.
|
||||
// Handles both exact matches and subdirectories, regardless of trailing slashes.
|
||||
func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (int, error) {
|
||||
links, err := s.back.All()
|
||||
if err != nil && err != errors.ErrNotExist {
|
||||
logger.Error("failed to list shares", "error", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Normalize paths for comparison (remove trailing slashes)
|
||||
oldPath = utils.AddTrailingSlashIfNotExists(oldPath)
|
||||
newPath = utils.AddTrailingSlashIfNotExists(newPath)
|
||||
|
||||
updated := 0
|
||||
for _, l := range links {
|
||||
if l == nil {
|
||||
continue
|
||||
}
|
||||
if l.Source != oldSource {
|
||||
if l == nil || l.Source != oldSource {
|
||||
continue
|
||||
}
|
||||
l.Path = utils.AddTrailingSlashIfNotExists(l.Path)
|
||||
|
||||
pos := strings.Index(l.Path, oldPath)
|
||||
if pos < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
oldFullPath := l.Path
|
||||
l.Source = newSource
|
||||
l.Path = oldFullPath[:pos] + newPath + oldFullPath[pos+len(oldPath):]
|
||||
l.Path = newPath
|
||||
|
||||
if err := s.back.Save(l); err != nil {
|
||||
logger.Error("failed to save updated share", "hash", l.Hash, "error", err)
|
||||
return updated, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
|
||||
// update existing cached pointer fields
|
||||
existing.Source = l.Source
|
||||
existing.Path = l.Path
|
||||
} else {
|
||||
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
|
||||
}
|
||||
|
||||
// UpdateSharePath updates the path for a specific share identified by hash
|
||||
func (s *Storage) UpdateSharePath(hash, newPath string) error {
|
||||
link, err := s.GetByHash(hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oldPath := link.Path
|
||||
link.Path = newPath
|
||||
|
||||
if err := s.back.Save(link); err != nil {
|
||||
logger.Error("failed to save updated share", "hash", hash, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if existing, ok := s.shareCache[hash]; ok && existing != nil {
|
||||
existing.Path = newPath
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
logger.Debug("share path updated", "hash", hash, "fromPath", oldPath, "toPath", newPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save wraps StorageBackend.Save
|
||||
func (s *Storage) Save(l *Link) error {
|
||||
if err := s.back.Save(l); err != nil {
|
||||
@@ -321,3 +356,8 @@ func (s *Storage) filterExpired(links []*Link) ([]*Link, error) {
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// GetCommonShareByHash retrieves only the CommonShare attributes for a given hash
|
||||
func (s *Storage) GetCommonShareByHash(hash string) (*CommonShare, error) {
|
||||
return s.back.GetCommonShareByHash(hash)
|
||||
}
|
||||
@@ -45,6 +45,23 @@ func (s shareBackend) GetByHash(hash string) (*share.Link, error) {
|
||||
return &v, err
|
||||
}
|
||||
|
||||
func (s shareBackend) GetCommonShareByHash(hash string) (*share.CommonShare, error) {
|
||||
var v share.Link
|
||||
err := s.db.One("Hash", hash, &v)
|
||||
if err == storm.ErrNotFound {
|
||||
return nil, errors.ErrNotExist
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.Source = ""
|
||||
v.Path = ""
|
||||
v.AllowedUsernames = nil
|
||||
v.DownloadsLimit = 0
|
||||
v.CommonShare.HasPassword = v.HasPassword()
|
||||
return &v.CommonShare, nil
|
||||
}
|
||||
|
||||
func (s shareBackend) GetPermanent(path, source string, id uint) (*share.Link, error) {
|
||||
var v share.Link
|
||||
// TODO remove legacy and return notfound errors
|
||||
|
||||
@@ -74,11 +74,15 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
passwordUser := existingUser.LoginMethod == users.LoginMethodPassword
|
||||
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
|
||||
if passwordUser && enforcedOtp && !user.OtpEnabled {
|
||||
return errors.ErrNoTotpConfigured
|
||||
}
|
||||
if user.LoginMethod == "" {
|
||||
user.LoginMethod = existingUser.LoginMethod
|
||||
}
|
||||
fields, err = parseFields(user, fields, actorIsAdmin)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -116,6 +120,14 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
|
||||
return err
|
||||
}
|
||||
}
|
||||
// converting scopes to map of paths intead of names (names can change)
|
||||
if slices.Contains(fields, "SidebarLinks") {
|
||||
adjustedLinks, err := settings.ConvertToBackendSidebarLinks(user.SidebarLinks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.SidebarLinks = adjustedLinks
|
||||
}
|
||||
// Use reflection to access struct fields
|
||||
userFields := reflect.ValueOf(user).Elem() // Get struct value
|
||||
for _, field := range fields {
|
||||
@@ -279,6 +291,7 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
|
||||
if capitalField == "Password" {
|
||||
// Only process password if it's actually being updated (not empty)
|
||||
if user.Password != "" {
|
||||
logger.Debugf("Updating password for user [%s] loginMethod: %s", user.Username, user.LoginMethod)
|
||||
if user.LoginMethod != users.LoginMethodPassword {
|
||||
return nil, fmt.Errorf("password cannot be changed when login method is not password")
|
||||
}
|
||||
|
||||
@@ -42,10 +42,10 @@ func InitializeDb(path string) (*bolt.BoltStore, bool, error) {
|
||||
return nil, exists, err
|
||||
}
|
||||
if !exists {
|
||||
if settings.Config.Env.IsPlaywright || settings.Config.Env.IsDevMode {
|
||||
settings.Config.Env.IsFirstLoad = false
|
||||
if settings.Env.IsPlaywright || settings.Env.IsDevMode {
|
||||
settings.Env.IsFirstLoad = false
|
||||
} else {
|
||||
settings.Config.Env.IsFirstLoad = true
|
||||
settings.Env.IsFirstLoad = true
|
||||
}
|
||||
quickSetup(store)
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func quickSetup(store *bolt.BoltStore) {
|
||||
}
|
||||
user.LockPassword = false
|
||||
user.Permissions = settings.AdminPerms()
|
||||
user.ShowFirstLogin = settings.Config.Env.IsFirstLoad && user.Permissions.Admin
|
||||
user.ShowFirstLogin = settings.Env.IsFirstLoad && user.Permissions.Admin
|
||||
logger.Debugf("Creating user as admin: %v %v", user.Username, user.Password)
|
||||
err = store.Users.Save(user, true, true)
|
||||
utils.CheckErr("store.Users.Save", err)
|
||||
@@ -109,7 +109,7 @@ func quickSetup(store *bolt.BoltStore) {
|
||||
// create new user
|
||||
func CreateUser(userInfo users.User, permissions users.Permissions) error {
|
||||
newUser := &userInfo
|
||||
newUser.ShowFirstLogin = settings.Config.Env.IsFirstLoad && newUser.Permissions.Admin
|
||||
newUser.ShowFirstLogin = settings.Env.IsFirstLoad && newUser.Permissions.Admin
|
||||
if userInfo.LoginMethod == "password" {
|
||||
if userInfo.Password == "" {
|
||||
return fmt.Errorf("password is required to create a password login user")
|
||||
|
||||
@@ -84,35 +84,36 @@ type SourceScope struct {
|
||||
|
||||
// json tags must match variable name with smaller case first letter
|
||||
type NonAdminEditable struct {
|
||||
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
|
||||
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
|
||||
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
|
||||
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
Preview Preview `json:"preview"`
|
||||
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
|
||||
DarkMode bool `json:"darkMode"` // should dark mode be enabled
|
||||
Password string `json:"password,omitempty"`
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
|
||||
Sorting Sorting `json:"sorting"`
|
||||
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
|
||||
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications
|
||||
FileLoading FileLoading `json:"fileLoading"` // upload and download settings
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // deprecated
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
|
||||
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
|
||||
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
|
||||
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
|
||||
DefaultLandingPage string `json:"defaultLandingPage"` // default landing page to use: eg. /files/mysource/mysubpath, /settings, etc.
|
||||
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
|
||||
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
|
||||
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
|
||||
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
Preview Preview `json:"preview"`
|
||||
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
|
||||
DarkMode bool `json:"darkMode"` // should dark mode be enabled
|
||||
Password string `json:"password,omitempty"`
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
|
||||
Sorting Sorting `json:"sorting"`
|
||||
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
|
||||
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications
|
||||
FileLoading FileLoading `json:"fileLoading"` // upload and download settings
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // deprecated
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
|
||||
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
type FileLoading struct {
|
||||
@@ -121,6 +122,15 @@ type FileLoading struct {
|
||||
ClearAll bool `json:"clearAll"`
|
||||
}
|
||||
|
||||
// SidebarLink represents a customizable link in the sidebar.
|
||||
type SidebarLink struct {
|
||||
Name string `json:"name"` // Display name of the link
|
||||
Category string `json:"category"` // Category type: "source", "share", "tool", "custom", etc.
|
||||
Target string `json:"target"` // Target path/URL for the link (relative for source/share)
|
||||
Icon string `json:"icon"` // Material icon name
|
||||
SourceName string `json:"sourceName,omitempty"` // Source identifier for source-type links
|
||||
}
|
||||
|
||||
func CleanUsername(s string) string {
|
||||
// Remove any trailing space to avoid ending on -
|
||||
s = strings.Trim(s, " ")
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,13 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"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
|
||||
)
|
||||
+5
-38
@@ -13,41 +13,8 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
)
|
||||
|
||||
// ImageService handles image operations with ffmpeg
|
||||
type ImageService struct {
|
||||
ffmpegPath string
|
||||
ffprobePath string
|
||||
debug bool
|
||||
cacheDir string
|
||||
semaphore chan struct{}
|
||||
}
|
||||
|
||||
// NewImageService creates a new image service instance
|
||||
func NewImageService(ffmpegPath, ffprobePath string, maxConcurrent int, debug bool, cacheDir string) *ImageService {
|
||||
return &ImageService{
|
||||
ffmpegPath: ffmpegPath,
|
||||
ffprobePath: ffprobePath,
|
||||
debug: debug,
|
||||
cacheDir: cacheDir,
|
||||
semaphore: make(chan struct{}, maxConcurrent),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ImageService) Acquire(ctx context.Context) error {
|
||||
select {
|
||||
case s.semaphore <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ImageService) Release() {
|
||||
<-s.semaphore
|
||||
}
|
||||
|
||||
// GetImageOrientation extracts the EXIF orientation from an image file using exiftool
|
||||
func (s *ImageService) GetImageOrientation(imagePath string) (string, error) {
|
||||
func (s *FFmpegService) GetImageOrientation(imagePath string) (string, error) {
|
||||
// Use exiftool to get orientation information
|
||||
cmd := exec.Command("exiftool", "-Orientation", "-s3", imagePath)
|
||||
var out bytes.Buffer
|
||||
@@ -67,7 +34,7 @@ func (s *ImageService) GetImageOrientation(imagePath string) (string, error) {
|
||||
}
|
||||
|
||||
// GetOrientationFilter converts EXIF orientation to FFmpeg filter
|
||||
func (s *ImageService) GetOrientationFilter(orientation string) string {
|
||||
func (s *FFmpegService) GetOrientationFilter(orientation string) string {
|
||||
switch orientation {
|
||||
// Text-based EXIF orientation values (from exiftool)
|
||||
case "Rotate 90 CW", "Right-top":
|
||||
@@ -111,7 +78,7 @@ func (s *ImageService) GetOrientationFilter(orientation string) string {
|
||||
}
|
||||
|
||||
// GetImageDimensions extracts the dimensions of an image file using ffprobe
|
||||
func (s *ImageService) GetImageDimensions(imagePath string) (width, height int, err error) {
|
||||
func (s *FFmpegService) GetImageDimensions(imagePath string) (width, height int, err error) {
|
||||
|
||||
// Get HEIC dimensions (fallback to individual stream dimensions for nowe re
|
||||
probeCmd := exec.Command(
|
||||
@@ -161,7 +128,7 @@ func (s *ImageService) GetImageDimensions(imagePath string) (width, height int,
|
||||
}
|
||||
|
||||
// ConvertHEICToJPEGDirect converts a HEIC file to JPEG using direct FFmpeg conversion (fast method)
|
||||
func (s *ImageService) ConvertHEICToJPEGDirect(ctx context.Context, heicPath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
|
||||
func (s *FFmpegService) ConvertHEICToJPEGDirect(ctx context.Context, heicPath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
|
||||
// Check if context is cancelled before starting
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
@@ -229,7 +196,7 @@ func (s *ImageService) ConvertHEICToJPEGDirect(ctx context.Context, heicPath str
|
||||
}
|
||||
|
||||
// ConvertHEICToJPEG converts a HEIC file to JPEG with specified dimensions and quality using proper tile extraction
|
||||
func (s *ImageService) ConvertHEICToJPEG(ctx context.Context, heicPath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
|
||||
func (s *FFmpegService) ConvertHEICToJPEG(ctx context.Context, heicPath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
|
||||
// Check if context is cancelled before starting
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetOrientationFilter(t *testing.T) {
|
||||
service := &ImageService{}
|
||||
service := &FFmpegService{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -125,7 +124,7 @@ func TestGetImageOrientation(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
service := NewImageService("ffmpeg", "ffprobe", 2, false, tempDir)
|
||||
service := NewFFmpegService(2, false, "/tmp/test_cache")
|
||||
|
||||
t.Run("Non-existent file", func(t *testing.T) {
|
||||
nonExistentFile := filepath.Join(tempDir, "non_existent.heic")
|
||||
@@ -153,94 +152,9 @@ func TestGetImageOrientation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewImageService(t *testing.T) {
|
||||
ffmpegPath := "/usr/bin/ffmpeg"
|
||||
ffprobePath := "/usr/bin/ffprobe"
|
||||
debug := true
|
||||
cacheDir := "/tmp/test_cache"
|
||||
|
||||
service := NewImageService(ffmpegPath, ffprobePath, 2, debug, cacheDir)
|
||||
|
||||
if service.ffmpegPath != ffmpegPath {
|
||||
t.Errorf("Expected ffmpegPath %q, got %q", ffmpegPath, service.ffmpegPath)
|
||||
}
|
||||
if service.ffprobePath != ffprobePath {
|
||||
t.Errorf("Expected ffprobePath %q, got %q", ffprobePath, service.ffprobePath)
|
||||
}
|
||||
if service.debug != debug {
|
||||
t.Errorf("Expected debug %v, got %v", debug, service.debug)
|
||||
}
|
||||
if service.cacheDir != cacheDir {
|
||||
t.Errorf("Expected cacheDir %q, got %q", cacheDir, service.cacheDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Integration test for orientation handling in direct conversion
|
||||
func TestConvertHEICToJPEGDirect_OrientationHandling(t *testing.T) {
|
||||
// Skip this test if ffmpeg is not available
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "ffmpeg_integration_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
service := NewImageService("ffmpeg", "ffprobe", 2, true, tempDir)
|
||||
|
||||
t.Run("Direct conversion with orientation detection", func(t *testing.T) {
|
||||
// This test requires actual HEIC files to work properly
|
||||
// For CI/CD environments, we can mock this or skip if files don't exist
|
||||
|
||||
heicTestFiles := []string{
|
||||
"/Users/steffag/Downloads/heic/IMG_6660.heic",
|
||||
"/Users/steffag/Downloads/heic/IMG_2919.HEIC",
|
||||
"/Users/steffag/Downloads/heic/shelf-christmas-decoration.heic",
|
||||
}
|
||||
|
||||
for _, testFile := range heicTestFiles {
|
||||
// Check if test file exists
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Logf("Skipping test for %s (file not found)", filepath.Base(testFile))
|
||||
continue
|
||||
}
|
||||
|
||||
t.Logf("Testing orientation handling for %s", filepath.Base(testFile))
|
||||
|
||||
// Test orientation detection
|
||||
orientation, err := service.GetImageOrientation(testFile)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get orientation for %s: %v", filepath.Base(testFile), err)
|
||||
continue
|
||||
}
|
||||
t.Logf("Detected orientation: %s", orientation)
|
||||
|
||||
// Test filter generation
|
||||
filter := service.GetOrientationFilter(orientation)
|
||||
t.Logf("Generated filter: %s", filter)
|
||||
|
||||
// Test actual conversion (small size for speed)
|
||||
jpegBytes, err := service.ConvertHEICToJPEGDirect(context.Background(), testFile, 100, 100, "5")
|
||||
if err != nil {
|
||||
t.Errorf("Failed to convert %s: %v", filepath.Base(testFile), err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(jpegBytes) < 400 {
|
||||
t.Errorf("Converted image too small (%d bytes), likely conversion failed", len(jpegBytes))
|
||||
}
|
||||
|
||||
t.Logf("Successfully converted %s to %d bytes", filepath.Base(testFile), len(jpegBytes))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark the orientation filter generation
|
||||
func BenchmarkGetOrientationFilter(b *testing.B) {
|
||||
service := &ImageService{}
|
||||
service := &FFmpegService{}
|
||||
orientations := []string{
|
||||
"Horizontal (normal)",
|
||||
"Rotate 90 CW",
|
||||
@@ -258,7 +172,7 @@ func BenchmarkGetOrientationFilter(b *testing.B) {
|
||||
|
||||
// Test table for all EXIF orientation values
|
||||
func TestGetOrientationFilter_AllEXIFValues(t *testing.T) {
|
||||
service := &ImageService{}
|
||||
service := &FFmpegService{}
|
||||
|
||||
// Test all possible EXIF orientation values according to EXIF spec
|
||||
exifTests := []struct {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetMediaDuration extracts the duration of a media file (audio or video) using ffprobe
|
||||
// This method respects the concurrency limits, uses caching, and gracefully handles errors
|
||||
// Cache key is based on file path + mod time for automatic cache invalidation on file changes
|
||||
func (s *FFmpegService) GetMediaDuration(ctx context.Context, mediaPath string) (float64, error) {
|
||||
// Get file info for cache key (path + mod time)
|
||||
fileInfo, err := os.Stat(mediaPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to stat file: %w", err)
|
||||
}
|
||||
// Create cache key from path and modification time
|
||||
cacheKey := fmt.Sprintf("%s:%d", mediaPath, fileInfo.ModTime().Unix())
|
||||
// Check cache first
|
||||
if duration, found := MetadataCache.Get(cacheKey); found {
|
||||
return duration, nil
|
||||
}
|
||||
// Acquire semaphore slot for concurrency control
|
||||
if err = s.Acquire(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer s.Release()
|
||||
|
||||
// Check if context is cancelled before starting
|
||||
if ctx.Err() != nil {
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx,
|
||||
s.ffprobePath,
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
mediaPath,
|
||||
)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
MetadataCache.Set(cacheKey, 0)
|
||||
return 0, fmt.Errorf("ffprobe failed: %w", err)
|
||||
}
|
||||
|
||||
durationStr := strings.TrimSpace(string(output))
|
||||
if durationStr == "" || durationStr == "N/A" {
|
||||
MetadataCache.Set(cacheKey, 0)
|
||||
return 0, fmt.Errorf("could not determine media duration")
|
||||
}
|
||||
|
||||
duration, err := strconv.ParseFloat(durationStr, 64)
|
||||
if err != nil {
|
||||
MetadataCache.Set(cacheKey, 0)
|
||||
return 0, fmt.Errorf("invalid duration: %v", err)
|
||||
}
|
||||
|
||||
// Cache successful result
|
||||
MetadataCache.Set(cacheKey, duration)
|
||||
|
||||
return duration, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// VideoService handles video preview operations with ffmpeg
|
||||
type FFmpegService struct {
|
||||
ffmpegPath string
|
||||
ffprobePath string
|
||||
debug bool
|
||||
semaphore chan struct{}
|
||||
cacheDir string
|
||||
maxConcurrent int // For logging purposes
|
||||
}
|
||||
|
||||
// NewVideoService creates a new video service instance
|
||||
func NewFFmpegService(maxConcurrent int, debug bool, cacheDir string) *FFmpegService {
|
||||
if settings.Env.FFmpegPath == "" || settings.Env.FFprobePath == "" {
|
||||
return nil
|
||||
}
|
||||
return &FFmpegService{
|
||||
ffmpegPath: settings.Env.FFmpegPath,
|
||||
ffprobePath: settings.Env.FFprobePath,
|
||||
debug: debug,
|
||||
semaphore: make(chan struct{}, maxConcurrent),
|
||||
maxConcurrent: maxConcurrent,
|
||||
cacheDir: cacheDir,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FFmpegService) Acquire(ctx context.Context) error {
|
||||
select {
|
||||
case s.semaphore <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FFmpegService) Release() {
|
||||
<-s.semaphore
|
||||
}
|
||||
|
||||
// CheckValidFFmpeg checks for a valid ffmpeg executable.
|
||||
// If a path is provided, it looks there. Otherwise, it searches the system's PATH.
|
||||
func CheckValidFFmpeg(path string) (string, error) {
|
||||
return checkExecutable(path, "ffmpeg")
|
||||
}
|
||||
|
||||
// CheckValidFFprobe checks for a valid ffprobe executable.
|
||||
// If a path is provided, it looks there. Otherwise, it searches the system's PATH.
|
||||
func CheckValidFFprobe(path string) (string, error) {
|
||||
return checkExecutable(path, "ffprobe")
|
||||
}
|
||||
|
||||
// checkExecutable is an internal helper function to find and validate an executable.
|
||||
// It checks a specific path if provided, otherwise falls back to searching the system PATH.
|
||||
func checkExecutable(providedPath, execName string) (string, error) {
|
||||
// Add .exe extension for Windows systems
|
||||
if runtime.GOOS == "windows" {
|
||||
execName += ".exe"
|
||||
}
|
||||
|
||||
var finalPath string
|
||||
var err error
|
||||
|
||||
if providedPath != "" {
|
||||
// A path was provided, so we'll use it.
|
||||
finalPath = filepath.Join(providedPath, execName)
|
||||
} else {
|
||||
// No path was provided, so search the system's PATH for the executable.
|
||||
finalPath, err = exec.LookPath(execName)
|
||||
if err != nil {
|
||||
// The executable was not found in the system's PATH.
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the executable is valid by running the "-version" command.
|
||||
cmd := exec.Command(finalPath, "-version")
|
||||
err = cmd.Run()
|
||||
|
||||
return finalPath, err
|
||||
}
|
||||
|
||||
func SetFFmpegPaths() {
|
||||
ffmpegMainPath, err := CheckValidFFmpeg(settings.Env.FFmpegPath)
|
||||
if err != nil && settings.Env.FFmpegPath != "" {
|
||||
logger.Warningf("the configured ffmpeg path does not contain a valid ffmpeg binary %s, err: %v", settings.Env.FFmpegPath, err)
|
||||
}
|
||||
ffprobePath, errprobe := CheckValidFFprobe(settings.Env.FFprobePath)
|
||||
if errprobe != nil && settings.Env.FFprobePath != "" {
|
||||
logger.Warningf("the configured ffmpeg path is not a valid ffprobe binary %s, err: %v", settings.Env.FFprobePath, err)
|
||||
}
|
||||
settings.Env.FFmpegPath = ffmpegMainPath
|
||||
settings.Env.FFprobePath = ffprobePath
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
@@ -25,11 +24,6 @@ type SubtitleTrack struct {
|
||||
IsFile bool `json:"isFile"` // true for external files, false for embedded streams
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
// FFProbeOutput represents the JSON output from ffprobe
|
||||
type FFProbeOutput struct {
|
||||
Streams []struct {
|
||||
|
||||
+3
-62
@@ -13,46 +13,13 @@ import (
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// VideoService handles video preview operations with ffmpeg
|
||||
type VideoService struct {
|
||||
ffmpegPath string
|
||||
ffprobePath string
|
||||
debug bool
|
||||
semaphore chan struct{}
|
||||
maxConcurrent int // For logging purposes
|
||||
}
|
||||
|
||||
// NewVideoService creates a new video service instance
|
||||
func NewVideoService(ffmpegPath, ffprobePath string, maxConcurrent int, debug bool) *VideoService {
|
||||
return &VideoService{
|
||||
ffmpegPath: ffmpegPath,
|
||||
ffprobePath: ffprobePath,
|
||||
debug: debug,
|
||||
semaphore: make(chan struct{}, maxConcurrent),
|
||||
maxConcurrent: maxConcurrent,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *VideoService) acquire(ctx context.Context) error {
|
||||
select {
|
||||
case s.semaphore <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *VideoService) release() {
|
||||
<-s.semaphore
|
||||
}
|
||||
|
||||
// GenerateVideoPreviewStreaming generates a video preview and streams it directly to a writer
|
||||
// This is more memory efficient for large previews as it doesn't load the entire file into memory
|
||||
func (s *VideoService) GenerateVideoPreviewStreaming(ctx context.Context, videoPath string, percentageSeek int, writer io.Writer) error {
|
||||
if err := s.acquire(ctx); err != nil {
|
||||
func (s *FFmpegService) GenerateVideoPreviewStreaming(ctx context.Context, videoPath string, percentageSeek int, writer io.Writer) error {
|
||||
if err := s.Acquire(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.release()
|
||||
defer s.Release()
|
||||
|
||||
// Check if context is cancelled before starting
|
||||
if ctx.Err() != nil {
|
||||
@@ -145,29 +112,3 @@ func (s *VideoService) GenerateVideoPreviewStreaming(ctx context.Context, videoP
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetVideoDuration extracts the duration of a video file using ffprobe
|
||||
func GetVideoDuration(ffprobePath string, videoPath string) (float64, error) {
|
||||
cmd := exec.Command(ffprobePath,
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
videoPath)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ffprobe failed: %w", err)
|
||||
}
|
||||
|
||||
durationStr := strings.TrimSpace(string(output))
|
||||
if durationStr == "" || durationStr == "N/A" {
|
||||
return 0, fmt.Errorf("could not determine video duration")
|
||||
}
|
||||
|
||||
duration, err := strconv.ParseFloat(durationStr, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration: %v", err)
|
||||
}
|
||||
|
||||
return duration, nil
|
||||
}
|
||||
+30
-31
@@ -20,11 +20,11 @@ require (
|
||||
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.43.0
|
||||
golang.org/x/image v0.32.0
|
||||
golang.org/x/mod v0.29.0
|
||||
golang.org/x/oauth2 v0.32.0
|
||||
golang.org/x/sys v0.37.0
|
||||
golang.org/x/crypto v0.44.0
|
||||
golang.org/x/image v0.33.0
|
||||
golang.org/x/mod v0.30.0
|
||||
golang.org/x/oauth2 v0.33.0
|
||||
golang.org/x/sys v0.38.0
|
||||
golang.org/x/time v0.14.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
@@ -49,7 +49,7 @@ require (
|
||||
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||
github.com/MirrexOne/unqueryvet v1.2.1 // indirect
|
||||
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
|
||||
github.com/air-verse/air v1.63.0 // indirect
|
||||
github.com/air-verse/air v1.63.1 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
|
||||
@@ -83,7 +83,6 @@ require (
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/ckaznocha/intrange v0.3.1 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/curioswitch/go-reassign v0.3.0 // indirect
|
||||
github.com/daixiang0/gci v0.13.7 // indirect
|
||||
github.com/dave/dst v0.27.3 // indirect
|
||||
@@ -92,7 +91,7 @@ require (
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
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/ebitengine/purego v0.9.0 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/ettle/strcase v0.2.0 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fatih/structtag v1.2.0 // indirect
|
||||
@@ -101,19 +100,19 @@ require (
|
||||
github.com/fzipp/gocyclo v0.6.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
|
||||
github.com/ghostiam/protogetter v0.3.17 // indirect
|
||||
github.com/go-critic/go-critic v0.14.0 // indirect
|
||||
github.com/go-critic/go-critic v0.14.2 // 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.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
||||
github.com/go-openapi/spec v0.22.0 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.3 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.3 // indirect
|
||||
github.com/go-openapi/spec v0.22.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.3 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.3 // 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
|
||||
@@ -129,13 +128,13 @@ require (
|
||||
github.com/godoc-lint/godoc-lint v0.10.1 // indirect
|
||||
github.com/gofrs/flock v0.13.0 // indirect
|
||||
github.com/gohugoio/hugo v0.149.1 // indirect
|
||||
github.com/golang/geo v0.0.0-20251030142647-5906ab3d21fa // indirect
|
||||
github.com/golang/geo v0.0.0-20251117194806-05dcfdd28b33 // 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.6.0 // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.6.2 // indirect
|
||||
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 // indirect
|
||||
github.com/golangci/misspell v0.7.0 // indirect
|
||||
github.com/golangci/plugin-module-register v0.1.2 // indirect
|
||||
@@ -158,10 +157,10 @@ require (
|
||||
github.com/jjti/go-spancheck v0.6.5 // indirect
|
||||
github.com/julz/importas v0.2.0 // indirect
|
||||
github.com/jupiterrider/ffi v0.5.1 // indirect
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
|
||||
github.com/kisielk/errcheck v1.9.0 // indirect
|
||||
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
|
||||
github.com/kovidgoyal/go-parallel v1.0.1 // indirect
|
||||
github.com/kovidgoyal/go-parallel v1.1.1 // 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
|
||||
@@ -178,8 +177,8 @@ require (
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
|
||||
github.com/manuelarte/funcorder v0.5.0 // indirect
|
||||
github.com/maratori/testableexamples v1.0.0 // indirect
|
||||
github.com/maratori/testpackage v1.1.1 // indirect
|
||||
github.com/maratori/testableexamples v1.0.1 // indirect
|
||||
github.com/maratori/testpackage v1.1.2 // indirect
|
||||
github.com/matoous/godox v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -202,7 +201,7 @@ require (
|
||||
github.com/prometheus/client_model v0.2.0 // indirect
|
||||
github.com/prometheus/common v0.32.1 // indirect
|
||||
github.com/prometheus/procfs v0.7.3 // indirect
|
||||
github.com/quasilyte/go-ruleguard v0.4.4 // indirect
|
||||
github.com/quasilyte/go-ruleguard v0.4.5 // indirect
|
||||
github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
|
||||
github.com/quasilyte/gogrep v0.5.0 // indirect
|
||||
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
|
||||
@@ -259,11 +258,11 @@ require (
|
||||
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/typeparams v0.0.0-20251002181428-27f1f14c8bb9 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/tools v0.39.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
|
||||
|
||||
+70
-64
@@ -77,8 +77,8 @@ github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsu
|
||||
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.0 h1:fwcdHpwaUe4/+q349PxptzAIn8gVE6Yke8TgW0LsDxQ=
|
||||
github.com/air-verse/air v1.63.0/go.mod h1:RyCQVx2+3Zz2BzoqkukYiGmWkWXNKMf0x5ubIFcUB8Q=
|
||||
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/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=
|
||||
@@ -200,8 +200,6 @@ github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSw
|
||||
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=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs=
|
||||
github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88=
|
||||
github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ=
|
||||
@@ -238,8 +236,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LV
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
|
||||
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
|
||||
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
|
||||
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
@@ -271,8 +269,8 @@ 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/go-critic/go-critic v0.14.0 h1:fzA7pGprRPwgA2LwoiaHuWzZzmUEM7dZjihfFpiZQpQ=
|
||||
github.com/go-critic/go-critic v0.14.0/go.mod h1:/2QgW4QJIJ9aYCYTQ9RWKc+1KnMFouAJ/FmPylhzOoU=
|
||||
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/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
|
||||
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
|
||||
@@ -292,29 +290,33 @@ 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.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
|
||||
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
|
||||
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
|
||||
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
|
||||
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
|
||||
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
|
||||
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
|
||||
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
|
||||
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
|
||||
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
|
||||
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
|
||||
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
|
||||
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
|
||||
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
|
||||
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
|
||||
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
|
||||
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
|
||||
github.com/go-openapi/swag/conv v0.25.3 h1:PcB18wwfba7MN5BVlBIV+VxvUUeC2kEuCEyJ2/t2X7E=
|
||||
github.com/go-openapi/swag/conv v0.25.3/go.mod h1:n4Ibfwhn8NJnPXNRhBO5Cqb9ez7alBR40JS4rbASUPU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A=
|
||||
github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.3 h1:kV7wer79KXUM4Ea4tBdAVTU842Rg6tWstX3QbM4fGdw=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.3/go.mod h1:ILcKqe4HC1VEZmJx51cVuZQ6MF8QvdfXsQfiaCs0z9o=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3 h1:/i3E9hBujtXfHy91rjtwJ7Fgv5TuDHgnSrYjhFxwxOw=
|
||||
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3/go.mod h1:8kYfCR2rHyOj25HVvxL5Nm8wkfzggddgjZm6RgjT8Ao=
|
||||
github.com/go-openapi/swag/loading v0.25.3 h1:Nn65Zlzf4854MY6Ft0JdNrtnHh2bdcS/tXckpSnOb2Y=
|
||||
github.com/go-openapi/swag/loading v0.25.3/go.mod h1:xajJ5P4Ang+cwM5gKFrHBgkEDWfLcsAKepIuzTmOb/c=
|
||||
github.com/go-openapi/swag/stringutils v0.25.3 h1:nAmWq1fUTWl/XiaEPwALjp/8BPZJun70iDHRNq/sH6w=
|
||||
github.com/go-openapi/swag/stringutils v0.25.3/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
|
||||
github.com/go-openapi/swag/typeutils v0.25.3 h1:2w4mEEo7DQt3V4veWMZw0yTPQibiL3ri2fdDV4t2TQc=
|
||||
github.com/go-openapi/swag/typeutils v0.25.3/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.3 h1:LKTJjCn/W1ZfMec0XDL4Vxh8kyAnv1orH5F2OREDUrg=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.3/go.mod h1:Y7QN6Wc5DOBXK14/xeo1cQlq0EA0wvLoSv13gDQoCao=
|
||||
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-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=
|
||||
@@ -383,8 +385,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-20251030142647-5906ab3d21fa h1:Q0YqGWxrOZZCHHlFc3KZBjnl6FKqFCpd2qxnbf7duXY=
|
||||
github.com/golang/geo v0.0.0-20251030142647-5906ab3d21fa/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
|
||||
github.com/golang/geo v0.0.0-20251117194806-05dcfdd28b33 h1:1iCpF+lKihFpyN/Ujqvy9cWVlB516bGZItvb3l9HOSo=
|
||||
github.com/golang/geo v0.0.0-20251117194806-05dcfdd28b33/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=
|
||||
@@ -424,8 +426,8 @@ 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.0 h1:hgjuaMTwbWrBf1kvMrmWmyCRKq9wU7Cvi84O0METZHc=
|
||||
github.com/golangci/golangci-lint/v2 v2.6.0/go.mod h1:qKA7VpZt6X2uo+ylnFYaNpdmJnm4tBSzdZiD0xDOIJE=
|
||||
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/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/misspell v0.7.0 h1:4GOHr/T1lTW0hhR4tgaaV1WS/lJ+ncvYCoFKmqJsj0c=
|
||||
@@ -533,8 +535,8 @@ 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/karamaru-alpha/copyloopvar v1.2.1 h1:wmZaZYIjnJ0b5UoKDjUHrikcV0zuPyyxI4SVplLd2CI=
|
||||
github.com/karamaru-alpha/copyloopvar v1.2.1/go.mod h1:nFmMlFNlClC2BPvNaHMdkirmTJxVCY0lhxBtlfOypMM=
|
||||
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=
|
||||
github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
@@ -542,10 +544,14 @@ github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/tt
|
||||
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
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.0.1 h1:nYUjN+EdpbmQjTg3N5eTUInuXTB3/1oD2vHdaMfuHoI=
|
||||
github.com/kovidgoyal/go-parallel v1.0.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
|
||||
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/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.7.2 h1:mmT6k6Az3mC6dbqdZ6Q9KQCdZFWTAQ+q97NyGZgJ/2c=
|
||||
github.com/kovidgoyal/imaging v1.7.2/go.mod h1:GdkCORjfZMMGFY0Pb7TDmRhj7PDhxF/QShKukSCj0VU=
|
||||
github.com/kovidgoyal/imaging v1.8.15 h1:FwKRGvmbQ04OFYF5WIalcvUontcnj1chBeHmR5I7UYg=
|
||||
github.com/kovidgoyal/imaging v1.8.15/go.mod h1:aXZHGm/gKcaXpxn1NnQiVtuz3Z2BCnXWyjF0ZGrmJ1g=
|
||||
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=
|
||||
@@ -590,10 +596,10 @@ github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLt
|
||||
github.com/manuelarte/embeddedstructfieldcheck v0.4.0/go.mod h1:z8dFSyXqp+fC6NLDSljRJeNQJJDWnY7RoWFzV3PC6UM=
|
||||
github.com/manuelarte/funcorder v0.5.0 h1:llMuHXXbg7tD0i/LNw8vGnkDTHFpTnWqKPI85Rknc+8=
|
||||
github.com/manuelarte/funcorder v0.5.0/go.mod h1:Yt3CiUQthSBMBxjShjdXMexmzpP8YGvGLjrxJNkO2hA=
|
||||
github.com/maratori/testableexamples v1.0.0 h1:dU5alXRrD8WKSjOUnmJZuzdxWOEQ57+7s93SLMxb2vI=
|
||||
github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE=
|
||||
github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04=
|
||||
github.com/maratori/testpackage v1.1.1/go.mod h1:s4gRK/ym6AMrqpOa/kEbQTV4Q4jb7WeLZzVhVVVOQMc=
|
||||
github.com/maratori/testableexamples v1.0.1 h1:HfOQXs+XgfeRBJ+Wz0XfH+FHnoY9TVqL6Fcevpzy4q8=
|
||||
github.com/maratori/testableexamples v1.0.1/go.mod h1:XE2F/nQs7B9N08JgyRmdGjYVGqxWwClLPCGSQhXQSrQ=
|
||||
github.com/maratori/testpackage v1.1.2 h1:ffDSh+AgqluCLMXhM19f/cpvQAKygKAJXFl9aUjmbqs=
|
||||
github.com/maratori/testpackage v1.1.2/go.mod h1:8F24GdVDFW5Ew43Et02jamrVMNXLUNaOynhDssITGfc=
|
||||
github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
|
||||
github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
|
||||
github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4=
|
||||
@@ -704,8 +710,8 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
|
||||
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||
github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU=
|
||||
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||
github.com/quasilyte/go-ruleguard v0.4.4 h1:53DncefIeLX3qEpjzlS1lyUmQoUEeOWPFWqaTJq9eAQ=
|
||||
github.com/quasilyte/go-ruleguard v0.4.4/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE=
|
||||
github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA=
|
||||
github.com/quasilyte/go-ruleguard v0.4.5/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE=
|
||||
github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY=
|
||||
github.com/quasilyte/go-ruleguard/dsl v0.3.23/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU=
|
||||
github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo=
|
||||
@@ -889,8 +895,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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||
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=
|
||||
@@ -905,12 +911,12 @@ golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||
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-20251002181428-27f1f14c8bb9 h1:EvjuVHWMoRaAxH402KMgrQpGUjoBy/OWvZjLOqQnwNk=
|
||||
golang.org/x/exp/typeparams v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
|
||||
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 h1:HDjDiATsGqvuqvkDvgJjD1IgPrVekcSXVVE21JwvzGE=
|
||||
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
|
||||
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
|
||||
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
||||
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
|
||||
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=
|
||||
@@ -936,8 +942,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.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
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=
|
||||
@@ -980,16 +986,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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
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.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
|
||||
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
||||
golang.org/x/oauth2 v0.33.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=
|
||||
@@ -1005,8 +1011,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -1060,8 +1066,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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.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=
|
||||
@@ -1078,8 +1084,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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
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=
|
||||
@@ -1135,8 +1141,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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
||||
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=
|
||||
|
||||
+80
-1
@@ -133,7 +133,7 @@ func accessPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
|
||||
// accessDeleteHandler deletes a single user or group from a rule.
|
||||
// @Summary Delete access rule entry
|
||||
// @Description Delete a user or group from an allow or deny list for a sourcePath and indexPath.
|
||||
// @Description Delete a user or group from an allow or deny list for a sourcePath and indexPath. When cascade=true, removes the user/group from the specified path and all subpaths.
|
||||
// @Tags Access
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -142,6 +142,7 @@ func accessPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
// @Param ruleType query string true "Rule type (allow or deny)"
|
||||
// @Param ruleCategory query string true "Rule category (user or group)"
|
||||
// @Param value query string true "Username or groupname to remove"
|
||||
// @Param cascade query boolean false "Cascade delete to all subpaths (default: false)"
|
||||
// @Success 200 {object} map[string]string "Rule entry deleted"
|
||||
// @Failure 404 {object} map[string]string "Not found"
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
@@ -158,12 +159,47 @@ func accessDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
ruleType := r.URL.Query().Get("ruleType")
|
||||
ruleCategory := r.URL.Query().Get("ruleCategory")
|
||||
value := r.URL.Query().Get("value")
|
||||
cascade := r.URL.Query().Get("cascade") == "true"
|
||||
allow := ruleType == "allow"
|
||||
|
||||
if indexPath == "" || ruleCategory == "" || (ruleCategory != "all" && value == "") {
|
||||
return http.StatusBadRequest, fmt.Errorf("path, ruleCategory, and value are required, unless ruleCategory is 'all'")
|
||||
}
|
||||
|
||||
// Handle cascade delete
|
||||
if cascade {
|
||||
if ruleCategory == "all" {
|
||||
return http.StatusBadRequest, fmt.Errorf("cascade delete is not supported for 'all' rule category")
|
||||
}
|
||||
|
||||
var count int
|
||||
var err error
|
||||
|
||||
switch ruleCategory {
|
||||
case "user":
|
||||
count, err = store.Access.RemoveUserCascade(index.Path, indexPath, value, allow)
|
||||
case "group":
|
||||
count, err = store.Access.RemoveGroupCascade(index.Path, indexPath, value, allow)
|
||||
default:
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid ruleCategory for cascade delete: must be 'user' or 'group'")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Errorf("failed to cascade delete rule entry: %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to cascade delete rule entry: %w", err)
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return http.StatusNotFound, fmt.Errorf("no entries found in rule hierarchy")
|
||||
}
|
||||
|
||||
return renderJSON(w, r, map[string]interface{}{
|
||||
"message": "rule entries deleted",
|
||||
"count": count,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle non-cascade delete (original behavior)
|
||||
var found bool
|
||||
var err error
|
||||
if allow {
|
||||
@@ -265,3 +301,46 @@ func groupDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
}
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
// accessPatchHandler updates an access rule's path.
|
||||
// @Summary Update access rule path
|
||||
// @Description Updates the path for a specific access rule
|
||||
// @Tags Access
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{source=string,oldPath=string,newPath=string} true "Source, old path, and new path"
|
||||
// @Success 200 {object} map[string]string "Rule path updated successfully"
|
||||
// @Failure 400 {object} map[string]string "Bad request - missing or invalid parameters"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/access [patch]
|
||||
func accessPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
var body struct {
|
||||
Source string `json:"source"`
|
||||
OldPath string `json:"oldPath"`
|
||||
NewPath string `json:"newPath"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("failed to decode body: %w", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if body.Source == "" || body.OldPath == "" || body.NewPath == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("source, oldPath, and newPath are required")
|
||||
}
|
||||
|
||||
// Get the index for the source
|
||||
index := indexing.GetIndex(body.Source)
|
||||
if index == nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("source not found: %s", body.Source)
|
||||
}
|
||||
|
||||
// Update the access rule path
|
||||
err := store.Access.UpdateRulePath(index.Path, body.OldPath, body.NewPath)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to update rule path: %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to update rule path: %w", err)
|
||||
}
|
||||
|
||||
return renderJSON(w, r, map[string]string{"message": "rule path updated"})
|
||||
}
|
||||
@@ -223,7 +223,7 @@ func signupHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
},
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
}
|
||||
err := storage.CreateUser(user, settings.Config.UserDefaults.Permissions)
|
||||
err := storage.CreateUser(user, settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Permissions))
|
||||
if err != nil {
|
||||
logger.Debug(err.Error())
|
||||
// Return the actual error message instead of a generic one
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
)
|
||||
|
||||
// duplicateSearchMutex serializes duplicate searches to run one at a time
|
||||
// This is separate from the index's scanMutex to avoid conflicts with indexing
|
||||
var duplicateSearchMutex sync.Mutex
|
||||
|
||||
// duplicateResultsCache caches duplicate search results for 15 seconds
|
||||
var duplicateResultsCache = cache.NewCache[[]duplicateGroup](15 * time.Second)
|
||||
|
||||
// fileLocation is a minimal reference to a file in the index
|
||||
// Used during duplicate detection to avoid allocating full SearchResult objects
|
||||
type fileLocation struct {
|
||||
dirPath string // path to directory in index
|
||||
fileIdx int // index in directory's Files slice
|
||||
}
|
||||
|
||||
type duplicateGroup struct {
|
||||
Size int64 `json:"size"`
|
||||
Count int `json:"count"`
|
||||
Files []*indexing.SearchResult `json:"files"`
|
||||
}
|
||||
|
||||
type duplicatesOptions struct {
|
||||
source string
|
||||
searchScope string
|
||||
combinedPath string
|
||||
minSize int64
|
||||
useChecksum bool
|
||||
}
|
||||
|
||||
// duplicatesHandler handles requests to find duplicate files
|
||||
//
|
||||
// This endpoint finds files with the same size and uses fuzzy filename matching
|
||||
// to verify potential duplicates.
|
||||
//
|
||||
// It's optimized to handle large directories efficiently by:
|
||||
// 1. Filtering by minimum size first
|
||||
// 2. Grouping by size
|
||||
// 3. Verifying matches with fuzzy filename similarity
|
||||
//
|
||||
// @Summary Find Duplicate Files
|
||||
// @Description Finds duplicate files based on size and fuzzy filename matching
|
||||
// @Tags Duplicates
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param source query string true "Source name for the desired source"
|
||||
// @Param scope query string false "path within user scope to search"
|
||||
// @Param minSizeMb query int false "Minimum file size in megabytes (default: 1)"
|
||||
// @Success 200 {array} duplicateGroup "List of duplicate file groups"
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
// @Router /api/duplicates [get]
|
||||
func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
opts, err := prepDuplicatesOptions(r, d)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
index := indexing.GetIndex(opts.source)
|
||||
if index == nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("index not found for source %s", opts.source)
|
||||
}
|
||||
|
||||
// Generate cache key from all input parameters that affect results
|
||||
cacheKey := fmt.Sprintf("%s:%s:%d:%t", index.Path, opts.combinedPath, opts.minSize, opts.useChecksum)
|
||||
|
||||
// Check cache first (before acquiring mutex)
|
||||
if cachedResults, ok := duplicateResultsCache.Get(cacheKey); ok {
|
||||
return renderJSON(w, r, cachedResults)
|
||||
}
|
||||
|
||||
// Reject concurrent requests
|
||||
if !duplicateSearchMutex.TryLock() {
|
||||
return http.StatusServiceUnavailable, fmt.Errorf("another duplicate search is currently running, please try again in a moment")
|
||||
}
|
||||
defer duplicateSearchMutex.Unlock()
|
||||
|
||||
// Check cache again after acquiring lock (another request might have just completed)
|
||||
if cachedResults, ok := duplicateResultsCache.Get(cacheKey); ok {
|
||||
return renderJSON(w, r, cachedResults)
|
||||
}
|
||||
|
||||
// Find duplicates using index-native approach to minimize memory allocation
|
||||
// This avoids creating SearchResult objects until we know the final limited set
|
||||
duplicateGroups := findDuplicatesInIndex(index, opts)
|
||||
|
||||
// Cache the results before returning
|
||||
duplicateResultsCache.Set(cacheKey, duplicateGroups)
|
||||
|
||||
return renderJSON(w, r, duplicateGroups)
|
||||
}
|
||||
|
||||
// findDuplicatesInIndex finds duplicates by working directly with index structures
|
||||
// This minimizes memory allocation by only creating SearchResult objects for final results
|
||||
func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions) []duplicateGroup {
|
||||
const maxTotalFiles = 1000 // Limit total files across all groups
|
||||
|
||||
// Step 1: Group files by size, working directly with index structures
|
||||
// Key: size, Value: list of (dirPath, fileIndex) minimal references
|
||||
sizeGroups := make(map[int64][]fileLocation)
|
||||
|
||||
index.ReadOnlyOperation(func() {
|
||||
for dirPath, dir := range index.GetDirectories() {
|
||||
// Skip directories not in scope
|
||||
if opts.combinedPath != "" && !strings.HasPrefix(dirPath, opts.combinedPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range dir.Files {
|
||||
file := &dir.Files[i]
|
||||
if file.Size >= opts.minSize {
|
||||
sizeGroups[file.Size] = append(sizeGroups[file.Size], fileLocation{dirPath, i})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Step 2: Process each size group to find duplicates
|
||||
duplicateGroups := []duplicateGroup{}
|
||||
totalFiles := 0
|
||||
|
||||
for size, locations := range sizeGroups {
|
||||
if len(locations) < 2 {
|
||||
continue // Skip files with no potential duplicates
|
||||
}
|
||||
|
||||
var groups [][]fileLocation
|
||||
if opts.useChecksum {
|
||||
groups = groupLocationsByChecksum(locations, index, size)
|
||||
} else {
|
||||
groups = groupLocationsByFilename(locations, index, size)
|
||||
}
|
||||
|
||||
// Step 3: Only now create SearchResult objects for actual duplicates
|
||||
for _, locGroup := range groups {
|
||||
if len(locGroup) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if adding this group would exceed the limit
|
||||
if totalFiles+len(locGroup) > maxTotalFiles {
|
||||
break
|
||||
}
|
||||
|
||||
// Create SearchResult objects only for these confirmed duplicates
|
||||
resultGroup := make([]*indexing.SearchResult, 0, len(locGroup))
|
||||
index.ReadOnlyOperation(func() {
|
||||
dirs := index.GetDirectories()
|
||||
for _, loc := range locGroup {
|
||||
dir := dirs[loc.dirPath]
|
||||
if dir == nil {
|
||||
continue
|
||||
}
|
||||
if loc.fileIdx >= len(dir.Files) {
|
||||
continue
|
||||
}
|
||||
file := &dir.Files[loc.fileIdx]
|
||||
|
||||
// CRITICAL: Verify size matches the expected size for this group
|
||||
// Index could have changed between initial grouping and now
|
||||
if file.Size != size {
|
||||
continue
|
||||
}
|
||||
|
||||
// Construct full path
|
||||
fullPath := filepath.Join(loc.dirPath, file.Name)
|
||||
|
||||
// Remove the user scope from path
|
||||
adjustedPath := strings.TrimPrefix(fullPath, opts.combinedPath)
|
||||
if adjustedPath == "" {
|
||||
adjustedPath = "/"
|
||||
}
|
||||
|
||||
resultGroup = append(resultGroup, &indexing.SearchResult{
|
||||
Path: adjustedPath,
|
||||
Type: file.Type,
|
||||
Size: file.Size,
|
||||
Modified: file.ModTime.Format(time.RFC3339),
|
||||
HasPreview: file.HasPreview,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if len(resultGroup) >= 2 {
|
||||
duplicateGroups = append(duplicateGroups, duplicateGroup{
|
||||
Size: size,
|
||||
Count: len(resultGroup),
|
||||
Files: resultGroup,
|
||||
})
|
||||
totalFiles += len(resultGroup)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop processing more size groups if we've hit the limit
|
||||
if totalFiles >= maxTotalFiles {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return duplicateGroups
|
||||
}
|
||||
|
||||
func prepDuplicatesOptions(r *http.Request, d *requestContext) (*duplicatesOptions, error) {
|
||||
source := r.URL.Query().Get("source")
|
||||
scope := r.URL.Query().Get("scope")
|
||||
minSizeMbStr := r.URL.Query().Get("minSizeMb")
|
||||
// Checksum feature disabled due to performance issues with large file systems
|
||||
// Always use fuzzy filename matching instead
|
||||
useChecksum := false // r.URL.Query().Get("useChecksum") == "true"
|
||||
|
||||
// Default minimum size: 1MB
|
||||
minSizeMb := int64(1)
|
||||
if minSizeMbStr != "" {
|
||||
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, ".")
|
||||
|
||||
index := indexing.GetIndex(source)
|
||||
if index == nil {
|
||||
return nil, fmt.Errorf("index not found for source %s", source)
|
||||
}
|
||||
|
||||
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
combinedPath := index.MakeIndexPath(filepath.Join(userscope, searchScope))
|
||||
|
||||
return &duplicatesOptions{
|
||||
source: source,
|
||||
searchScope: searchScope,
|
||||
combinedPath: combinedPath,
|
||||
minSize: minSize,
|
||||
useChecksum: useChecksum,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// groupLocationsByChecksum groups file locations by partial checksum
|
||||
// Works with minimal fileLocation references instead of full SearchResult objects
|
||||
func groupLocationsByChecksum(locations []fileLocation, index *indexing.Index, fileSize int64) [][]fileLocation {
|
||||
checksumMap := make(map[string][]fileLocation)
|
||||
|
||||
// Build checksum groups
|
||||
for _, loc := range locations {
|
||||
// Construct filesystem path for checksum computation
|
||||
// index.Path is the absolute filesystem root, loc.dirPath is index-relative
|
||||
fullPath := filepath.Join(index.Path, loc.dirPath)
|
||||
|
||||
// Get the filename from the index and verify size still matches
|
||||
var fileName string
|
||||
var sizeMatches bool
|
||||
index.ReadOnlyOperation(func() {
|
||||
if dir := index.GetDirectories()[loc.dirPath]; dir != nil {
|
||||
if loc.fileIdx < len(dir.Files) {
|
||||
file := &dir.Files[loc.fileIdx]
|
||||
// CRITICAL: Verify size matches expected size for this group
|
||||
if file.Size == fileSize {
|
||||
fileName = file.Name
|
||||
sizeMatches = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if fileName == "" || !sizeMatches {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(fullPath, fileName)
|
||||
checksum, err := computePartialChecksum(filePath, fileSize)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
checksumMap[checksum] = append(checksumMap[checksum], loc)
|
||||
}
|
||||
|
||||
// Convert map to slice of groups
|
||||
groups := make([][]fileLocation, 0, len(checksumMap))
|
||||
for _, group := range checksumMap {
|
||||
if len(group) >= 2 {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// computePartialChecksum calculates MD5 hash by sampling key portions of a file
|
||||
// This is 10-100x faster than full file checksums while maintaining high accuracy
|
||||
// Strategy:
|
||||
// - Always read first 8KB (header/metadata)
|
||||
// - For files > 24KB: sample middle 8KB and last 8KB
|
||||
// - Total read: ~24KB max per file regardless of file size
|
||||
func computePartialChecksum(path string, size int64) (string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := md5.New()
|
||||
buf := make([]byte, 8192) // 8KB buffer
|
||||
|
||||
// Always read first 8KB (or entire file if smaller)
|
||||
n, err := io.ReadFull(file, buf)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
return "", err
|
||||
}
|
||||
hash.Write(buf[:n])
|
||||
|
||||
// For larger files, sample middle and end portions
|
||||
if size > 24576 { // 24KB
|
||||
// Sample middle 8KB
|
||||
middleOffset := size / 2
|
||||
if _, err := file.Seek(middleOffset, 0); err == nil {
|
||||
n, err := io.ReadFull(file, buf)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
return "", err
|
||||
}
|
||||
hash.Write(buf[:n])
|
||||
}
|
||||
|
||||
// Sample last 8KB
|
||||
endOffset := size - 8192
|
||||
if endOffset > 8192 { // Don't re-read start
|
||||
if _, err := file.Seek(endOffset, 0); err == nil {
|
||||
n, err := io.ReadFull(file, buf)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
return "", err
|
||||
}
|
||||
hash.Write(buf[:n])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%x", hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// groupLocationsByFilename groups file locations by fuzzy filename matching
|
||||
// Works with minimal fileLocation references instead of full SearchResult objects
|
||||
func groupLocationsByFilename(locations []fileLocation, index *indexing.Index, expectedSize int64) [][]fileLocation {
|
||||
if len(locations) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// First, fetch all file metadata we need for comparison in a single lock
|
||||
type fileMetadata struct {
|
||||
name string
|
||||
size int64
|
||||
}
|
||||
metadata := make([]fileMetadata, len(locations))
|
||||
|
||||
index.ReadOnlyOperation(func() {
|
||||
dirs := index.GetDirectories()
|
||||
for i, loc := range locations {
|
||||
if dir := dirs[loc.dirPath]; dir != nil {
|
||||
if loc.fileIdx < len(dir.Files) {
|
||||
file := &dir.Files[loc.fileIdx]
|
||||
// CRITICAL: Only include files that match the expected size
|
||||
// Index could have changed since locations were collected
|
||||
if file.Size == expectedSize {
|
||||
metadata[i] = fileMetadata{
|
||||
name: file.Name,
|
||||
size: file.Size,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Now group by fuzzy filename matching without holding the lock
|
||||
groups := [][]fileLocation{}
|
||||
used := make(map[int]bool)
|
||||
|
||||
for i := 0; i < len(locations); i++ {
|
||||
if used[i] || metadata[i].name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
group := []fileLocation{locations[i]}
|
||||
used[i] = true
|
||||
baseSize := metadata[i].size
|
||||
|
||||
baseName1 := metadata[i].name
|
||||
ext1 := strings.ToLower(filepath.Ext(baseName1))
|
||||
filename1 := normalizeFilename(baseName1)
|
||||
|
||||
for j := i + 1; j < len(locations); j++ {
|
||||
if used[j] || metadata[j].name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// CRITICAL: Ensure exact size match
|
||||
if metadata[j].size != baseSize {
|
||||
continue
|
||||
}
|
||||
|
||||
baseName2 := metadata[j].name
|
||||
ext2 := strings.ToLower(filepath.Ext(baseName2))
|
||||
|
||||
// CRITICAL: Extensions must match exactly (case-insensitive)
|
||||
if ext1 != ext2 {
|
||||
continue
|
||||
}
|
||||
|
||||
filename2 := normalizeFilename(baseName2)
|
||||
|
||||
// Check if filenames are similar enough
|
||||
if filenamesSimilar(filename1, filename2) {
|
||||
group = append(group, locations[j])
|
||||
used[j] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(group) >= 2 {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// normalizeFilename converts filename to lowercase and removes special characters
|
||||
// to make fuzzy matching more effective
|
||||
func normalizeFilename(filename string) string {
|
||||
// Convert to lowercase
|
||||
filename = strings.ToLower(filename)
|
||||
|
||||
// Remove file extension for comparison
|
||||
if idx := strings.LastIndex(filename, "."); idx > 0 {
|
||||
filename = filename[:idx]
|
||||
}
|
||||
|
||||
// Remove non-alphanumeric characters except spaces and hyphens
|
||||
var result strings.Builder
|
||||
for _, r := range filename {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ' || r == '-' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
// filenamesSimilar checks if two normalized filenames are similar enough to be considered duplicates
|
||||
func filenamesSimilar(name1, name2 string) bool {
|
||||
// Exact match
|
||||
if name1 == name2 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Empty names don't match
|
||||
if len(name1) == 0 || len(name2) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Calculate Levenshtein distance
|
||||
distance := levenshteinDistance(name1, name2)
|
||||
maxLen := max(len(name1), len(name2))
|
||||
|
||||
// Require at least 70% similarity
|
||||
similarity := 1.0 - float64(distance)/float64(maxLen)
|
||||
return similarity >= 0.7
|
||||
}
|
||||
|
||||
// levenshteinDistance calculates the edit distance between two strings
|
||||
func levenshteinDistance(s1, s2 string) int {
|
||||
if len(s1) == 0 {
|
||||
return len(s2)
|
||||
}
|
||||
if len(s2) == 0 {
|
||||
return len(s1)
|
||||
}
|
||||
|
||||
// Create matrix
|
||||
matrix := make([][]int, len(s1)+1)
|
||||
for i := range matrix {
|
||||
matrix[i] = make([]int, len(s2)+1)
|
||||
}
|
||||
|
||||
// Initialize first row and column
|
||||
for i := 0; i <= len(s1); i++ {
|
||||
matrix[i][0] = i
|
||||
}
|
||||
for j := 0; j <= len(s2); j++ {
|
||||
matrix[0][j] = j
|
||||
}
|
||||
|
||||
// Fill matrix
|
||||
for i := 1; i <= len(s1); i++ {
|
||||
for j := 1; j <= len(s2); j++ {
|
||||
cost := 0
|
||||
if s1[i-1] != s2[j-1] {
|
||||
cost = 1
|
||||
}
|
||||
|
||||
matrix[i][j] = min(
|
||||
matrix[i-1][j]+1, // deletion
|
||||
matrix[i][j-1]+1, // insertion
|
||||
matrix[i-1][j-1]+cost, // substitution
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[len(s1)][len(s2)]
|
||||
}
|
||||
|
||||
// min returns the minimum of three integers
|
||||
func min(a, b, c int) int {
|
||||
if a < b {
|
||||
if a < c {
|
||||
return a
|
||||
}
|
||||
return c
|
||||
}
|
||||
if b < c {
|
||||
return b
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// max returns the maximum of two integers
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
+27
-24
@@ -35,10 +35,9 @@ func (d dirFS) Open(name string) (fs.File, error) {
|
||||
}
|
||||
|
||||
var (
|
||||
store *bolt.BoltStore
|
||||
config *settings.Settings
|
||||
assetFs fs.FS
|
||||
assetPathPrefix string // "img/icons/" for dev, "public/img/icons/" for embedded
|
||||
store *bolt.BoltStore
|
||||
config *settings.Settings
|
||||
assetFs fs.FS
|
||||
)
|
||||
|
||||
func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete chan struct{}) {
|
||||
@@ -46,23 +45,19 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
config = &settings.Config
|
||||
var err error
|
||||
// Determine filesystem mode and set asset paths
|
||||
if settings.Config.Env.EmbeddedFs {
|
||||
if settings.Env.EmbeddedFs {
|
||||
// Embedded mode: Serve files from the embedded assets
|
||||
assetFs, err = fs.Sub(assets, "embed")
|
||||
if err != nil {
|
||||
logger.Fatal("Could not embed frontend. Does dist exist?")
|
||||
logger.Fatalf("fs.Sub failed: %v", err)
|
||||
}
|
||||
assetPathPrefix = "public/img/icons/"
|
||||
if config.Frontend.LoginIcon == "" {
|
||||
config.Frontend.LoginIcon = "embed/public/img/icons/favicon.svg"
|
||||
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")}
|
||||
assetPathPrefix = "img/icons/"
|
||||
if config.Frontend.LoginIcon == "" {
|
||||
config.Frontend.LoginIcon = "http/dist/img/icons/favicon.svg"
|
||||
}
|
||||
}
|
||||
|
||||
// In development mode, we want to reload the templates on each request.
|
||||
@@ -73,12 +68,12 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
return string(a), err
|
||||
},
|
||||
})
|
||||
if !config.Env.IsDevMode {
|
||||
if !settings.Env.IsDevMode {
|
||||
templates = template.Must(templates.ParseFS(assetFs, "public/index.html"))
|
||||
}
|
||||
templateRenderer = &TemplateRenderer{
|
||||
templates: templates,
|
||||
devMode: config.Env.IsDevMode,
|
||||
devMode: settings.Env.IsDevMode,
|
||||
}
|
||||
|
||||
router := http.NewServeMux()
|
||||
@@ -123,6 +118,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
// Access routes
|
||||
api.HandleFunc("GET /access", withAdmin(accessGetHandler))
|
||||
api.HandleFunc("POST /access", withAdmin(accessPostHandler))
|
||||
api.HandleFunc("PATCH /access", withAdmin(accessPatchHandler))
|
||||
api.HandleFunc("DELETE /access", withAdmin(accessDeleteHandler))
|
||||
api.HandleFunc("GET /access/groups", withAdmin(groupGetHandler))
|
||||
api.HandleFunc("POST /access/group", withAdmin(groupPostHandler))
|
||||
@@ -133,6 +129,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
api.HandleFunc("GET /share/direct", withPermShare(shareDirectDownloadHandler))
|
||||
api.HandleFunc("GET /share", withPermShare(shareGetHandler))
|
||||
api.HandleFunc("POST /share", withPermShare(sharePostHandler))
|
||||
api.HandleFunc("PATCH /share", withPermShare(sharePatchHandler))
|
||||
api.HandleFunc("DELETE /share", withPermShare(shareDeleteHandler))
|
||||
|
||||
// Create API sub-router for public API endpoints
|
||||
@@ -150,6 +147,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
publicAPI.HandleFunc("PUT /resources", withHashFile(publicPutHandler))
|
||||
publicAPI.HandleFunc("DELETE /resources", withHashFile(publicDeleteHandler))
|
||||
publicAPI.HandleFunc("PATCH /resources", withHashFile(publicPatchHandler))
|
||||
publicAPI.HandleFunc("GET /shareinfo", withOrWithoutUser(shareInfoHandler))
|
||||
|
||||
// Settings routes
|
||||
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
|
||||
@@ -168,6 +166,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
api.HandleFunc("GET /onlyoffice/callback", withUser(onlyofficeCallbackHandler))
|
||||
|
||||
api.HandleFunc("GET /search", withUser(searchHandler))
|
||||
api.HandleFunc("GET /duplicates", withUser(duplicatesHandler))
|
||||
// Mount the public API sub-router
|
||||
publicRoutes.Handle("/api/", http.StripPrefix("/api", publicAPI))
|
||||
|
||||
@@ -178,6 +177,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
router.Handle(publicPath+"/", http.StripPrefix(publicPath, publicRoutes))
|
||||
|
||||
// Frontend share route redirect (DEPRECATED - maintain for backwards compatibility)
|
||||
// Playwright tests need updating to remove this redirect.
|
||||
router.HandleFunc(fmt.Sprintf("GET %vshare/", config.Server.BaseURL), withOrWithoutUser(redirectToShare))
|
||||
|
||||
// New frontend share route handler - handle share page and any subpaths
|
||||
@@ -185,14 +185,13 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
|
||||
// Static and index file handlers
|
||||
staticPrefix := config.Server.BaseURL + "static/"
|
||||
router.Handle(staticPrefix, http.StripPrefix(staticPrefix, http.HandlerFunc(staticFilesHandler)))
|
||||
publicRoutes.Handle("GET /static/", http.StripPrefix("/static/", http.HandlerFunc(staticFilesHandler)))
|
||||
publicRoutes.HandleFunc("GET /static/loginIcon", http.HandlerFunc(loginIconHandler))
|
||||
router.Handle(staticPrefix, http.HandlerFunc(staticAssetHandler))
|
||||
publicRoutes.Handle("GET /static/", http.HandlerFunc(staticAssetHandler))
|
||||
|
||||
// Standard browser favicon and manifest routes
|
||||
router.HandleFunc("GET /favicon.ico", http.HandlerFunc(faviconHandler))
|
||||
router.HandleFunc("GET /site.webmanifest", http.HandlerFunc(manifestHandler))
|
||||
router.HandleFunc("GET /manifest.json", http.HandlerFunc(manifestHandler))
|
||||
router.HandleFunc("GET /favicon.ico", 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)
|
||||
@@ -208,9 +207,13 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
var scheme string
|
||||
port := ""
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%v", config.Server.Port),
|
||||
Addr: fmt.Sprintf("%v:%v", config.Server.ListenAddress, config.Server.Port),
|
||||
Handler: muxWithMiddleware(router),
|
||||
}
|
||||
listenAddress := config.Server.ListenAddress
|
||||
if listenAddress == "0.0.0.0" {
|
||||
listenAddress = "localhost"
|
||||
}
|
||||
go func() {
|
||||
// Determine whether to use HTTPS (TLS) or HTTP
|
||||
if config.Server.TLSCert != "" && config.Server.TLSKey != "" {
|
||||
@@ -233,7 +236,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
}
|
||||
|
||||
// Build the full URL with host and port
|
||||
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
|
||||
fullURL := fmt.Sprintf("%s://%s%s%s", scheme, listenAddress, port, config.Server.BaseURL)
|
||||
logger.Infof("Running at : %s", fullURL)
|
||||
|
||||
// Create a TLS listener and serve
|
||||
@@ -252,7 +255,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
}
|
||||
|
||||
// Build the full URL with host and port
|
||||
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
|
||||
fullURL := fmt.Sprintf("%s://%s%s%s", scheme, listenAddress, port, config.Server.BaseURL)
|
||||
logger.Infof("Running at : %s", fullURL)
|
||||
|
||||
// Start HTTP server
|
||||
|
||||
+43
-74
@@ -20,18 +20,14 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// userInfo struct to hold user claims from either UserInfo or ID token
|
||||
// userInfo holds all claims dynamically, plus pre-parsed Groups.
|
||||
type userInfo struct {
|
||||
Name string `json:"name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Sub string `json:"sub"`
|
||||
Phone string `json:"phone_number"`
|
||||
Groups []string `json:"-"` // Handled manually by userInfoUnmarshaller
|
||||
Claims map[string]interface{}
|
||||
Groups []string
|
||||
}
|
||||
|
||||
// userInfoUnmarshaller is a custom unmarshaller that handles configurable groups claim
|
||||
// userInfoUnmarshaller handles unmarshalling all claims dynamically,
|
||||
// while optionally parsing a configurable groups claim.
|
||||
type userInfoUnmarshaller struct {
|
||||
userInfo *userInfo
|
||||
groupsClaim string
|
||||
@@ -39,41 +35,36 @@ type userInfoUnmarshaller struct {
|
||||
|
||||
// UnmarshalJSON implements the json.Unmarshaler interface
|
||||
func (u *userInfoUnmarshaller) UnmarshalJSON(data []byte) error {
|
||||
// First, unmarshal the basic userInfo fields
|
||||
if err := json.Unmarshal(data, u.userInfo); err != nil {
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse the JSON to access the groups claim field
|
||||
var rawData map[string]interface{}
|
||||
if err := json.Unmarshal(data, &rawData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Look for the groups claim using the configured field name
|
||||
if groupsValue, exists := rawData[u.groupsClaim]; exists {
|
||||
switch v := groupsValue.(type) {
|
||||
case []interface{}:
|
||||
// It's already an array, convert to []string
|
||||
groups := make([]string, len(v))
|
||||
for i, group := range v {
|
||||
if str, ok := group.(string); ok {
|
||||
groups[i] = str
|
||||
// Extract groups if configured
|
||||
if u.groupsClaim != "" {
|
||||
if v, ok := raw[u.groupsClaim]; ok {
|
||||
switch val := v.(type) {
|
||||
case []interface{}:
|
||||
groups := make([]string, len(val))
|
||||
for i, g := range val {
|
||||
if s, ok := g.(string); ok {
|
||||
groups[i] = strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
u.userInfo.Groups = groups
|
||||
case string:
|
||||
// It's a string, split by commas
|
||||
if v != "" {
|
||||
u.userInfo.Groups = strings.Split(v, ",")
|
||||
// Trim whitespace from each group
|
||||
for i, group := range u.userInfo.Groups {
|
||||
u.userInfo.Groups[i] = strings.TrimSpace(group)
|
||||
u.userInfo.Groups = groups
|
||||
case string:
|
||||
if val != "" {
|
||||
parts := strings.Split(val, ",")
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
u.userInfo.Groups = parts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
u.userInfo.Claims = raw
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -100,7 +91,7 @@ func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
ClientSecret: oidcCfg.ClientSecret,
|
||||
Endpoint: oidcCfg.Provider.Endpoint(),
|
||||
RedirectURL: fmt.Sprintf("%s%sapi/auth/oidc/callback", origin, config.Server.BaseURL),
|
||||
Scopes: strings.Split(oidcCfg.Scopes, " "),
|
||||
Scopes: strings.Fields(oidcCfg.Scopes),
|
||||
}
|
||||
|
||||
nonce := utils.InsecureRandomIdentifier(16)
|
||||
@@ -157,7 +148,7 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
ClientSecret: oidcCfg.ClientSecret,
|
||||
Endpoint: oidcCfg.Provider.Endpoint(), // Use endpoint from discovered provider
|
||||
RedirectURL: redirectURL, // Use the dynamically determined redirect URL
|
||||
Scopes: strings.Split(oidcCfg.Scopes, " "),
|
||||
Scopes: strings.Fields(oidcCfg.Scopes),
|
||||
}
|
||||
|
||||
// Exchange the authorization code for tokens
|
||||
@@ -186,10 +177,10 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
|
||||
// Verify the ID token
|
||||
// This uses the verifier initialized with the provider's JWKS endpoint and client ID
|
||||
idToken, err := oidcCfg.Verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
idToken, verify_err := oidcCfg.Verifier.Verify(ctx, rawIDToken)
|
||||
if verify_err != nil {
|
||||
// this might not be necessary for certain providers like authentik
|
||||
logger.Debugf("failed to verify ID token: %v. This might be expected, falling back to UserInfo endpoint.", err)
|
||||
logger.Debugf("failed to verify ID token: %v. This might be expected, falling back to UserInfo endpoint.", verify_err)
|
||||
// Verification failed, claimsFromIDToken remains false
|
||||
} else {
|
||||
// Decode the ID token claims using custom unmarshaller
|
||||
@@ -203,30 +194,13 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
|
||||
// Decide if we rely on ID token claims or still need UserInfo
|
||||
// Even if parsing succeeded, if essential claims are missing, use UserInfo
|
||||
switch oidcCfg.UserIdentifier {
|
||||
case "email":
|
||||
if userdata.Email != "" {
|
||||
claimsFromIDToken = true
|
||||
loginUsername = userdata.Email
|
||||
}
|
||||
case "username":
|
||||
if userdata.Username != "" {
|
||||
claimsFromIDToken = true
|
||||
loginUsername = userdata.Username
|
||||
}
|
||||
case "preferred_username":
|
||||
if userdata.PreferredUsername != "" {
|
||||
claimsFromIDToken = true
|
||||
loginUsername = userdata.PreferredUsername
|
||||
}
|
||||
case "phone":
|
||||
if userdata.Phone != "" {
|
||||
claimsFromIDToken = true
|
||||
loginUsername = userdata.Phone
|
||||
}
|
||||
if _, ok := userdata.Claims[oidcCfg.UserIdentifier]; ok {
|
||||
claimsFromIDToken = true
|
||||
}
|
||||
}
|
||||
logger.Debugf("Failed to verify ID token: %v", verify_err)
|
||||
}
|
||||
|
||||
} else {
|
||||
logger.Debug("No ID token found in token response or it was empty. Falling back to UserInfo endpoint.")
|
||||
// claimsFromIDToken remains false
|
||||
@@ -247,22 +221,17 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
logger.Errorf("failed to decode user info from endpoint: %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to decode user info from endpoint: %v", err)
|
||||
}
|
||||
// Decide if we rely on ID token claims or still need UserInfo
|
||||
// Even if parsing succeeded, if essential claims are missing, use UserInfo
|
||||
switch oidcCfg.UserIdentifier {
|
||||
case "email":
|
||||
loginUsername = userdata.Email
|
||||
case "username":
|
||||
loginUsername = userdata.Username
|
||||
case "preferred_username":
|
||||
loginUsername = userdata.PreferredUsername
|
||||
case "phone":
|
||||
loginUsername = userdata.Phone
|
||||
}
|
||||
|
||||
// --- Determine login username dynamically ---
|
||||
if val, ok := userdata.Claims[oidcCfg.UserIdentifier]; ok {
|
||||
if v, ok := val.(string); ok {
|
||||
loginUsername = v
|
||||
}
|
||||
}
|
||||
if loginUsername == "" {
|
||||
logger.Errorf("No valid username found for identifier '%v' in ID token or UserInfo response.", oidcCfg.UserIdentifier)
|
||||
return http.StatusInternalServerError, fmt.Errorf("no valid username found in ID token or UserInfo response from claims")
|
||||
logger.Errorf("No valid username found for identifier '%v' in claims.", oidcCfg.UserIdentifier)
|
||||
return http.StatusInternalServerError, fmt.Errorf("no valid username found for identifier '%v'", oidcCfg.UserIdentifier)
|
||||
}
|
||||
|
||||
// Proceed to log the user in with the OIDC data
|
||||
|
||||
+24
-10
@@ -18,8 +18,11 @@ func TestUserInfoUnmarshaller(t *testing.T) {
|
||||
jsonData: `{"name":"John","email":"john@example.com","groups":["admin","users"]}`,
|
||||
groupsClaim: "groups",
|
||||
expected: userInfo{
|
||||
Name: "John",
|
||||
Email: "john@example.com",
|
||||
Claims: map[string]interface{}{
|
||||
"name": "John",
|
||||
"email": "john@example.com",
|
||||
"groups": []interface{}{"admin","users"},
|
||||
},
|
||||
Groups: []string{"admin", "users"},
|
||||
},
|
||||
},
|
||||
@@ -28,8 +31,11 @@ func TestUserInfoUnmarshaller(t *testing.T) {
|
||||
jsonData: `{"name":"Jane","email":"jane@example.com","roles":["admin","users"]}`,
|
||||
groupsClaim: "roles",
|
||||
expected: userInfo{
|
||||
Name: "Jane",
|
||||
Email: "jane@example.com",
|
||||
Claims: map[string]interface{}{
|
||||
"name": "Jane",
|
||||
"email": "jane@example.com",
|
||||
"roles": []interface{}{"admin","users"},
|
||||
},
|
||||
Groups: []string{"admin", "users"},
|
||||
},
|
||||
},
|
||||
@@ -38,8 +44,11 @@ func TestUserInfoUnmarshaller(t *testing.T) {
|
||||
jsonData: `{"name":"Bob","email":"bob@example.com","groups":"admin, users, guests"}`,
|
||||
groupsClaim: "groups",
|
||||
expected: userInfo{
|
||||
Name: "Bob",
|
||||
Email: "bob@example.com",
|
||||
Claims: map[string]interface{}{
|
||||
"name": "Bob",
|
||||
"email": "bob@example.com",
|
||||
"groups": "admin, users, guests",
|
||||
},
|
||||
Groups: []string{"admin", "users", "guests"},
|
||||
},
|
||||
},
|
||||
@@ -48,8 +57,10 @@ func TestUserInfoUnmarshaller(t *testing.T) {
|
||||
jsonData: `{"name":"Alice","email":"alice@example.com"}`,
|
||||
groupsClaim: "groups",
|
||||
expected: userInfo{
|
||||
Name: "Alice",
|
||||
Email: "alice@example.com",
|
||||
Claims: map[string]interface{}{
|
||||
"name": "Alice",
|
||||
"email": "alice@example.com",
|
||||
},
|
||||
Groups: nil,
|
||||
},
|
||||
},
|
||||
@@ -58,8 +69,11 @@ func TestUserInfoUnmarshaller(t *testing.T) {
|
||||
jsonData: `{"name":"Charlie","email":"charlie@example.com","groups":[]}`,
|
||||
groupsClaim: "groups",
|
||||
expected: userInfo{
|
||||
Name: "Charlie",
|
||||
Email: "charlie@example.com",
|
||||
Claims: map[string]interface{}{
|
||||
"name": "Charlie",
|
||||
"email": "charlie@example.com",
|
||||
"groups": []interface{}{},
|
||||
},
|
||||
Groups: []string{},
|
||||
},
|
||||
},
|
||||
|
||||
+13
-6
@@ -79,7 +79,7 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
Username: d.user.Username,
|
||||
Path: utils.JoinPathAsUnix(userscope, path),
|
||||
Source: source,
|
||||
Metadata: true,
|
||||
AlbumArt: true, // Extract album art for audio previews
|
||||
}, store.Access)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
@@ -116,7 +116,7 @@ func getDirectoryPreview(r *http.Request, d *requestContext, accessStore *access
|
||||
for _, item := range d.fileInfo.Files {
|
||||
// Only use files that should bubble up to folder previews (images, videos, audio)
|
||||
// Exclude text files, office documents, and PDFs
|
||||
if !item.HasPreview || !iteminfo.ShouldBubbleUpToFolderPreview(item) {
|
||||
if !item.HasPreview || !iteminfo.ShouldBubbleUpToFolderPreview(item.ItemInfo) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ func getDirectoryPreview(r *http.Request, d *requestContext, accessStore *access
|
||||
Username: d.user.Username,
|
||||
Path: path,
|
||||
Source: source,
|
||||
Metadata: true,
|
||||
AlbumArt: true, // Extract album art for audio previews
|
||||
}, accessStore)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
@@ -151,9 +151,16 @@ func getDirectoryPreview(r *http.Request, d *requestContext, accessStore *access
|
||||
cancel()
|
||||
|
||||
if previewErr != nil {
|
||||
// File might be corrupted, try next one
|
||||
logger.Debugf("Skipping corrupted preview file in directory '%s': %s (error: %v)",
|
||||
d.fileInfo.Name, item.Name, previewErr)
|
||||
// Skip context errors (timeout or cancellation) - they're not corruption issues
|
||||
if !errors.Is(previewErr, context.Canceled) && !errors.Is(previewErr, context.DeadlineExceeded) {
|
||||
// File might be corrupted, try next one
|
||||
logger.Debugf("Skipping preview file in directory '%s': %s (error: %v)",
|
||||
d.fileInfo.Name, item.Name, previewErr)
|
||||
} else {
|
||||
// if it is a context error, return the error
|
||||
// don't keep trying
|
||||
return nil, previewErr
|
||||
}
|
||||
lastErr = previewErr
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// @Failure 404 {object} map[string]string "Share not found or file not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Failure 501 {object} map[string]string "Browsing disabled for upload shares"
|
||||
// @Router /public/api/share [get]
|
||||
// @Router /public/api/resources [get]
|
||||
func publicGetResourceHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if d.share.ShareType == "upload" {
|
||||
return http.StatusNotImplemented, fmt.Errorf("browsing is disabled for upload shares")
|
||||
@@ -326,7 +326,7 @@ func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
// delete thumbnails
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
|
||||
err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath))
|
||||
err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath), fileInfo.Type == "directory")
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("an error occured while deleting the resource")
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
Source: source,
|
||||
Expand: true,
|
||||
Content: getContent,
|
||||
Metadata: true,
|
||||
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
|
||||
}, store.Access)
|
||||
if err != nil {
|
||||
@@ -183,7 +184,7 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
// delete thumbnails
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
|
||||
err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath))
|
||||
err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath), fileInfo.Type == "directory")
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
}
|
||||
@@ -200,6 +201,7 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
// @Param path query string true "url encoded destination path where to place the files inside the destination source, a directory must end in / to create a directory"
|
||||
// @Param source query string true "Name for the desired filebrowser destination source name, default is used if not provided"
|
||||
// @Param override query bool false "Override existing file if true"
|
||||
// @Param isDir query bool false "Explicitly specify if the resource is a directory"
|
||||
// @Success 200 "Resource created successfully"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 404 {object} map[string]string "Resource not found"
|
||||
@@ -242,7 +244,9 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
path = utils.JoinPathAsUnix(userscope, unescapedPath)
|
||||
}
|
||||
|
||||
isDir := strings.HasSuffix(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, "/")
|
||||
fileOpts := utils.FileOptions{
|
||||
Username: d.user.Username,
|
||||
Path: path,
|
||||
@@ -617,7 +621,7 @@ func patchAction(ctx context.Context, params patchActionParams) error {
|
||||
|
||||
// delete thumbnails
|
||||
preview.DelThumbs(ctx, *fileInfo)
|
||||
return files.MoveResource(params.isSrcDir, params.srcIndex, params.dstIndex, params.src, params.dst, store.Share)
|
||||
return files.MoveResource(params.isSrcDir, params.srcIndex, params.dstIndex, params.src, params.dst, store.Share, store.Access)
|
||||
default:
|
||||
return fmt.Errorf("unsupported action %s: %w", params.action, errors.ErrInvalidRequestParams)
|
||||
}
|
||||
|
||||
+45
-18
@@ -11,6 +11,15 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
)
|
||||
|
||||
type searchOptions struct {
|
||||
query string
|
||||
source string
|
||||
searchScope string
|
||||
combinedPath string
|
||||
sessionId string
|
||||
largest bool
|
||||
}
|
||||
|
||||
// searchHandler handles search requests for files based on the provided query.
|
||||
//
|
||||
// This endpoint processes a search query, retrieves relevant file paths, and
|
||||
@@ -57,39 +66,57 @@ import (
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
// @Router /api/search [get]
|
||||
func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
searchOptions, err := prepSearchOptions(r, d)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
index := indexing.GetIndex(searchOptions.source)
|
||||
if index == nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("index not found for source %s", searchOptions.source)
|
||||
}
|
||||
|
||||
// Perform the search using the provided query and user scope
|
||||
response := index.Search(searchOptions.query, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, indexing.DefaultSearchResults)
|
||||
// Remove the user scope from the path (modifying in place is safe - these are fresh allocations)
|
||||
for _, result := range response {
|
||||
result.Path = strings.TrimPrefix(result.Path, searchOptions.combinedPath)
|
||||
if result.Path == "" {
|
||||
result.Path = "/"
|
||||
}
|
||||
}
|
||||
return renderJSON(w, r, response)
|
||||
}
|
||||
|
||||
func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, error) {
|
||||
query := r.URL.Query().Get("query")
|
||||
source := r.URL.Query().Get("source")
|
||||
scope := r.URL.Query().Get("scope")
|
||||
largest := r.URL.Query().Get("largest") == "true"
|
||||
unencodedScope, err := url.PathUnescape(scope)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
return nil, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
if len(query) < settings.Config.Server.MinSearchLength {
|
||||
return http.StatusBadRequest, fmt.Errorf("query is too short, minimum length is %d", settings.Config.Server.MinSearchLength)
|
||||
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, ".")
|
||||
// Retrieve the User-Agent and X-Auth headers from the request
|
||||
sessionId := r.Header.Get("SessionId")
|
||||
index := indexing.GetIndex(source)
|
||||
if index == nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("index not found for source %s", source)
|
||||
return nil, fmt.Errorf("index not found for source %s", source)
|
||||
}
|
||||
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
return nil, err
|
||||
}
|
||||
combinedPath := index.MakeIndexPath(filepath.Join(userscope, searchScope))
|
||||
// Perform the search using the provided query and user scope
|
||||
response := index.Search(query, combinedPath, sessionId)
|
||||
for i := range response {
|
||||
// Remove the user scope from the path
|
||||
response[i].Path = strings.TrimPrefix(response[i].Path, combinedPath)
|
||||
if response[i].Path == "" {
|
||||
response[i].Path = "/"
|
||||
}
|
||||
}
|
||||
// trim user scope from each result path
|
||||
// Set the Content-Type header to application/json
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
return renderJSON(w, r, response)
|
||||
return &searchOptions{
|
||||
query: query,
|
||||
source: source,
|
||||
searchScope: searchScope,
|
||||
combinedPath: combinedPath,
|
||||
sessionId: sessionId,
|
||||
largest: largest,
|
||||
}, nil
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func settingsConfigHandler(w http.ResponseWriter, r *http.Request, d *requestCon
|
||||
var embeddedYaml []byte
|
||||
var readErr error
|
||||
|
||||
if config.Env.EmbeddedFs {
|
||||
if settings.Env.EmbeddedFs {
|
||||
embeddedYaml, readErr = assets.ReadFile("embed/config.generated.yaml")
|
||||
} else {
|
||||
embeddedYaml, readErr = os.ReadFile("http/dist/config.generated.yaml")
|
||||
|
||||
+115
-13
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -19,29 +20,58 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/share"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// ShareResponse represents a share with computed username field and download URL
|
||||
type ShareResponse struct {
|
||||
*share.Link
|
||||
Source string `json:"source"` // Override embedded field to show source name
|
||||
Username string `json:"username,omitempty"`
|
||||
DownloadURL string `json:"downloadURL,omitempty"`
|
||||
PathExists bool `json:"pathExists"`
|
||||
}
|
||||
|
||||
// convertToShareResponse converts shares to response format with usernames
|
||||
func convertToShareResponse(r *http.Request, shares []*share.Link) ([]*ShareResponse, error) {
|
||||
responses := make([]*ShareResponse, len(shares))
|
||||
for i, s := range shares {
|
||||
// convertToFrontendShareResponse converts shares to response format with usernames
|
||||
func convertToFrontendShareResponse(r *http.Request, shares []*share.Link) ([]*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
|
||||
}
|
||||
responses[i] = &ShareResponse{
|
||||
|
||||
// Get source info to convert path to name for frontend
|
||||
sourceInfo, ok := config.Server.SourceMap[s.Source]
|
||||
if !ok {
|
||||
// Source not found - likely corrupted data. Try to find by name as fallback
|
||||
sourceInfo, ok = config.Server.NameToSource[s.Source]
|
||||
if !ok {
|
||||
// Still not found - this share is invalid, skip it and delete it
|
||||
logger.Error("Invalid share - deleting", "hash", s.Hash, "source", s.Source)
|
||||
_ = store.Share.Delete(s.Hash) // Best effort delete
|
||||
continue
|
||||
}
|
||||
// Found by name - this is corrupted data, fix it
|
||||
logger.Warning("Share has corrupted source - fixing", "hash", s.Hash, "from", s.Source, "to", sourceInfo.Path)
|
||||
s.Source = sourceInfo.Path
|
||||
_ = store.Share.Save(s) // Best effort fix
|
||||
}
|
||||
|
||||
// Check if the path exists on the filesystem
|
||||
pathExists := utils.CheckPathExists(filepath.Join(sourceInfo.Path, s.Path))
|
||||
|
||||
s.CommonShare.HasPassword = s.HasPassword()
|
||||
|
||||
// Create response with source name (overrides the embedded Link's source field)
|
||||
responses = append(responses, &ShareResponse{
|
||||
Link: s,
|
||||
Source: sourceInfo.Name, // Override to show source name instead of backend path
|
||||
Username: username,
|
||||
DownloadURL: getDownloadURL(r, s.Hash),
|
||||
}
|
||||
PathExists: pathExists,
|
||||
})
|
||||
}
|
||||
return responses, nil
|
||||
}
|
||||
@@ -67,7 +97,7 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
shares = utils.NonNilSlice(shares)
|
||||
sharesWithUsernames, err := convertToShareResponse(r, shares)
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, shares)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -102,15 +132,17 @@ func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
scopePath := utils.JoinPathAsUnix(userscope, path)
|
||||
scopePath = utils.AddTrailingSlashIfNotExists(scopePath)
|
||||
s, err := store.Share.Gets(scopePath, sourceInfo.Path, d.user.ID)
|
||||
if err == errors.ErrNotExist || len(s) == 0 {
|
||||
return renderJSON(w, r, []*ShareResponse{})
|
||||
}
|
||||
// DownloadURL will be set in convertToShareResponse
|
||||
// DownloadURL will be set in convertToFrontendShareResponse
|
||||
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error getting share info from server")
|
||||
}
|
||||
sharesWithUsernames, err := convertToShareResponse(r, s)
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, s)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -143,6 +175,53 @@ func shareDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
// sharePatchHandler updates a share link's path.
|
||||
// @Summary Update share link path
|
||||
// @Description Updates the path for a specific share link identified by hash
|
||||
// @Tags Shares
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{hash=string,path=string} true "Hash and new path"
|
||||
// @Success 200 {object} ShareResponse "Updated share link"
|
||||
// @Failure 400 {object} map[string]string "Bad request - missing or invalid parameters"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/share [patch]
|
||||
func sharePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
var body struct {
|
||||
Hash string `json:"hash"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("failed to decode body: %w", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if body.Hash == "" || body.Path == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("hash and path are required")
|
||||
}
|
||||
|
||||
// Update the share path
|
||||
err := store.Share.UpdateSharePath(body.Hash, body.Path)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
// Get the updated share
|
||||
updatedShare, err := store.Share.GetByHash(body.Hash)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{updatedShare})
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return renderJSON(w, r, sharesWithUsernames[0])
|
||||
}
|
||||
|
||||
// sharePostHandler creates a new share link.
|
||||
// @Summary Create a share link
|
||||
// @Description Creates a new share link with an optional expiration time and password protection.
|
||||
@@ -274,6 +353,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
scopePath := utils.JoinPathAsUnix(userscope, body.Path)
|
||||
scopePath = utils.AddTrailingSlashIfNotExists(scopePath)
|
||||
body.Path = scopePath
|
||||
// validate path exists as file or folder
|
||||
_, exists := idx.GetReducedMetadata(body.Path, true) // true to check if it exists
|
||||
@@ -295,11 +375,12 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
PasswordHash: stringHash,
|
||||
Token: token,
|
||||
CommonShare: body.CommonShare,
|
||||
Version: 1, // Set version for new shares
|
||||
}
|
||||
if err = store.Share.Save(s); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
sharesWithUsernames, err := convertToShareResponse(r, []*share.Link{s})
|
||||
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{s})
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -437,9 +518,10 @@ func shareDirectDownloadHandler(w http.ResponseWriter, r *http.Request, d *reque
|
||||
|
||||
// No matching existing share found, create a new one
|
||||
shareLink := &share.Link{
|
||||
Expire: expire,
|
||||
UserID: d.user.ID,
|
||||
Hash: secureHash,
|
||||
Expire: expire,
|
||||
UserID: d.user.ID,
|
||||
Hash: secureHash,
|
||||
Version: 1, // Set version for new shares
|
||||
CommonShare: share.CommonShare{
|
||||
Path: scopePath,
|
||||
Source: idx.Path,
|
||||
@@ -491,6 +573,26 @@ func getDownloadURL(r *http.Request, hash string) string {
|
||||
return downloadURL
|
||||
}
|
||||
|
||||
// shareInfoHandler retrieves share information by hash.
|
||||
// @Summary Get share information by hash
|
||||
// @Description Returns information about a share link based on its hash. This endpoint is publicly accessible and can be used with or without authentication.
|
||||
// @Tags Shares
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param hash query string true "Hash of the share link"
|
||||
// @Success 200 {object} share.CommonShare "Share information"
|
||||
// @Failure 404 {object} map[string]string "Share hash not found"
|
||||
// @Router /public/api/shareinfo [get]
|
||||
func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
hash := r.URL.Query().Get("hash")
|
||||
// Get the file link by hash
|
||||
commonShare, err := store.Share.GetCommonShareByHash(hash)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("share hash not found")
|
||||
}
|
||||
return renderJSON(w, r, commonShare)
|
||||
}
|
||||
|
||||
func getSharePasswordHash(body share.CreateBody) (data []byte, statuscode int, err error) {
|
||||
if body.Password == "" {
|
||||
return nil, 0, nil
|
||||
|
||||
+108
-233
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
@@ -45,7 +46,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
versionString := ""
|
||||
commitSHAString := ""
|
||||
externalLinks := config.Frontend.ExternalLinks
|
||||
if config.Env.IsPlaywright {
|
||||
if settings.Env.IsPlaywright {
|
||||
versionString = version.Version
|
||||
commitSHAString = version.CommitSHA
|
||||
}
|
||||
@@ -56,7 +57,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
}
|
||||
versionString = version.Version
|
||||
commitSHAString = version.CommitSHA
|
||||
} else if !config.Env.IsPlaywright {
|
||||
} else if !settings.Env.IsPlaywright {
|
||||
newExternalLinks := []settings.ExternalLink{}
|
||||
// remove version and commit SHA from external links
|
||||
for _, link := range externalLinks {
|
||||
@@ -74,115 +75,38 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
title := config.Frontend.Name
|
||||
|
||||
// Use custom favicon if configured and validated, otherwise fall back to default
|
||||
var favicon string
|
||||
if config.Frontend.Favicon != "" {
|
||||
favicon = staticURL + "/favicon"
|
||||
} else {
|
||||
// Default favicon
|
||||
favicon = staticURL + "/img/icons/favicon-256x256.png"
|
||||
}
|
||||
favicon := staticURL + "/favicon"
|
||||
data := make(map[string]interface{})
|
||||
shareProps := map[string]interface{}{
|
||||
"isShare": false,
|
||||
"isValid": false,
|
||||
"banner": "",
|
||||
"title": "",
|
||||
"quickDownload": false,
|
||||
"description": "",
|
||||
"themeColor": "",
|
||||
"disableThumbnails": false,
|
||||
"viewMode": "list",
|
||||
"disableFileViewer": false,
|
||||
"disableShareCard": false,
|
||||
"disableSidebar": false,
|
||||
"isPasswordProtected": false,
|
||||
"hash": "",
|
||||
}
|
||||
disableNavButtons := settings.Config.Frontend.DisableNavButtons
|
||||
if d.share != nil {
|
||||
shareProps["isShare"] = true
|
||||
shareProps["isValid"] = d.shareValid
|
||||
shareProps["hash"] = d.share.Hash
|
||||
|
||||
if d.shareValid {
|
||||
shareProps["singleFileShare"] = d.share.IsSingleFileShare()
|
||||
disableNavButtons = disableNavButtons || d.share.HideNavButtons
|
||||
shareProps["viewMode"] = d.share.ViewMode
|
||||
shareProps["banner"] = d.share.Banner
|
||||
shareProps["title"] = d.share.Title
|
||||
shareProps["description"] = d.share.Description
|
||||
shareProps["themeColor"] = d.share.ThemeColor
|
||||
shareProps["quickDownload"] = d.share.QuickDownload
|
||||
shareProps["disableThumbnails"] = d.share.DisableThumbnails
|
||||
shareProps["disableFileViewer"] = d.share.DisableFileViewer
|
||||
shareProps["disableShareCard"] = d.share.DisableShareCard
|
||||
shareProps["disableSidebar"] = d.share.DisableSidebar
|
||||
shareProps["isPasswordProtected"] = d.share.HasPassword()
|
||||
shareProps["downloadURL"] = getDownloadURL(r, d.share.Hash)
|
||||
shareProps["enforceDarkLightMode"] = d.share.EnforceDarkLightMode
|
||||
shareProps["viewMode"] = d.share.ViewMode
|
||||
shareProps["enableOnlyOffice"] = d.share.EnableOnlyOffice
|
||||
shareProps["shareType"] = utils.Ternary(d.share.ShareType == "", "normal", d.share.ShareType)
|
||||
shareProps["perUserDownloadLimit"] = d.share.PerUserDownloadLimit
|
||||
shareProps["extractEmbeddedSubtitles"] = d.share.ExtractEmbeddedSubtitles
|
||||
shareProps["disableDownload"] = d.share.DisableDownload
|
||||
shareProps["allowCreate"] = d.share.AllowCreate
|
||||
shareProps["allowModify"] = d.share.AllowModify
|
||||
shareProps["allowDelete"] = d.share.AllowDelete
|
||||
shareProps["allowReplacements"] = d.share.AllowReplacements
|
||||
shareProps["downloadsLimit"] = d.share.DownloadsLimit
|
||||
shareProps["shareTheme"] = d.share.ShareTheme
|
||||
shareProps["disableAnonymous"] = d.share.DisableAnonymous
|
||||
shareProps["maxBandwidth"] = d.share.MaxBandwidth
|
||||
shareProps["keepAfterExpiration"] = d.share.KeepAfterExpiration
|
||||
shareProps["allowedUsernames"] = d.share.AllowedUsernames
|
||||
shareProps["hideNavButtons"] = d.share.HideNavButtons
|
||||
|
||||
// Additional computed properties from extended.go
|
||||
shareProps["isPermanent"] = d.share.IsPermanent()
|
||||
shareProps["fileExtension"] = d.share.GetFileExtension()
|
||||
shareProps["fileName"] = d.share.GetFileName()
|
||||
if d.share.Favicon != "" {
|
||||
if strings.HasPrefix(d.share.Favicon, "http") {
|
||||
data["favicon"] = d.share.Favicon
|
||||
} else {
|
||||
data["favicon"] = staticURL + "/" + d.share.Favicon
|
||||
}
|
||||
}
|
||||
if d.share.Description != "" {
|
||||
description = d.share.Description
|
||||
}
|
||||
if d.share.Title != "" {
|
||||
title = d.share.Title
|
||||
}
|
||||
if d.share.ShareTheme != "" {
|
||||
theme, ok := config.Frontend.Styling.CustomThemeOptions[d.share.ShareTheme]
|
||||
if ok {
|
||||
userSelectedTheme = theme.CssRaw
|
||||
}
|
||||
}
|
||||
if d.share.ShareTheme != "" {
|
||||
theme, ok := config.Frontend.Styling.CustomThemeOptions[d.share.ShareTheme]
|
||||
if ok {
|
||||
userSelectedTheme = theme.CssRaw
|
||||
}
|
||||
if d.share != nil && d.shareValid {
|
||||
if d.share.Favicon != "" {
|
||||
if strings.HasPrefix(d.share.Favicon, "http") {
|
||||
favicon = d.share.Favicon
|
||||
} else {
|
||||
favicon = staticURL + "/" + d.share.Favicon
|
||||
}
|
||||
}
|
||||
|
||||
// base url could be different for routes behind proxy
|
||||
data["staticURL"] = staticURL
|
||||
// Use custom favicon for shares too if configured
|
||||
if config.Frontend.Favicon != "" {
|
||||
data["favicon"] = staticURL + "/favicon"
|
||||
} else {
|
||||
data["favicon"] = staticURL + "/img/icons/favicon-256x256.png"
|
||||
if d.share.Description != "" {
|
||||
description = d.share.Description
|
||||
}
|
||||
if d.share.Title != "" {
|
||||
title = d.share.Title
|
||||
}
|
||||
if d.share.ShareTheme != "" {
|
||||
theme, ok := config.Frontend.Styling.CustomThemeOptions[d.share.ShareTheme]
|
||||
if ok {
|
||||
userSelectedTheme = theme.CssRaw
|
||||
}
|
||||
}
|
||||
if d.share.ShareTheme != "" {
|
||||
theme, ok := config.Frontend.Styling.CustomThemeOptions[d.share.ShareTheme]
|
||||
if ok {
|
||||
userSelectedTheme = theme.CssRaw
|
||||
}
|
||||
}
|
||||
data["winIcon"] = staticURL + "/img/icons/mstile-144x144.png"
|
||||
data["appIcon"] = staticURL + "/img/icons/android-chrome-256x256.png"
|
||||
}
|
||||
// Set login icon URL
|
||||
loginIcon := staticURL + "/loginIcon"
|
||||
|
||||
data["htmlVars"] = map[string]interface{}{
|
||||
"title": title,
|
||||
"customCSS": config.Frontend.Styling.CustomCSSRaw,
|
||||
@@ -213,16 +137,15 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
"externalLinks": externalLinks,
|
||||
"externalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
|
||||
"onlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
|
||||
"sourceCount": len(config.Server.SourceMap),
|
||||
"oidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
|
||||
"proxyAvailable": config.Auth.Methods.ProxyAuth.Enabled,
|
||||
"passwordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
|
||||
"mediaAvailable": config.Integrations.Media.FfmpegPath != "",
|
||||
"muPdfAvailable": config.Env.MuPdfAvailable,
|
||||
"mediaAvailable": settings.MediaEnabled(),
|
||||
"muPdfAvailable": settings.Env.MuPdfAvailable,
|
||||
"updateAvailable": utils.GetUpdateAvailableUrl(),
|
||||
"disableNavButtons": disableNavButtons,
|
||||
"userSelectableThemes": config.Frontend.Styling.CustomThemeOptions,
|
||||
"enableHeicConversion": config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview],
|
||||
"enableHeicConversion": config.Integrations.Media.Convert.ImagePreview[settings.HEICImagePreview] && settings.MediaEnabled(),
|
||||
"eventBasedThemes": !config.Frontend.Styling.DisableEventBasedThemes,
|
||||
"loginIcon": loginIcon,
|
||||
}
|
||||
@@ -232,14 +155,9 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error marshaling globalVars: %w", err)
|
||||
}
|
||||
shareVarsJSON, err := json.Marshal(shareProps)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("error marshaling shareVars: %w", err)
|
||||
}
|
||||
|
||||
// Replace with JSON strings for direct template usage
|
||||
data["globalVars"] = string(globalVarsJSON)
|
||||
data["shareVars"] = string(shareVarsJSON)
|
||||
|
||||
// Render the template with global variables
|
||||
if err := templateRenderer.Render(w, file, data); err != nil {
|
||||
@@ -248,149 +166,106 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
func loginIconHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// setContentType sets the appropriate Content-Type header based on file extension
|
||||
func setContentType(w http.ResponseWriter, path string) {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".js":
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
case ".css":
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
case ".svg":
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
case ".png":
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
case ".jpg", ".jpeg":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
case ".gif":
|
||||
w.Header().Set("Content-Type", "image/gif")
|
||||
case ".webp":
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
case ".ico":
|
||||
w.Header().Set("Content-Type", "image/x-icon")
|
||||
case ".woff2":
|
||||
w.Header().Set("Content-Type", "font/woff2")
|
||||
case ".webmanifest":
|
||||
w.Header().Set("Content-Type", "application/manifest+json")
|
||||
case ".json":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
}
|
||||
}
|
||||
|
||||
// staticAssetHandler serves static assets exactly as the frontend build produces them
|
||||
func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
|
||||
const maxAge = 86400 // 1 day
|
||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%v", maxAge))
|
||||
w.Header().Set("Content-Security-Policy", `default-src 'self'; style-src 'unsafe-inline';`)
|
||||
|
||||
// Handle custom login icon if configured
|
||||
if config.Frontend.LoginIcon != "" {
|
||||
// Check if it's a file path (not embedded)
|
||||
if _, err := fs.Stat(assetFs, config.Frontend.LoginIcon); err == nil {
|
||||
// Serve from embedded/dist filesystem
|
||||
fileContents, err := fs.ReadFile(assetFs, config.Frontend.LoginIcon)
|
||||
if err == nil {
|
||||
// Set content type based on file extension
|
||||
lowerPath := strings.ToLower(config.Frontend.LoginIcon)
|
||||
if strings.HasSuffix(lowerPath, ".svg") {
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
} else if strings.HasSuffix(lowerPath, ".png") {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
} else if strings.HasSuffix(lowerPath, ".jpg") || strings.HasSuffix(lowerPath, ".jpeg") {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
} else if strings.HasSuffix(lowerPath, ".gif") {
|
||||
w.Header().Set("Content-Type", "image/gif")
|
||||
} else if strings.HasSuffix(lowerPath, ".webp") {
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
} else if strings.HasSuffix(lowerPath, ".ico") {
|
||||
w.Header().Set("Content-Type", "image/x-icon")
|
||||
}
|
||||
_, err = w.Write(fileContents)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Strip baseURL and /static/ prefix to get clean asset path
|
||||
path := r.URL.Path
|
||||
path = strings.TrimPrefix(path, config.Server.BaseURL) // Remove /testing/ or other baseURL
|
||||
path = strings.TrimPrefix(path, "/") // Remove leading slash FIRST
|
||||
path = strings.TrimPrefix(path, "static/") // Then remove static/ prefix
|
||||
|
||||
// Handle special routes that need path mapping
|
||||
var assetPath string
|
||||
switch path {
|
||||
case "favicon.ico", "favicon":
|
||||
// Handle custom favicon from filesystem
|
||||
if settings.Env.FaviconIsCustom {
|
||||
http.ServeFile(w, r, settings.Env.FaviconPath)
|
||||
return
|
||||
}
|
||||
// Use embedded default
|
||||
assetPath = settings.Env.FaviconEmbeddedPath
|
||||
case "manifest.json":
|
||||
assetPath = "img/icons/manifest.json"
|
||||
case "site.webmanifest":
|
||||
assetPath = "img/icons/site.webmanifest"
|
||||
case "loginIcon":
|
||||
// Handle custom login icon from filesystem
|
||||
if settings.Env.LoginIconIsCustom {
|
||||
http.ServeFile(w, r, settings.Env.LoginIconPath)
|
||||
return
|
||||
}
|
||||
// Use embedded default
|
||||
assetPath = settings.Env.LoginIconEmbeddedPath
|
||||
default:
|
||||
assetPath = path
|
||||
}
|
||||
|
||||
// Fallback to default favicon icon if custom login icon not found
|
||||
// Try both paths to support embedded and dev modes
|
||||
defaultIconPaths := []string{
|
||||
"img/icons/favicon-256x256.png", // Dev mode path
|
||||
"public/img/icons/favicon-256x256.png", // Embedded mode path
|
||||
}
|
||||
// Try gzipped version first for files that may be compressed
|
||||
var fileContents []byte
|
||||
var err error
|
||||
|
||||
for _, defaultIconPath := range defaultIconPaths {
|
||||
fileContents, err := fs.ReadFile(assetFs, defaultIconPath)
|
||||
// Check if gzipped version exists
|
||||
if strings.HasSuffix(assetPath, ".js") || strings.HasSuffix(assetPath, ".woff2") {
|
||||
gzPath := assetPath + ".gz"
|
||||
fileContents, err = fs.ReadFile(assetFs, gzPath)
|
||||
if err == nil {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
// Gzipped version exists, serve it
|
||||
setContentType(w, assetPath)
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
_, err = w.Write(fileContents)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If even default icon fails, return 404
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func faviconHandler(w http.ResponseWriter, r *http.Request) {
|
||||
const maxAge = 86400 // 1 day
|
||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%v", maxAge))
|
||||
|
||||
// Try to serve custom favicon if configured
|
||||
if config.Frontend.Favicon != "" {
|
||||
http.ServeFile(w, r, config.Frontend.Favicon)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve default favicon.ico using path set at startup
|
||||
iconPath := assetPathPrefix + "favicon.ico"
|
||||
fileContents, err := fs.ReadFile(assetFs, iconPath)
|
||||
if err == nil {
|
||||
w.Header().Set("Content-Type", "image/x-icon")
|
||||
_, err = w.Write(fileContents)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
func manifestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
const maxAge = 86400 // 1 day
|
||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%v", maxAge))
|
||||
w.Header().Set("Content-Type", "application/manifest+json")
|
||||
|
||||
// Read manifest using path set at startup
|
||||
manifestPath := assetPathPrefix + "site.webmanifest"
|
||||
fileContents, err := fs.ReadFile(assetFs, manifestPath)
|
||||
// Serve uncompressed version
|
||||
fileContents, err = fs.ReadFile(assetFs, assetPath)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Replace relative paths with baseURL-aware paths
|
||||
manifestStr := string(fileContents)
|
||||
baseURL := config.Server.BaseURL
|
||||
staticURL := baseURL + "public/static/"
|
||||
|
||||
// Update the manifest to use the correct baseURL
|
||||
manifestStr = strings.ReplaceAll(manifestStr, `"start_url": "/"`, fmt.Sprintf(`"start_url": "%s"`, baseURL))
|
||||
manifestStr = strings.ReplaceAll(manifestStr, `"src": "img/icons/`, fmt.Sprintf(`"src": "%simg/icons/`, staticURL))
|
||||
|
||||
_, err = w.Write([]byte(manifestStr))
|
||||
setContentType(w, assetPath)
|
||||
_, err = w.Write(fileContents)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func staticFilesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
const maxAge = 86400 // 1 day
|
||||
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%v", maxAge))
|
||||
w.Header().Set("Content-Security-Policy", `default-src 'self'; style-src 'unsafe-inline';`)
|
||||
|
||||
// Handle custom favicon if configured and requested
|
||||
if r.URL.Path == "favicon" && config.Frontend.Favicon != "" {
|
||||
http.ServeFile(w, r, config.Frontend.Favicon)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle site.webmanifest with proper content type
|
||||
if strings.HasSuffix(r.URL.Path, "site.webmanifest") || strings.HasSuffix(r.URL.Path, "manifest.json") {
|
||||
w.Header().Set("Content-Type", "application/manifest+json")
|
||||
}
|
||||
|
||||
adjustedCompressed := r.URL.Path + ".gz"
|
||||
if strings.HasSuffix(r.URL.Path, ".js") {
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8") // Set the correct MIME type for JavaScript files
|
||||
}
|
||||
// Check if the gzipped version of the file exists
|
||||
fileContents, err := fs.ReadFile(assetFs, adjustedCompressed)
|
||||
if err == nil {
|
||||
w.Header().Set("Content-Encoding", "gzip") // Let the browser know the file is compressed
|
||||
status, err := w.Write(fileContents) // Write the gzipped file content to the response
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(status), status)
|
||||
}
|
||||
} else {
|
||||
// Otherwise, serve the regular file
|
||||
http.FileServer(http.FS(assetFs)).ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func indexHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
|
||||
@@ -91,6 +91,7 @@ func prepForFrontend(u *users.User) {
|
||||
u.TOTPSecret = ""
|
||||
u.TOTPNonce = ""
|
||||
u.Scopes = settings.ConvertToFrontendScopes(u.Scopes)
|
||||
u.SidebarLinks = settings.ConvertToFrontendSidebarLinks(u.SidebarLinks)
|
||||
}
|
||||
|
||||
// userDeleteHandler deletes a user by ID.
|
||||
@@ -178,11 +179,10 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// @Description Updates the details of a user identified by ID.
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id query string false "user ID to update"
|
||||
// @Param id query string false "usename to update"
|
||||
// @Param data body users.User true "User data to update"
|
||||
// @Success 200 {object} users.User "Updated user details"
|
||||
// @Success 204 "No Content - User updated successfully"
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||
@@ -234,7 +234,5 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
// Return the updated user (with the password hidden) as JSON response
|
||||
req.User.Password = ""
|
||||
return renderJSON(w, r, req.User)
|
||||
return http.StatusNoContent, nil
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package indexing
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
)
|
||||
|
||||
// setupTestIndexForDeletion creates a test index with mock data for deletion testing
|
||||
func setupTestIndexForDeletion(t *testing.T) *Index {
|
||||
t.Helper()
|
||||
|
||||
// Create index with mock data
|
||||
idx := &Index{
|
||||
Source: settings.Source{
|
||||
Name: "test_delete",
|
||||
Path: "/mock/path",
|
||||
Config: settings.SourceConfig{
|
||||
DisableIndexing: false,
|
||||
},
|
||||
},
|
||||
Directories: make(map[string]*iteminfo.FileInfo),
|
||||
DirectoriesLedger: make(map[string]struct{}),
|
||||
FoundHardLinks: make(map[string]uint64),
|
||||
processedInodes: make(map[uint64]struct{}),
|
||||
mock: true, // Enable mock mode
|
||||
}
|
||||
|
||||
// Create mock directory structure with predictable sizes
|
||||
idx.Directories["/"] = &iteminfo.FileInfo{
|
||||
Path: "/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "/",
|
||||
Type: "directory",
|
||||
Size: 1000, // Total: 100 + 200 + 700 (subdir)
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file1.txt", Size: 100}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file2.txt", Size: 200}},
|
||||
},
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "subdir", Type: "directory", Size: 700},
|
||||
},
|
||||
}
|
||||
|
||||
idx.Directories["/subdir/"] = &iteminfo.FileInfo{
|
||||
Path: "/subdir/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "subdir",
|
||||
Type: "directory",
|
||||
Size: 700, // 300 + 400 (deepdir)
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file3.txt", Size: 300}},
|
||||
},
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "deepdir", Type: "directory", Size: 400},
|
||||
},
|
||||
}
|
||||
|
||||
idx.Directories["/subdir/deepdir/"] = &iteminfo.FileInfo{
|
||||
Path: "/subdir/deepdir/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "deepdir",
|
||||
Type: "directory",
|
||||
Size: 400,
|
||||
},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file4.txt", Size: 400}},
|
||||
},
|
||||
}
|
||||
|
||||
idx.DirectoriesLedger["/"] = struct{}{}
|
||||
idx.DirectoriesLedger["/subdir/"] = struct{}{}
|
||||
idx.DirectoriesLedger["/subdir/deepdir/"] = struct{}{}
|
||||
|
||||
return idx
|
||||
}
|
||||
|
||||
func TestDeleteMetadata_RecursiveDirectoryCleanup(t *testing.T) {
|
||||
idx := setupTestIndexForDeletion(t)
|
||||
|
||||
// Delete a directory recursively
|
||||
idx.DeleteMetadata("/subdir/deepdir/", true, true)
|
||||
|
||||
// Verify it was removed from Directories
|
||||
if _, exists := idx.Directories["/subdir/deepdir/"]; exists {
|
||||
t.Error("Directory '/subdir/deepdir/' should have been removed from Directories")
|
||||
}
|
||||
|
||||
// Verify it was removed from DirectoriesLedger
|
||||
if _, exists := idx.DirectoriesLedger["/subdir/deepdir/"]; exists {
|
||||
t.Error("Directory '/subdir/deepdir/' should have been removed from DirectoriesLedger")
|
||||
}
|
||||
|
||||
// Verify parent still exists
|
||||
if _, exists := idx.Directories["/subdir/"]; !exists {
|
||||
t.Error("Parent directory '/subdir/' should still exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMetadata_RecursiveWithSubdirectories(t *testing.T) {
|
||||
idx := setupTestIndexForDeletion(t)
|
||||
|
||||
// Add a deeper subdirectory
|
||||
idx.Directories["/subdir/deepdir/verydeep/"] = &iteminfo.FileInfo{
|
||||
Path: "/subdir/deepdir/verydeep/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "verydeep",
|
||||
Type: "directory",
|
||||
Size: 50,
|
||||
},
|
||||
}
|
||||
idx.DirectoriesLedger["/subdir/deepdir/verydeep/"] = struct{}{}
|
||||
|
||||
// Delete the parent recursively
|
||||
idx.DeleteMetadata("/subdir/deepdir/", true, true)
|
||||
|
||||
// Verify all subdirectories were removed
|
||||
if _, exists := idx.Directories["/subdir/deepdir/"]; exists {
|
||||
t.Error("Directory '/subdir/deepdir/' should have been removed")
|
||||
}
|
||||
if _, exists := idx.Directories["/subdir/deepdir/verydeep/"]; exists {
|
||||
t.Error("Subdirectory '/subdir/deepdir/verydeep/' should have been removed")
|
||||
}
|
||||
|
||||
// Verify ledger was cleaned
|
||||
if _, exists := idx.DirectoriesLedger["/subdir/deepdir/"]; exists {
|
||||
t.Error("Directory '/subdir/deepdir/' should have been removed from ledger")
|
||||
}
|
||||
if _, exists := idx.DirectoriesLedger["/subdir/deepdir/verydeep/"]; exists {
|
||||
t.Error("Subdirectory '/subdir/deepdir/verydeep/' should have been removed from ledger")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMetadata_FileRemoval(t *testing.T) {
|
||||
idx := setupTestIndexForDeletion(t)
|
||||
|
||||
// Delete a file
|
||||
idx.DeleteMetadata("/subdir/file3.txt", false, false)
|
||||
|
||||
// Verify file was removed from parent's Files slice
|
||||
parentInfo, exists := idx.Directories["/subdir/"]
|
||||
if !exists {
|
||||
t.Fatal("Parent directory not found")
|
||||
}
|
||||
|
||||
for _, file := range parentInfo.Files {
|
||||
if file.Name == "file3.txt" {
|
||||
t.Error("File 'file3.txt' should have been removed from parent's Files slice")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteMetadata_DirectoryFromParentFolder(t *testing.T) {
|
||||
idx := setupTestIndexForDeletion(t)
|
||||
|
||||
// Delete a directory (non-recursive)
|
||||
idx.DeleteMetadata("/subdir/deepdir/", true, false)
|
||||
|
||||
// Verify directory was removed from Directories
|
||||
if _, exists := idx.Directories["/subdir/deepdir/"]; exists {
|
||||
t.Error("Directory '/subdir/deepdir/' should have been removed")
|
||||
}
|
||||
|
||||
// Verify it was removed from parent's Folders slice
|
||||
parentInfo, exists := idx.Directories["/subdir/"]
|
||||
if !exists {
|
||||
t.Fatal("Parent directory not found")
|
||||
}
|
||||
|
||||
for _, folder := range parentInfo.Folders {
|
||||
if folder.Name == "deepdir" {
|
||||
t.Error("Folder 'deepdir' should have been removed from parent's Folders slice")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,36 +30,59 @@ type actionConfig struct {
|
||||
IsRoutineScan bool // whether this is a routine/scheduled scan (vs initial indexing)
|
||||
}
|
||||
|
||||
// ScannerInfo is the exposed scanner information for the client
|
||||
type ScannerInfo struct {
|
||||
Path string `json:"path"`
|
||||
IsRoot bool `json:"isRoot"`
|
||||
LastScanned time.Time `json:"lastScanned"`
|
||||
Complexity uint `json:"complexity"` // 0-10 scale: 0=unknown, 1=simple, 2-6=normal, 7-9=complex, 10=highlyComplex
|
||||
CurrentSchedule int `json:"currentSchedule"`
|
||||
QuickScanTime int `json:"quickScanTime"`
|
||||
FullScanTime int `json:"fullScanTime"`
|
||||
NumDirs uint64 `json:"numDirs"`
|
||||
NumFiles uint64 `json:"numFiles"`
|
||||
}
|
||||
|
||||
// reduced index is json exposed to the client
|
||||
type ReducedIndex struct {
|
||||
IdxName string `json:"name"`
|
||||
DiskUsed uint64 `json:"used"`
|
||||
DiskTotal uint64 `json:"total"`
|
||||
Status IndexStatus `json:"status"`
|
||||
NumDirs uint64 `json:"numDirs"`
|
||||
NumFiles uint64 `json:"numFiles"`
|
||||
NumDeleted uint64 `json:"numDeleted"`
|
||||
LastIndexed time.Time `json:"-"`
|
||||
LastIndexedUnix int64 `json:"lastIndexedUnixTime"`
|
||||
QuickScanTime int `json:"quickScanDurationSeconds"`
|
||||
FullScanTime int `json:"fullScanDurationSeconds"`
|
||||
Assessment string `json:"assessment"`
|
||||
IdxName string `json:"name"`
|
||||
DiskUsed uint64 `json:"used"`
|
||||
DiskTotal uint64 `json:"total"`
|
||||
Status IndexStatus `json:"status"`
|
||||
NumDirs uint64 `json:"numDirs"`
|
||||
NumFiles uint64 `json:"numFiles"`
|
||||
NumDeleted uint64 `json:"numDeleted"`
|
||||
LastIndexed time.Time `json:"-"`
|
||||
LastIndexedUnix int64 `json:"lastIndexedUnixTime"`
|
||||
QuickScanTime int `json:"quickScanDurationSeconds"`
|
||||
FullScanTime int `json:"fullScanDurationSeconds"`
|
||||
Complexity uint `json:"complexity"` // 0-10 scale: 0=unknown, 1=simple, 2-6=normal, 7-9=complex, 10=highlyComplex
|
||||
Scanners []*ScannerInfo `json:"scanners,omitempty"`
|
||||
}
|
||||
type Index struct {
|
||||
ReducedIndex
|
||||
CurrentSchedule int `json:"-"`
|
||||
settings.Source `json:"-"`
|
||||
Directories map[string]*iteminfo.FileInfo `json:"-"`
|
||||
DirectoriesLedger map[string]struct{} `json:"-"`
|
||||
runningScannerCount int `json:"-"`
|
||||
SmartModifier time.Duration `json:"-"`
|
||||
FilesChangedDuringIndexing bool `json:"-"`
|
||||
mock bool
|
||||
mu sync.RWMutex
|
||||
wasIndexed bool
|
||||
FoundHardLinks map[string]uint64 `json:"-"` // hardlink path -> size
|
||||
processedInodes map[uint64]struct{} `json:"-"`
|
||||
totalSize uint64 `json:"-"`
|
||||
settings.Source `json:"-"`
|
||||
|
||||
// Shared state (protected by mu)
|
||||
Directories map[string]*iteminfo.FileInfo `json:"-"`
|
||||
DirectoriesLedger map[string]struct{} `json:"-"`
|
||||
FoundHardLinks map[string]uint64 `json:"-"` // hardlink path -> size
|
||||
processedInodes map[uint64]struct{} `json:"-"`
|
||||
totalSize uint64 `json:"-"`
|
||||
|
||||
// Scanner management (new multi-scanner system)
|
||||
scanners map[string]*Scanner `json:"-"` // path -> scanner
|
||||
scanMutex sync.Mutex `json:"-"` // Global scan mutex - only one scanner runs at a time
|
||||
activeScannerPath string `json:"-"` // Which scanner is currently running (for logging/status)
|
||||
runningScannerCount int `json:"-"` // Tracks active scanners
|
||||
lastRootScanTime time.Time `json:"-"` // Last time root scanner completed - child scanners wait for this
|
||||
initialScanStartTime time.Time `json:"-"` // When initial multi-scanner indexing started
|
||||
hasLoggedInitialScan bool `json:"-"` // Whether we've logged the first complete round
|
||||
|
||||
// Control
|
||||
mock bool
|
||||
mu sync.RWMutex
|
||||
wasIndexed bool
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -99,14 +122,13 @@ func Initialize(source *settings.Source, mock bool) {
|
||||
newIndex.ReducedIndex = ReducedIndex{
|
||||
Status: "indexing",
|
||||
IdxName: source.Name,
|
||||
Assessment: "unknown",
|
||||
Complexity: 0, // 0 = unknown until first scan completes
|
||||
}
|
||||
indexes[newIndex.Name] = &newIndex
|
||||
indexesMutex.Unlock()
|
||||
if !newIndex.Config.DisableIndexing {
|
||||
time.Sleep(time.Second)
|
||||
logger.Infof("initializing index: [%v]", newIndex.Name)
|
||||
newIndex.RunIndexing("/", false)
|
||||
// Start multi-scanner system (each scanner will do its own initial scan)
|
||||
go newIndex.setupIndexingScanners()
|
||||
} else {
|
||||
newIndex.Status = "ready"
|
||||
@@ -121,10 +143,8 @@ func (idx *Index) indexDirectoryWithOptions(adjustedPath string, config actionCo
|
||||
|
||||
// Define a function to recursively index files and directories
|
||||
func (idx *Index) indexDirectory(adjustedPath string, config actionConfig) error {
|
||||
// Normalize path to always have trailing slash (except for root which is just "/")
|
||||
if adjustedPath != "/" {
|
||||
adjustedPath = strings.TrimSuffix(adjustedPath, "/") + "/"
|
||||
}
|
||||
// Normalize path to always have trailing slash
|
||||
adjustedPath = utils.AddTrailingSlashIfNotExists(adjustedPath)
|
||||
realPath := strings.TrimRight(idx.Path, "/") + adjustedPath
|
||||
// Open the directory
|
||||
dir, err := os.Open(realPath)
|
||||
@@ -169,9 +189,8 @@ func (idx *Index) indexDirectory(adjustedPath string, config actionConfig) error
|
||||
// recursively check cached dirs for mod time changes as well
|
||||
if config.Recursive {
|
||||
if modChange {
|
||||
idx.mu.Lock()
|
||||
idx.FilesChangedDuringIndexing = true
|
||||
idx.mu.Unlock()
|
||||
// Mark files as changed in the active scanner
|
||||
idx.markFilesChanged()
|
||||
} else if config.Quick {
|
||||
for _, item := range cacheDirItems {
|
||||
subConfig := actionConfig{
|
||||
@@ -231,9 +250,7 @@ func (idx *Index) GetFsDirInfo(adjustedPath string) (*iteminfo.FileInfo, error)
|
||||
}
|
||||
|
||||
// Normalize directory path to always have trailing slash
|
||||
if adjustedPath != "/" {
|
||||
adjustedPath = strings.TrimSuffix(adjustedPath, "/") + "/"
|
||||
}
|
||||
adjustedPath = utils.AddTrailingSlashIfNotExists(adjustedPath)
|
||||
// adjustedPath is already normalized with trailing slash
|
||||
combinedPath := adjustedPath
|
||||
var response *iteminfo.FileInfo
|
||||
@@ -255,7 +272,7 @@ func (idx *Index) GetFsDirInfo(adjustedPath string) (*iteminfo.FileInfo, error)
|
||||
filePath := strings.TrimSuffix(adjustedPath, "/") + "/" + item.Name
|
||||
response = &iteminfo.FileInfo{
|
||||
Path: filePath,
|
||||
ItemInfo: item,
|
||||
ItemInfo: item.ItemInfo,
|
||||
}
|
||||
found = true
|
||||
continue
|
||||
@@ -279,7 +296,7 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
|
||||
return nil, err
|
||||
}
|
||||
var totalSize int64
|
||||
fileInfos := []iteminfo.ItemInfo{}
|
||||
fileInfos := []iteminfo.ExtendedItemInfo{}
|
||||
dirInfos := []iteminfo.ItemInfo{}
|
||||
hasPreview := false
|
||||
if !config.Recursive {
|
||||
@@ -353,41 +370,52 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
|
||||
totalSize += itemInfo.Size
|
||||
itemInfo.Type = "directory"
|
||||
dirInfos = append(dirInfos, *itemInfo)
|
||||
if config.Recursive {
|
||||
if config.IsRoutineScan {
|
||||
idx.NumDirs++
|
||||
// Also update the active scanner's counter
|
||||
idx.incrementScannerDirs()
|
||||
}
|
||||
} else {
|
||||
size, shouldCountSize := idx.handleFile(file, fullCombined)
|
||||
itemInfo.DetectType(realPath+"/"+file.Name(), false)
|
||||
// Set HasPreview flags - use cached metadata optimization only when indexing is enabled
|
||||
usedCachedPreview := false
|
||||
if !idx.Config.DisableIndexing && config.Recursive {
|
||||
// Optimization: For audio files during indexing, check if we can use cached album art info
|
||||
simpleType := strings.Split(itemInfo.Type, "/")[0]
|
||||
if simpleType == "audio" {
|
||||
previousInfo, exists := idx.GetReducedMetadata(fullCombined, false)
|
||||
if exists && time.Time.Equal(previousInfo.ModTime, file.ModTime()) {
|
||||
// File unchanged - use cached album art info
|
||||
// File unchanged - use cached album art info (whether true or false)
|
||||
itemInfo.HasPreview = previousInfo.HasPreview
|
||||
usedCachedPreview = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// When indexing is disabled or CheckViewable mode, always check directly
|
||||
setFilePreviewFlags(itemInfo, realPath+"/"+file.Name())
|
||||
|
||||
// Skip if we already used cached preview data (avoids redundant HasAlbumArt checks)
|
||||
if !usedCachedPreview {
|
||||
setFilePreviewFlags(itemInfo, realPath+"/"+file.Name())
|
||||
}
|
||||
itemInfo.Size = int64(size)
|
||||
|
||||
// Update parent folder preview status for images, videos, and audio with album art
|
||||
// Use shared function to determine if this file type should bubble up to folder preview
|
||||
if itemInfo.HasPreview && iteminfo.ShouldBubbleUpToFolderPreview(*itemInfo) {
|
||||
hasPreview = true
|
||||
}
|
||||
|
||||
fileInfos = append(fileInfos, *itemInfo)
|
||||
// Wrap ItemInfo in ExtendedItemInfo for files array
|
||||
extItemInfo := iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: *itemInfo,
|
||||
}
|
||||
fileInfos = append(fileInfos, extItemInfo)
|
||||
if shouldCountSize {
|
||||
totalSize += itemInfo.Size
|
||||
}
|
||||
if config.Recursive {
|
||||
if config.IsRoutineScan {
|
||||
idx.NumFiles++
|
||||
// Also update the active scanner's counter
|
||||
idx.incrementScannerFiles()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,7 +449,9 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
|
||||
return dirFileInfo, nil
|
||||
}
|
||||
|
||||
func (idx *Index) recursiveUpdateDirSizes(childInfo *iteminfo.FileInfo, previousSize int64) {
|
||||
// RecursiveUpdateDirSizes updates parent directory sizes recursively up the tree
|
||||
// childInfo should have the NEW size, previousSize should be the OLD size
|
||||
func (idx *Index) RecursiveUpdateDirSizes(childInfo *iteminfo.FileInfo, previousSize int64) {
|
||||
parentDir := utils.GetParentDirectoryPath(childInfo.Path)
|
||||
|
||||
parentInfo, exists := idx.GetMetadataInfo(parentDir, true)
|
||||
@@ -437,7 +467,7 @@ func (idx *Index) recursiveUpdateDirSizes(childInfo *iteminfo.FileInfo, previous
|
||||
idx.UpdateMetadata(parentInfo)
|
||||
|
||||
// Recursively update grandparents
|
||||
idx.recursiveUpdateDirSizes(parentInfo, previousParentSize)
|
||||
idx.RecursiveUpdateDirSizes(parentInfo, previousParentSize)
|
||||
}
|
||||
|
||||
func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
|
||||
@@ -494,7 +524,7 @@ func (idx *Index) RefreshFileInfo(opts utils.FileOptions) error {
|
||||
|
||||
// If size changed, propagate to parents
|
||||
if previousSize != newInfo.Size {
|
||||
idx.recursiveUpdateDirSizes(newInfo, previousSize)
|
||||
idx.RecursiveUpdateDirSizes(newInfo, previousSize)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -518,38 +548,34 @@ func isHidden(file os.FileInfo, srcPath string) bool {
|
||||
// This consolidates the logic used in both GetFsDirInfo and GetDirInfo
|
||||
func setFilePreviewFlags(fileInfo *iteminfo.ItemInfo, realPath string) {
|
||||
simpleType := strings.Split(fileInfo.Type, "/")[0]
|
||||
ext := strings.ToLower(filepath.Ext(fileInfo.Name))
|
||||
extWithoutPeriod := strings.TrimPrefix(ext, ".")
|
||||
|
||||
// Check if it's an image
|
||||
if simpleType == "image" {
|
||||
fileInfo.HasPreview = true
|
||||
}
|
||||
|
||||
// Check for HEIC/HEIF
|
||||
switch extWithoutPeriod {
|
||||
case "heic", "heif":
|
||||
if settings.CanConvertImage(extWithoutPeriod) {
|
||||
fileInfo.HasPreview = true
|
||||
}
|
||||
switch fileInfo.Type {
|
||||
case "image/heic", "image/heif":
|
||||
fileInfo.HasPreview = settings.CanConvertImage("heic")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it's a video
|
||||
if simpleType == "video" && settings.CanConvertVideo(extWithoutPeriod) {
|
||||
switch simpleType {
|
||||
case "image":
|
||||
fileInfo.HasPreview = true
|
||||
return
|
||||
case "video":
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(fileInfo.Name)), ".")
|
||||
fileInfo.HasPreview = settings.CanConvertVideo(ext)
|
||||
return
|
||||
case "audio":
|
||||
ext := strings.ToLower(filepath.Ext(fileInfo.Name))
|
||||
hasArt := iteminfo.HasAlbumArt(realPath, ext)
|
||||
fileInfo.HasPreview = hasArt
|
||||
return
|
||||
}
|
||||
|
||||
// Check for audio with album art (always check, don't rely on cache)
|
||||
if simpleType == "audio" {
|
||||
fileInfo.HasPreview = iteminfo.HasAlbumArt(realPath, ext)
|
||||
}
|
||||
|
||||
// Check for office docs and PDFs
|
||||
if settings.Config.Integrations.OnlyOffice.Secret != "" && iteminfo.IsOnlyOffice(fileInfo.Name) {
|
||||
fileInfo.HasPreview = true
|
||||
return
|
||||
}
|
||||
if iteminfo.HasDocConvertableExtension(fileInfo.Name, fileInfo.Type) {
|
||||
fileInfo.HasPreview = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -776,7 +802,7 @@ func (idx *Index) MakeIndexPath(path string) string {
|
||||
}
|
||||
path = strings.TrimPrefix(path, idx.Path)
|
||||
path = idx.MakeIndexPathPlatform(path)
|
||||
path = strings.TrimSuffix(path, "/") + "/"
|
||||
path = utils.AddTrailingSlashIfNotExists(path)
|
||||
return path
|
||||
}
|
||||
|
||||
|
||||
@@ -37,9 +37,9 @@ func setupTestIndex(t *testing.T) (*Index, string, func()) {
|
||||
Type: "directory",
|
||||
Size: 1000, // Total logical size: 100 + 200 + 300 + 400
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "file1.txt", Size: 100},
|
||||
{Name: "file2.txt", Size: 200},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file1.txt", Size: 100}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file2.txt", Size: 200}},
|
||||
},
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "subdir", Type: "directory", Size: 700}, // 300 + 400
|
||||
@@ -53,8 +53,8 @@ func setupTestIndex(t *testing.T) (*Index, string, func()) {
|
||||
Type: "directory",
|
||||
Size: 700, // 300 + 400
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "file3.txt", Size: 300},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file3.txt", Size: 300}},
|
||||
},
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "deepdir", Type: "directory", Size: 400},
|
||||
@@ -68,8 +68,8 @@ func setupTestIndex(t *testing.T) (*Index, string, func()) {
|
||||
Type: "directory",
|
||||
Size: 400,
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "file4.txt", Size: 400},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file4.txt", Size: 400}},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -151,9 +151,11 @@ func TestRecursiveSizeUpdate(t *testing.T) {
|
||||
// Simulate adding a new file to deepdir by updating the mock data
|
||||
// Add file5.txt (500 bytes) to deepdir
|
||||
deepdirInfo := idx.Directories["/subdir/deepdir/"]
|
||||
deepdirInfo.Files = append(deepdirInfo.Files, iteminfo.ItemInfo{
|
||||
Name: "file5.txt",
|
||||
Size: 500,
|
||||
deepdirInfo.Files = append(deepdirInfo.Files, iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "file5.txt",
|
||||
Size: 500,
|
||||
},
|
||||
})
|
||||
|
||||
// Update deepdir size
|
||||
@@ -161,7 +163,7 @@ func TestRecursiveSizeUpdate(t *testing.T) {
|
||||
deepdirInfo.Size = 900 // 400 + 500
|
||||
|
||||
// Simulate the recursive size update by calling the method directly
|
||||
idx.recursiveUpdateDirSizes(deepdirInfo, oldDeepdirSize)
|
||||
idx.RecursiveUpdateDirSizes(deepdirInfo, oldDeepdirSize)
|
||||
|
||||
// Check that deepdir size updated
|
||||
deepdirInfo, exists = idx.GetMetadataInfo("/subdir/deepdir/", true)
|
||||
@@ -208,9 +210,11 @@ func TestNonRecursiveMetadataUpdate(t *testing.T) {
|
||||
initialRootSize := rootInfo.Size
|
||||
|
||||
// Simulate adding a new file directly to root by updating mock data
|
||||
rootInfo.Files = append(rootInfo.Files, iteminfo.ItemInfo{
|
||||
Name: "file3.txt",
|
||||
Size: 150,
|
||||
rootInfo.Files = append(rootInfo.Files, iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "file3.txt",
|
||||
Size: 150,
|
||||
},
|
||||
})
|
||||
|
||||
// Update root size (non-recursive - only direct files)
|
||||
@@ -260,7 +264,7 @@ func TestRecursiveUpdateDirSizes(t *testing.T) {
|
||||
deepdirInfo.Size = 900
|
||||
|
||||
// Call recursiveUpdateDirSizes
|
||||
idx.recursiveUpdateDirSizes(deepdirInfo, previousSize)
|
||||
idx.RecursiveUpdateDirSizes(deepdirInfo, previousSize)
|
||||
|
||||
// Check that subdir size updated
|
||||
subdirInfo := idx.Directories["/subdir/"]
|
||||
@@ -309,7 +313,7 @@ func TestSizeDecreasePropagate(t *testing.T) {
|
||||
deepdirInfo.Size = 100
|
||||
|
||||
// Call recursiveUpdateDirSizes
|
||||
idx.recursiveUpdateDirSizes(deepdirInfo, previousSize)
|
||||
idx.RecursiveUpdateDirSizes(deepdirInfo, previousSize)
|
||||
|
||||
// Check that subdir size decreased
|
||||
subdirInfo := idx.Directories["/subdir/"]
|
||||
@@ -332,10 +336,12 @@ func TestPreviewDoesNotPropagateFromSubdirectories(t *testing.T) {
|
||||
|
||||
// Add an image file to subdir in mock data (should have preview)
|
||||
subdirInfo := idx.Directories["/subdir/"]
|
||||
subdirInfo.Files = append(subdirInfo.Files, iteminfo.ItemInfo{
|
||||
Name: "image.jpg",
|
||||
Size: 100,
|
||||
HasPreview: true, // Image files have preview
|
||||
subdirInfo.Files = append(subdirInfo.Files, iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "image.jpg",
|
||||
Size: 100,
|
||||
HasPreview: true, // Image files have preview
|
||||
},
|
||||
})
|
||||
subdirInfo.HasPreview = true // Subdir now has preview
|
||||
|
||||
@@ -367,10 +373,12 @@ func TestPreviewPropagatesFromFiles(t *testing.T) {
|
||||
|
||||
// Add an image file to root in mock data (should propagate preview to root)
|
||||
rootInfo := idx.Directories["/"]
|
||||
rootInfo.Files = append(rootInfo.Files, iteminfo.ItemInfo{
|
||||
Name: "image.png",
|
||||
Size: 100,
|
||||
HasPreview: true, // Image files have preview
|
||||
rootInfo.Files = append(rootInfo.Files, iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "image.png",
|
||||
Size: 100,
|
||||
HasPreview: true, // Image files have preview
|
||||
},
|
||||
})
|
||||
rootInfo.HasPreview = true // Root now has preview
|
||||
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
package indexing
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// Scanner represents an independent scanner for a specific directory path
|
||||
// Each scanner has its own schedule and stats, but only one can run at a time (protected by Index.scanMutex)
|
||||
type Scanner struct {
|
||||
// Identity
|
||||
scanPath string // "/" for root scanner, "/Documents/" for child scanners
|
||||
|
||||
// Per-scanner scheduling (not shared between scanners)
|
||||
currentSchedule int
|
||||
smartModifier time.Duration
|
||||
complexity uint // 0-10 scale: 0=unknown, 1=simple, 2-6=normal, 7-9=complex, 10=highlyComplex
|
||||
fullScanCounter int // every 5th scan is a full scan
|
||||
|
||||
// Per-scanner stats (not shared)
|
||||
filesChanged bool
|
||||
lastScanned time.Time
|
||||
quickScanTime int
|
||||
fullScanTime int
|
||||
numDirs uint64 // Local count for this path
|
||||
numFiles uint64 // Local count for this path
|
||||
|
||||
// Reference back to parent index
|
||||
idx *Index
|
||||
|
||||
// Control
|
||||
stopChan chan struct{}
|
||||
}
|
||||
|
||||
// calculateTimeScore returns a 1-10 score based on full scan time
|
||||
func (s *Scanner) calculateTimeScore() uint {
|
||||
if s.fullScanTime == 0 {
|
||||
return 1 // No data yet, assume simple
|
||||
}
|
||||
// Time-based thresholds (in seconds)
|
||||
switch {
|
||||
case s.fullScanTime < 2:
|
||||
return 1
|
||||
case s.fullScanTime < 5:
|
||||
return 2
|
||||
case s.fullScanTime < 10:
|
||||
return 3
|
||||
case s.fullScanTime < 15:
|
||||
return 4
|
||||
case s.fullScanTime < 30:
|
||||
return 5
|
||||
case s.fullScanTime < 60:
|
||||
return 6
|
||||
case s.fullScanTime < 90:
|
||||
return 7
|
||||
case s.fullScanTime < 120:
|
||||
return 8
|
||||
case s.fullScanTime < 180:
|
||||
return 9
|
||||
default:
|
||||
return 10
|
||||
}
|
||||
}
|
||||
|
||||
// calculateDirScore returns a 1-10 score based on directory count
|
||||
func (s *Scanner) calculateDirScore() uint {
|
||||
// Directory-based thresholds
|
||||
switch {
|
||||
case s.numDirs < 2500:
|
||||
return 1
|
||||
case s.numDirs < 5000:
|
||||
return 2
|
||||
case s.numDirs < 10000:
|
||||
return 3
|
||||
case s.numDirs < 25000:
|
||||
return 4
|
||||
case s.numDirs < 50000:
|
||||
return 5
|
||||
case s.numDirs < 100000:
|
||||
return 6
|
||||
case s.numDirs < 250000:
|
||||
return 7
|
||||
case s.numDirs < 500000:
|
||||
return 8
|
||||
case s.numDirs < 1000000:
|
||||
return 9
|
||||
default:
|
||||
return 10
|
||||
}
|
||||
}
|
||||
|
||||
// start begins the scanner's main loop
|
||||
func (s *Scanner) start() {
|
||||
// Do initial scan for all scanners
|
||||
// Wait a bit to stagger child scanner initial scans (root goes first)
|
||||
if s.scanPath != "/" {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
s.tryAcquireAndScan()
|
||||
|
||||
for {
|
||||
// Check if directory still exists (for non-root scanners)
|
||||
if s.scanPath != "/" && !s.directoryExists() {
|
||||
logger.Debugf("Scanner [%s] stopping: directory no longer exists", s.scanPath)
|
||||
s.removeSelf()
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate sleep based on this scanner's schedule
|
||||
sleepTime := s.calculateSleepTime()
|
||||
|
||||
select {
|
||||
case <-s.stopChan:
|
||||
logger.Debugf("Scanner [%s] received stop signal", s.scanPath)
|
||||
return
|
||||
|
||||
case <-time.After(sleepTime):
|
||||
// Time to scan! But must acquire mutex first
|
||||
s.tryAcquireAndScan()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tryAcquireAndScan attempts to acquire the global scan mutex and run a scan
|
||||
func (s *Scanner) tryAcquireAndScan() {
|
||||
// Child scanners must wait for root scanner to go first each round
|
||||
if s.scanPath != "/" {
|
||||
s.idx.mu.RLock()
|
||||
lastRootScan := s.idx.lastRootScanTime
|
||||
myLastScan := s.lastScanned
|
||||
s.idx.mu.RUnlock()
|
||||
|
||||
// If we've scanned more recently than the root, skip this cycle
|
||||
if !myLastScan.IsZero() && myLastScan.After(lastRootScan) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.idx.scanMutex.Lock()
|
||||
|
||||
// Mark which scanner is active (for status/logging)
|
||||
s.idx.mu.Lock()
|
||||
s.idx.activeScannerPath = s.scanPath
|
||||
s.idx.mu.Unlock()
|
||||
|
||||
// Determine if quick or full scan
|
||||
// First scan (fullScanCounter=0) is always full
|
||||
// Scans 1-4 are quick, scan 5 is full, then repeat
|
||||
quick := s.fullScanCounter > 0 && s.fullScanCounter < 5
|
||||
s.fullScanCounter++
|
||||
if s.fullScanCounter >= 5 {
|
||||
s.fullScanCounter = 0
|
||||
}
|
||||
s.runIndexing(quick)
|
||||
|
||||
// Update this scanner's schedule based on results
|
||||
s.updateSchedule()
|
||||
|
||||
// If this is the root scanner, update the last root scan time
|
||||
if s.scanPath == "/" {
|
||||
s.idx.mu.Lock()
|
||||
s.idx.lastRootScanTime = time.Now()
|
||||
s.idx.mu.Unlock()
|
||||
}
|
||||
|
||||
// Clear active scanner
|
||||
s.idx.mu.Lock()
|
||||
s.idx.activeScannerPath = ""
|
||||
s.idx.mu.Unlock()
|
||||
|
||||
s.idx.scanMutex.Unlock()
|
||||
|
||||
// Aggregate stats to Index level and update status (after releasing active scanner)
|
||||
s.idx.aggregateStatsFromScanners()
|
||||
}
|
||||
|
||||
// runIndexing performs the actual indexing work
|
||||
func (s *Scanner) runIndexing(quick bool) {
|
||||
if s.scanPath == "/" {
|
||||
// ROOT SCANNER: Non-recursive, just scan root directory itself
|
||||
s.runRootScan(quick)
|
||||
} else {
|
||||
// CHILD SCANNER: Recursive scan of assigned directory
|
||||
s.runChildScan(quick)
|
||||
}
|
||||
|
||||
s.lastScanned = time.Now()
|
||||
}
|
||||
|
||||
// runRootScan scans only the root directory (non-recursive) and checks for new child directories
|
||||
func (s *Scanner) runRootScan(quick bool) {
|
||||
config := actionConfig{
|
||||
Quick: quick,
|
||||
Recursive: false,
|
||||
IsRoutineScan: true,
|
||||
}
|
||||
|
||||
// Reset counters for full scan
|
||||
if !quick {
|
||||
s.numDirs = 0
|
||||
s.numFiles = 0
|
||||
}
|
||||
|
||||
s.filesChanged = false
|
||||
startTime := time.Now()
|
||||
|
||||
err := s.idx.indexDirectory("/", config)
|
||||
if err != nil {
|
||||
logger.Errorf("Root scanner error: %v", err)
|
||||
}
|
||||
|
||||
scanDuration := int(time.Since(startTime).Seconds())
|
||||
if quick {
|
||||
s.quickScanTime = scanDuration
|
||||
} else {
|
||||
s.fullScanTime = scanDuration
|
||||
s.updateComplexity()
|
||||
}
|
||||
|
||||
// Check for new top-level directories and create scanners for them
|
||||
s.checkForNewChildDirectories()
|
||||
}
|
||||
|
||||
// runChildScan scans a specific directory recursively
|
||||
func (s *Scanner) runChildScan(quick bool) {
|
||||
config := actionConfig{
|
||||
Quick: quick,
|
||||
Recursive: true,
|
||||
IsRoutineScan: true,
|
||||
}
|
||||
|
||||
// Reset counters for full scan
|
||||
if !quick {
|
||||
s.numDirs = 0
|
||||
s.numFiles = 0
|
||||
}
|
||||
|
||||
s.filesChanged = false
|
||||
startTime := time.Now()
|
||||
|
||||
err := s.idx.indexDirectory(s.scanPath, config)
|
||||
if err != nil {
|
||||
logger.Errorf("Scanner [%s] error: %v", s.scanPath, err)
|
||||
}
|
||||
|
||||
scanDuration := int(time.Since(startTime).Seconds())
|
||||
if quick {
|
||||
s.quickScanTime = scanDuration
|
||||
} else {
|
||||
s.fullScanTime = scanDuration
|
||||
s.updateComplexity()
|
||||
}
|
||||
}
|
||||
|
||||
// checkForNewChildDirectories detects new top-level directories and creates scanners for them
|
||||
// Also detects deleted directories and signals their scanners to stop
|
||||
func (s *Scanner) checkForNewChildDirectories() {
|
||||
if s.scanPath != "/" {
|
||||
return
|
||||
}
|
||||
|
||||
// Get current top-level directories from filesystem (already filtered by exclusion rules)
|
||||
currentDirs := s.getTopLevelDirs()
|
||||
currentDirsMap := make(map[string]bool)
|
||||
for _, dir := range currentDirs {
|
||||
currentDirsMap[dir] = true
|
||||
}
|
||||
|
||||
// Check which scanners already exist
|
||||
s.idx.mu.RLock()
|
||||
existingScanners := make(map[string]*Scanner)
|
||||
for path, scanner := range s.idx.scanners {
|
||||
if path != "/" { // Don't check root scanner
|
||||
existingScanners[path] = scanner
|
||||
}
|
||||
}
|
||||
s.idx.mu.RUnlock()
|
||||
|
||||
// Check for deleted directories and stop their scanners
|
||||
for path, scanner := range existingScanners {
|
||||
if !currentDirsMap[path] {
|
||||
logger.Debugf("Directory [%s] no longer exists, stopping scanner", path)
|
||||
scanner.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// Create scanner for any new directories (getTopLevelDirs already filtered excluded dirs)
|
||||
for _, dirPath := range currentDirs {
|
||||
_, exists := existingScanners[dirPath]
|
||||
if !exists && dirPath != "/" {
|
||||
logger.Debugf("Detected new directory, creating scanner: [%s]", dirPath)
|
||||
newScanner := s.idx.createChildScanner(dirPath)
|
||||
|
||||
s.idx.mu.Lock()
|
||||
s.idx.scanners[dirPath] = newScanner
|
||||
s.idx.mu.Unlock()
|
||||
|
||||
go newScanner.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getTopLevelDirs returns a list of top-level directory paths in the root
|
||||
func (s *Scanner) getTopLevelDirs() []string {
|
||||
dirs := []string{}
|
||||
realPath := strings.TrimRight(s.idx.Path, "/") + "/"
|
||||
|
||||
dir, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to open root directory: %v", err)
|
||||
return dirs
|
||||
}
|
||||
defer dir.Close()
|
||||
|
||||
files, err := dir.Readdir(-1)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to read root directory: %v", err)
|
||||
return dirs
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
baseName := file.Name()
|
||||
|
||||
// Skip files - only create scanners for directories
|
||||
if !file.IsDir() {
|
||||
// Note: includeRootItems may contain files, but we only create scanners for directories
|
||||
continue
|
||||
}
|
||||
|
||||
dirPath := "/" + baseName + "/"
|
||||
|
||||
// Skip directories in omit list
|
||||
if omitList[baseName] {
|
||||
logger.Debugf("Skipping scanner creation for omitted directory: %s", dirPath)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we should include this directory (respects includeRootItems filter)
|
||||
// When includeRootItems is set, ONLY those items (that are directories) get scanners
|
||||
if !s.idx.shouldInclude(baseName) {
|
||||
logger.Debugf("Skipping scanner creation for non-included directory: %s", dirPath)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this directory should be excluded from indexing (respects exclusion rules)
|
||||
hidden := isHidden(file, s.idx.Path+dirPath)
|
||||
if s.idx.shouldSkip(true, hidden, dirPath, baseName, actionConfig{}) {
|
||||
logger.Debugf("Skipping scanner creation for excluded directory: %s", dirPath)
|
||||
continue
|
||||
}
|
||||
|
||||
dirs = append(dirs, dirPath)
|
||||
}
|
||||
|
||||
return dirs
|
||||
}
|
||||
|
||||
// calculateSleepTime determines how long to wait before the next scan
|
||||
func (s *Scanner) calculateSleepTime() time.Duration {
|
||||
// Get base schedule time and apply complexity modifier
|
||||
sleepTime := scanSchedule[s.currentSchedule] + s.smartModifier
|
||||
|
||||
// Allow manual override via config
|
||||
if s.idx.Config.IndexingInterval > 0 {
|
||||
sleepTime = time.Duration(s.idx.Config.IndexingInterval) * time.Minute
|
||||
}
|
||||
|
||||
return sleepTime
|
||||
}
|
||||
|
||||
// updateSchedule adjusts the scanner's schedule based on whether files changed
|
||||
func (s *Scanner) updateSchedule() {
|
||||
// Adjust schedule based on file changes
|
||||
if s.filesChanged {
|
||||
logger.Debugf("Scanner [%s] detected changes, adjusting schedule", s.scanPath)
|
||||
// Move to at least the full-scan anchor or reduce interval
|
||||
if s.currentSchedule > fullScanAnchor {
|
||||
s.currentSchedule = fullScanAnchor
|
||||
} else if s.currentSchedule > 0 {
|
||||
s.currentSchedule--
|
||||
}
|
||||
} else {
|
||||
// Increment toward the longest interval if no changes
|
||||
if s.currentSchedule < len(scanSchedule)-1 {
|
||||
s.currentSchedule++
|
||||
}
|
||||
}
|
||||
|
||||
// Cap simple complexity (1) at schedule 4
|
||||
// Don't apply this cap if complexity is still unknown (0)
|
||||
if s.complexity == 1 && s.currentSchedule > 4 {
|
||||
s.currentSchedule = 4
|
||||
}
|
||||
|
||||
// Ensure currentSchedule stays within bounds
|
||||
if s.currentSchedule < 0 {
|
||||
s.currentSchedule = 0
|
||||
} else if s.currentSchedule >= len(scanSchedule) {
|
||||
s.currentSchedule = len(scanSchedule) - 1
|
||||
}
|
||||
}
|
||||
|
||||
// updateComplexity calculates the complexity level (1-10) for this scanner's directory
|
||||
// 0: unknown, 1: simple, 2-6: normal, 7-9: complex, 10: highlyComplex
|
||||
func (s *Scanner) updateComplexity() {
|
||||
// Calculate complexity based on both scan time and directory count
|
||||
timeScore := s.calculateTimeScore()
|
||||
dirScore := s.calculateDirScore()
|
||||
|
||||
// Use the higher score (more conservative approach)
|
||||
complexity := timeScore
|
||||
if dirScore > timeScore {
|
||||
complexity = dirScore
|
||||
}
|
||||
|
||||
s.complexity = complexity
|
||||
|
||||
// Set smartModifier based on complexity level
|
||||
if modifier, ok := complexityModifier[s.complexity]; ok {
|
||||
s.smartModifier = modifier
|
||||
} else {
|
||||
s.smartModifier = 0
|
||||
}
|
||||
}
|
||||
|
||||
// directoryExists checks if the scanner's directory still exists
|
||||
func (s *Scanner) directoryExists() bool {
|
||||
realPath := strings.TrimRight(s.idx.Path, "/") + s.scanPath
|
||||
realPath = strings.TrimSuffix(realPath, "/")
|
||||
|
||||
_, err := os.Stat(realPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// removeSelf removes this scanner from the index's scanner map
|
||||
func (s *Scanner) removeSelf() {
|
||||
s.idx.mu.Lock()
|
||||
defer s.idx.mu.Unlock()
|
||||
|
||||
delete(s.idx.scanners, s.scanPath)
|
||||
logger.Infof("Removed scanner [%s] from active scanners", s.scanPath)
|
||||
|
||||
// Trigger stats aggregation to update overall index
|
||||
go s.idx.aggregateStatsFromScanners()
|
||||
}
|
||||
|
||||
// stop gracefully stops the scanner
|
||||
func (s *Scanner) stop() {
|
||||
close(s.stopChan)
|
||||
}
|
||||
@@ -22,30 +22,60 @@ var scanSchedule = map[int]time.Duration{
|
||||
9: 12 * time.Hour,
|
||||
}
|
||||
|
||||
// complexityModifier defines time adjustments based on complexity level (0-10)
|
||||
// 0: unknown (not yet scanned), 1: simple, 2-6: normal, 7-9: complex, 10: highlyComplex
|
||||
// Each level gets progressively more aggressive with scan timing adjustments
|
||||
var complexityModifier = map[uint]time.Duration{
|
||||
0: 0 * time.Minute, // unknown: no modifier
|
||||
1: -4 * time.Minute, // simple: scan more frequently
|
||||
2: -2 * time.Minute, // normal (lightest)
|
||||
3: -1 * time.Minute,
|
||||
4: 0 * time.Minute, // baseline normal
|
||||
5: 1 * time.Minute,
|
||||
6: 2 * time.Minute, // normal (heaviest)
|
||||
7: 4 * time.Minute, // complex (lightest)
|
||||
8: 8 * time.Minute,
|
||||
9: 12 * time.Minute,
|
||||
10: 16 * time.Minute, // highlyComplex: scan less frequently
|
||||
}
|
||||
|
||||
var fullScanAnchor = 3 // index of the schedule for a full scan
|
||||
|
||||
func (idx *Index) newScanner(origin string) {
|
||||
fullScanCounter := 0 // every 5th scan is a full scan
|
||||
for {
|
||||
// Determine sleep time with modifiers
|
||||
fullScanCounter++
|
||||
sleepTime := scanSchedule[idx.CurrentSchedule] + idx.SmartModifier
|
||||
if idx.Assessment == "simple" {
|
||||
sleepTime = scanSchedule[idx.CurrentSchedule] - idx.SmartModifier
|
||||
}
|
||||
if idx.Config.IndexingInterval > 0 {
|
||||
sleepTime = time.Duration(idx.Config.IndexingInterval) * time.Minute
|
||||
}
|
||||
// Log and sleep before indexing
|
||||
logger.Debugf("Next scan in %v", sleepTime)
|
||||
time.Sleep(sleepTime)
|
||||
if fullScanCounter == 5 {
|
||||
idx.RunIndexing(origin, false) // Full scan
|
||||
fullScanCounter = 0
|
||||
} else {
|
||||
idx.RunIndexing(origin, true) // Quick scan
|
||||
}
|
||||
idx.UpdateSchedule()
|
||||
// Removed: Old single-scanner implementation - replaced by multi-scanner system in indexingScanner.go
|
||||
|
||||
// markFilesChanged marks that files have changed in the currently active scanner
|
||||
func (idx *Index) markFilesChanged() {
|
||||
idx.mu.RLock()
|
||||
activePath := idx.activeScannerPath
|
||||
scanner, exists := idx.scanners[activePath]
|
||||
idx.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
scanner.filesChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
// incrementScannerDirs increments the directory counter for the active scanner
|
||||
func (idx *Index) incrementScannerDirs() {
|
||||
idx.mu.RLock()
|
||||
activePath := idx.activeScannerPath
|
||||
scanner, exists := idx.scanners[activePath]
|
||||
idx.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
scanner.numDirs++
|
||||
}
|
||||
}
|
||||
|
||||
// incrementScannerFiles increments the file counter for the active scanner
|
||||
func (idx *Index) incrementScannerFiles() {
|
||||
idx.mu.RLock()
|
||||
activePath := idx.activeScannerPath
|
||||
scanner, exists := idx.scanners[activePath]
|
||||
idx.mu.RUnlock()
|
||||
|
||||
if exists {
|
||||
scanner.numFiles++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,32 +108,7 @@ func (idx *Index) garbageCollection() {
|
||||
idx.DirectoriesLedger = make(map[string]struct{})
|
||||
}
|
||||
|
||||
func (idx *Index) UpdateSchedule() {
|
||||
// Adjust schedule based on file changes
|
||||
if idx.FilesChangedDuringIndexing {
|
||||
logger.Debugf("Files changed during indexing [%v], adjusting schedule.", idx.Name)
|
||||
// Move to at least the full-scan anchor or reduce interval
|
||||
if idx.CurrentSchedule > fullScanAnchor {
|
||||
idx.CurrentSchedule = fullScanAnchor
|
||||
} else if idx.CurrentSchedule > 0 {
|
||||
idx.CurrentSchedule--
|
||||
}
|
||||
} else {
|
||||
// Increment toward the longest interval if no changes
|
||||
if idx.CurrentSchedule < len(scanSchedule)-1 {
|
||||
idx.CurrentSchedule++
|
||||
}
|
||||
}
|
||||
if idx.Assessment == "simple" && idx.CurrentSchedule > 3 {
|
||||
idx.CurrentSchedule = 3
|
||||
}
|
||||
// Ensure `currentSchedule` stays within bounds
|
||||
if idx.CurrentSchedule < 0 {
|
||||
idx.CurrentSchedule = 0
|
||||
} else if idx.CurrentSchedule >= len(scanSchedule) {
|
||||
idx.CurrentSchedule = len(scanSchedule) - 1
|
||||
}
|
||||
}
|
||||
// Removed: UpdateSchedule - now handled per-scanner in Scanner.updateSchedule()
|
||||
|
||||
func (idx *Index) SendSourceUpdateEvent() error {
|
||||
if idx.mock {
|
||||
@@ -125,80 +130,188 @@ func (idx *Index) SendSourceUpdateEvent() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (idx *Index) RunIndexing(origin string, quick bool) {
|
||||
if idx.runningScannerCount > 0 {
|
||||
logger.Debugf("Indexing already in progress for [%v]", idx.Name)
|
||||
return
|
||||
}
|
||||
err := idx.PreScan()
|
||||
if err != nil {
|
||||
logger.Errorf("Error during indexing: %v", err)
|
||||
return
|
||||
}
|
||||
// Removed: RunIndexing - replaced by multi-scanner system where each scanner handles its own indexing
|
||||
|
||||
// setupMultiScanner creates and starts the multi-scanner system
|
||||
// Creates a root scanner (non-recursive) and child scanners for each top-level directory
|
||||
func (idx *Index) setupMultiScanner() {
|
||||
logger.Infof("Setting up multi-scanner system for [%v]", idx.Name)
|
||||
|
||||
idx.mu.Lock()
|
||||
idx.scanners = make(map[string]*Scanner)
|
||||
idx.initialScanStartTime = time.Now()
|
||||
idx.hasLoggedInitialScan = false
|
||||
idx.mu.Unlock()
|
||||
|
||||
// Create and start root scanner
|
||||
rootScanner := idx.createRootScanner()
|
||||
idx.mu.Lock()
|
||||
idx.scanners["/"] = rootScanner
|
||||
idx.mu.Unlock()
|
||||
go rootScanner.start()
|
||||
|
||||
// Discover existing top-level directories (root scanner will create scanners for new ones dynamically)
|
||||
topLevelDirs := rootScanner.getTopLevelDirs()
|
||||
|
||||
// Create child scanner for each top-level directory
|
||||
for _, dirPath := range topLevelDirs {
|
||||
childScanner := idx.createChildScanner(dirPath)
|
||||
|
||||
prevNumDirs := idx.NumDirs
|
||||
prevNumFiles := idx.NumFiles
|
||||
if quick {
|
||||
logger.Debugf("Starting quick scan for [%v]", idx.Name)
|
||||
} else {
|
||||
logger.Debugf("Starting full scan for [%v]", idx.Name)
|
||||
idx.mu.Lock()
|
||||
idx.NumDirs = 0
|
||||
idx.NumFiles = 0
|
||||
idx.processedInodes = make(map[uint64]struct{})
|
||||
idx.FoundHardLinks = make(map[string]uint64)
|
||||
idx.scanners[dirPath] = childScanner
|
||||
idx.mu.Unlock()
|
||||
}
|
||||
startTime := time.Now()
|
||||
idx.FilesChangedDuringIndexing = false
|
||||
// Perform the indexing operation
|
||||
config := actionConfig{
|
||||
Quick: quick,
|
||||
Recursive: true,
|
||||
IsRoutineScan: idx.wasIndexed, // This is a routine scan if we already have an index
|
||||
}
|
||||
err = idx.indexDirectory("/", config)
|
||||
if err != nil {
|
||||
logger.Errorf("Error during indexing: %v", err)
|
||||
}
|
||||
firstRun := time.Time.Equal(idx.LastIndexed, time.Time{})
|
||||
// Update the LastIndexed time
|
||||
idx.LastIndexed = time.Now()
|
||||
idx.LastIndexedUnix = idx.LastIndexed.Unix()
|
||||
if quick {
|
||||
idx.QuickScanTime = int(time.Since(startTime).Seconds())
|
||||
logger.Debugf("Time spent indexing [%v]: %v seconds", idx.Name, idx.QuickScanTime)
|
||||
} else {
|
||||
idx.FullScanTime = int(time.Since(startTime).Seconds())
|
||||
// update smart indexing
|
||||
if idx.FullScanTime < 3 || idx.NumDirs < 10000 {
|
||||
idx.Assessment = "simple"
|
||||
idx.SmartModifier = 4 * time.Minute
|
||||
} else if idx.FullScanTime > 120 || idx.NumDirs > 500000 {
|
||||
idx.Assessment = "complex"
|
||||
modifier := idx.FullScanTime / 10 // seconds
|
||||
idx.SmartModifier = time.Duration(modifier) * time.Minute
|
||||
} else {
|
||||
idx.Assessment = "normal"
|
||||
}
|
||||
if firstRun {
|
||||
logger.Infof("Index assessment : [%v] complexity=%v directories=%v files=%v", idx.Name, idx.Assessment, idx.NumDirs, idx.NumFiles)
|
||||
} else {
|
||||
logger.Debugf("Index assessment : [%v] complexity=%v directories=%v files=%v", idx.Name, idx.Assessment, idx.NumDirs, idx.NumFiles)
|
||||
}
|
||||
if idx.NumDirs != prevNumDirs || idx.NumFiles != prevNumFiles {
|
||||
idx.FilesChangedDuringIndexing = true
|
||||
}
|
||||
logger.Debugf("Time spent indexing [%v]: %v seconds", idx.Name, idx.FullScanTime)
|
||||
|
||||
go childScanner.start()
|
||||
}
|
||||
|
||||
err = idx.PostScan()
|
||||
if err != nil {
|
||||
logger.Errorf("Error during post scan indexing: %v", err)
|
||||
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 {
|
||||
return &Scanner{
|
||||
scanPath: dirPath,
|
||||
idx: idx,
|
||||
stopChan: make(chan struct{}),
|
||||
currentSchedule: 0,
|
||||
fullScanCounter: 0,
|
||||
complexity: 0, // 0 = unknown until first full scan completes
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy function kept for backwards compatibility - now deprecated
|
||||
func (idx *Index) setupIndexingScanners() {
|
||||
// Use new multi-scanner system
|
||||
idx.setupMultiScanner()
|
||||
}
|
||||
|
||||
// aggregateStatsFromScanners aggregates stats from all scanners to Index-level stats
|
||||
func (idx *Index) aggregateStatsFromScanners() {
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
|
||||
if len(idx.scanners) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Aggregate stats from all scanners
|
||||
var totalDirs uint64 = 0
|
||||
var totalFiles uint64 = 0
|
||||
var totalQuickScanTime = 0
|
||||
var totalFullScanTime = 0
|
||||
var mostRecentScan time.Time
|
||||
var maxComplexity uint = 0 // Start at 0 (unknown)
|
||||
allScannedAtLeastOnce := true
|
||||
|
||||
for _, scanner := range idx.scanners {
|
||||
totalDirs += scanner.numDirs
|
||||
totalFiles += scanner.numFiles
|
||||
totalQuickScanTime += scanner.quickScanTime
|
||||
totalFullScanTime += scanner.fullScanTime
|
||||
|
||||
// Track most recent scan
|
||||
if scanner.lastScanned.After(mostRecentScan) {
|
||||
mostRecentScan = scanner.lastScanned
|
||||
}
|
||||
|
||||
// Check if all scanners have scanned at least once
|
||||
if scanner.lastScanned.IsZero() {
|
||||
allScannedAtLeastOnce = false
|
||||
}
|
||||
if !allScannedAtLeastOnce {
|
||||
continue
|
||||
}
|
||||
|
||||
// Track highest complexity (most conservative assessment)
|
||||
// Only consider scanners that have been assessed (complexity > 0)
|
||||
if scanner.complexity > 0 && scanner.complexity > maxComplexity {
|
||||
maxComplexity = scanner.complexity
|
||||
}
|
||||
}
|
||||
if allScannedAtLeastOnce && idx.FullScanTime == 0 {
|
||||
maxComplexity = 1 // assess as simple because it took 0 seconds total
|
||||
}
|
||||
|
||||
// Update Index-level stats
|
||||
idx.NumDirs = totalDirs
|
||||
idx.NumFiles = totalFiles
|
||||
idx.QuickScanTime = totalQuickScanTime
|
||||
idx.FullScanTime = totalFullScanTime
|
||||
idx.Complexity = maxComplexity
|
||||
|
||||
// Update last indexed time
|
||||
if !mostRecentScan.IsZero() {
|
||||
idx.LastIndexed = mostRecentScan
|
||||
idx.LastIndexedUnix = mostRecentScan.Unix()
|
||||
idx.wasIndexed = true
|
||||
}
|
||||
|
||||
// Log first complete scan round (once)
|
||||
if allScannedAtLeastOnce && !idx.hasLoggedInitialScan {
|
||||
totalDuration := time.Since(idx.initialScanStartTime)
|
||||
truncatedToSecond := totalDuration.Truncate(time.Second)
|
||||
logger.Debugf("Time spent indexing [%v]: %v seconds", idx.Name, truncatedToSecond)
|
||||
idx.hasLoggedInitialScan = true
|
||||
}
|
||||
|
||||
// Update status: if all scanners have completed at least one scan, mark as READY
|
||||
if allScannedAtLeastOnce && idx.activeScannerPath == "" {
|
||||
idx.Status = READY
|
||||
// Send update event to notify clients
|
||||
idx.mu.Unlock()
|
||||
err := idx.SendSourceUpdateEvent()
|
||||
idx.mu.Lock()
|
||||
if err != nil {
|
||||
logger.Errorf("Error sending source update event: %v", err)
|
||||
}
|
||||
} else if idx.activeScannerPath != "" {
|
||||
idx.Status = INDEXING
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (idx *Index) setupIndexingScanners() {
|
||||
go idx.newScanner("/")
|
||||
// GetScannerStatus returns detailed information about all active scanners
|
||||
func (idx *Index) GetScannerStatus() map[string]interface{} {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
|
||||
status := make(map[string]interface{})
|
||||
|
||||
// Current active scanner (if any is running)
|
||||
status["activeScanner"] = idx.activeScannerPath
|
||||
status["isScanning"] = idx.activeScannerPath != ""
|
||||
|
||||
// Individual scanner stats
|
||||
scannerStats := make([]map[string]interface{}, 0, len(idx.scanners))
|
||||
for path, scanner := range idx.scanners {
|
||||
scannerInfo := map[string]interface{}{
|
||||
"path": path,
|
||||
"lastScanned": scanner.lastScanned.Format(time.RFC3339),
|
||||
"complexity": scanner.complexity,
|
||||
"currentSchedule": scanner.currentSchedule,
|
||||
"quickScanTime": scanner.quickScanTime,
|
||||
"fullScanTime": scanner.fullScanTime,
|
||||
"numDirs": scanner.numDirs,
|
||||
"numFiles": scanner.numFiles,
|
||||
"filesChanged": scanner.filesChanged,
|
||||
"smartModifier": scanner.smartModifier.String(),
|
||||
}
|
||||
scannerStats = append(scannerStats, scannerInfo)
|
||||
}
|
||||
status["scanners"] = scannerStats
|
||||
status["totalScanners"] = len(idx.scanners)
|
||||
|
||||
return status
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
)
|
||||
|
||||
func BenchmarkFillIndex(b *testing.B) {
|
||||
@@ -52,6 +53,80 @@ func TestGetIndex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiScannerMutex verifies that only one scanner can run at a time
|
||||
func TestMultiScannerMutex(t *testing.T) {
|
||||
// Create a mock index with multi-scanner support
|
||||
idx := &Index{
|
||||
Source: settings.Source{
|
||||
Name: "test-multiscanner",
|
||||
Path: "/tmp/test",
|
||||
Config: settings.SourceConfig{
|
||||
DisableIndexing: false,
|
||||
},
|
||||
},
|
||||
mock: true,
|
||||
Directories: make(map[string]*iteminfo.FileInfo),
|
||||
DirectoriesLedger: make(map[string]struct{}),
|
||||
processedInodes: make(map[uint64]struct{}),
|
||||
FoundHardLinks: make(map[string]uint64),
|
||||
scanners: make(map[string]*Scanner),
|
||||
}
|
||||
|
||||
// Create multiple scanners
|
||||
scanner1 := idx.createRootScanner()
|
||||
scanner2 := idx.createChildScanner("/test1/")
|
||||
scanner3 := idx.createChildScanner("/test2/")
|
||||
|
||||
idx.scanners["/"] = scanner1
|
||||
idx.scanners["/test1/"] = scanner2
|
||||
idx.scanners["/test2/"] = scanner3
|
||||
|
||||
// Verify scanners were created
|
||||
if len(idx.scanners) != 3 {
|
||||
t.Errorf("Expected 3 scanners, got %d", len(idx.scanners))
|
||||
}
|
||||
|
||||
// Verify scanner properties
|
||||
if scanner1.scanPath != "/" {
|
||||
t.Error("Scanner 1 should be root scanner")
|
||||
}
|
||||
if scanner2.scanPath == "/" || scanner3.scanPath == "/" {
|
||||
t.Error("Child scanners should not be root")
|
||||
}
|
||||
|
||||
// Test that GetScannerStatus works
|
||||
status := idx.GetScannerStatus()
|
||||
if status["totalScanners"] != 3 {
|
||||
t.Errorf("Expected totalScanners=3, got %v", status["totalScanners"])
|
||||
}
|
||||
if status["activeScanner"] != "" {
|
||||
t.Errorf("Expected no active scanner, got %v", status["activeScanner"])
|
||||
}
|
||||
if status["isScanning"] != false {
|
||||
t.Error("Expected isScanning=false")
|
||||
}
|
||||
|
||||
// Verify scanner counters can be updated
|
||||
idx.activeScannerPath = "/test1/"
|
||||
idx.incrementScannerDirs()
|
||||
idx.incrementScannerFiles()
|
||||
|
||||
if scanner2.numDirs != 1 {
|
||||
t.Errorf("Expected scanner2 numDirs=1, got %d", scanner2.numDirs)
|
||||
}
|
||||
if scanner2.numFiles != 1 {
|
||||
t.Errorf("Expected scanner2 numFiles=1, got %d", scanner2.numFiles)
|
||||
}
|
||||
|
||||
// Verify markFilesChanged works
|
||||
idx.markFilesChanged()
|
||||
if !scanner2.filesChanged {
|
||||
t.Error("Expected scanner2.filesChanged=true")
|
||||
}
|
||||
|
||||
t.Log("Multi-scanner mutex test passed")
|
||||
}
|
||||
|
||||
func TestMakeIndexPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -52,6 +52,8 @@ var ResizableImageTypes = map[string]bool{
|
||||
".png": true,
|
||||
".gif": true,
|
||||
".bmp": true,
|
||||
".tiff": true,
|
||||
".tif": true,
|
||||
".heic": true,
|
||||
".heif": true,
|
||||
".other": false,
|
||||
@@ -308,6 +310,35 @@ func IsMatchingType(extension string, matchType string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// IsMatchingDetectedType checks if the detected MIME type matches the search type.
|
||||
// This uses the actual detected type rather than just the extension, which is more accurate
|
||||
// for ambiguous extensions like .ts (TypeScript vs MPEG Transport Stream)
|
||||
func IsMatchingDetectedType(detectedType string, extension string, matchType string) bool {
|
||||
// Check if the detected MIME type starts with the match type (e.g., "video/", "image/", "audio/")
|
||||
// No need to strip charset since HasPrefix works with it
|
||||
if strings.HasPrefix(detectedType, matchType+"/") {
|
||||
return true
|
||||
}
|
||||
|
||||
// For special categories that don't map directly to MIME type prefixes,
|
||||
// use extension-based checking
|
||||
switch matchType {
|
||||
case "doc":
|
||||
// Check if detected type is application/document or use extension check
|
||||
if strings.HasPrefix(detectedType, "application/document") {
|
||||
return true
|
||||
}
|
||||
return IsDoc(extension)
|
||||
case "text":
|
||||
// Already checked with HasPrefix above
|
||||
return false
|
||||
case "archive":
|
||||
return IsArchive(extension)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// DetectType detects the MIME type of a file and updates the ItemInfo struct.
|
||||
func (i *ItemInfo) DetectType(realPath string, saveContent bool) {
|
||||
name := i.Name
|
||||
@@ -350,7 +381,7 @@ func hasBundleExtension(name string) bool {
|
||||
}
|
||||
|
||||
func HasDocConvertableExtension(name, mimetype string) bool {
|
||||
if !settings.Config.Env.MuPdfAvailable {
|
||||
if !settings.Env.MuPdfAvailable {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(mimetype, "text") {
|
||||
|
||||
@@ -15,25 +15,32 @@ type ItemInfo struct {
|
||||
HasPreview bool `json:"hasPreview"` // whether the file has a thumbnail preview
|
||||
}
|
||||
|
||||
// ExtendedItemInfo extends ItemInfo with optional metadata that's only populated on-demand
|
||||
// This avoids adding memory overhead to indexed items
|
||||
type ExtendedItemInfo struct {
|
||||
ItemInfo
|
||||
Metadata *MediaMetadata `json:"metadata,omitempty"` // optional media metadata (audio/video only)
|
||||
}
|
||||
|
||||
// FileInfo describes a file.
|
||||
// reduced item is non-recursive reduced "Items", used to pass flat items array
|
||||
type FileInfo struct {
|
||||
ItemInfo
|
||||
Files []ItemInfo `json:"files"` // files in the directory
|
||||
Folders []ItemInfo `json:"folders"` // folders in the directory
|
||||
Path string `json:"path"` // path scoped to the associated index
|
||||
Files []ExtendedItemInfo `json:"files,omitempty"` // files in the directory with optional metadata
|
||||
Folders []ItemInfo `json:"folders,omitempty"` // folders in the directory
|
||||
Path string `json:"path,omitempty"` // path scoped to the associated index
|
||||
}
|
||||
|
||||
// AudioMetadata contains metadata extracted from audio files
|
||||
type AudioMetadata struct {
|
||||
Title string `json:"title,omitempty"` // track title
|
||||
// MediaMetadata contains metadata extracted from audio and video files
|
||||
type MediaMetadata struct {
|
||||
Title string `json:"title,omitempty"` // track/video title
|
||||
Artist string `json:"artist,omitempty"` // track artist
|
||||
Album string `json:"album,omitempty"` // album name
|
||||
Year int `json:"year,omitempty"` // release year
|
||||
Genre string `json:"genre,omitempty"` // music genre
|
||||
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
|
||||
AlbumArt string `json:"albumArt,omitempty"` // base64 encoded album art / video thumbnail
|
||||
}
|
||||
|
||||
// for efficiency, a response will be a pointer to the data
|
||||
@@ -42,7 +49,7 @@ 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
|
||||
AudioMeta *AudioMetadata `json:"audioMeta,omitempty"` // audio metadata for audio 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
|
||||
|
||||
@@ -338,9 +338,9 @@ func (fi ItemInfo) ContainsSearchTerm(searchTerm string, options SearchOptions)
|
||||
var fileSize int64
|
||||
extension := filepath.Ext(lowerFileName)
|
||||
|
||||
// Collect file types
|
||||
// Collect file types using the actual detected MIME type, not just extension
|
||||
for _, k := range AllFiletypeOptions {
|
||||
if IsMatchingType(extension, k) {
|
||||
if IsMatchingDetectedType(fi.Type, extension, k) {
|
||||
fileTypes[k] = true
|
||||
}
|
||||
}
|
||||
|
||||
+17
-13
@@ -11,15 +11,17 @@ import (
|
||||
func (idx *Index) CreateMockData(numDirs, numFilesPerDir int) {
|
||||
for i := 0; i < numDirs; i++ {
|
||||
dirPath := utils.GenerateRandomPath(rand.Intn(3) + 1)
|
||||
files := []iteminfo.ItemInfo{} // Slice of FileInfo
|
||||
files := []iteminfo.ExtendedItemInfo{}
|
||||
|
||||
// Simulating files and directories with FileInfo
|
||||
// Simulating files and directories with ExtendedItemInfo
|
||||
for j := 0; j < numFilesPerDir; j++ {
|
||||
newFile := iteminfo.ItemInfo{
|
||||
Name: "file-" + utils.GetRandomTerm() + utils.GetRandomExtension(),
|
||||
Size: rand.Int63n(1000), // Random size
|
||||
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
|
||||
Type: "blob",
|
||||
newFile := iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "file-" + utils.GetRandomTerm() + utils.GetRandomExtension(),
|
||||
Size: rand.Int63n(1000),
|
||||
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour),
|
||||
Type: "blob",
|
||||
},
|
||||
}
|
||||
files = append(files, newFile)
|
||||
}
|
||||
@@ -44,13 +46,15 @@ func CreateMockData(numDirs, numFilesPerDir int) iteminfo.FileInfo {
|
||||
}
|
||||
dir.Folders = append(dir.Folders, newFile)
|
||||
}
|
||||
// Simulating files and directories with FileInfo
|
||||
// Simulating files and directories with ExtendedItemInfo
|
||||
for j := 0; j < numFilesPerDir; j++ {
|
||||
newFile := iteminfo.ItemInfo{
|
||||
Name: "file-" + utils.GetRandomTerm() + utils.GetRandomExtension(),
|
||||
Size: rand.Int63n(1000), // Random size
|
||||
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour), // Random mod time
|
||||
Type: "blob",
|
||||
newFile := iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "file-" + utils.GetRandomTerm() + utils.GetRandomExtension(),
|
||||
Size: rand.Int63n(1000),
|
||||
ModTime: time.Now().Add(-time.Duration(rand.Intn(100)) * time.Hour),
|
||||
Type: "blob",
|
||||
},
|
||||
}
|
||||
dir.Files = append(dir.Files, newFile)
|
||||
}
|
||||
|
||||
+50
-11
@@ -21,17 +21,22 @@ func (idx *Index) UpdateMetadata(info *iteminfo.FileInfo) bool {
|
||||
|
||||
// DeleteMetadata removes the specified path from the index.
|
||||
// If recursive is true and the path is a directory, it will also remove all subdirectories.
|
||||
// NOTE: path should already be an index path (with trailing slash for directories)
|
||||
func (idx *Index) DeleteMetadata(path string, isDir bool, recursive bool) bool {
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
|
||||
indexPath := idx.MakeIndexPath(path)
|
||||
// Normalize the path - ensure trailing slash for directories
|
||||
indexPath := path
|
||||
if isDir {
|
||||
indexPath = utils.AddTrailingSlashIfNotExists(path)
|
||||
}
|
||||
|
||||
if !isDir {
|
||||
// For files, remove from parent directory's Files slice
|
||||
parentPath := idx.MakeIndexPath(filepath.Dir(path))
|
||||
parentPath := utils.AddTrailingSlashIfNotExists(filepath.Dir(strings.TrimSuffix(path, "/")))
|
||||
if parentDir, exists := idx.Directories[parentPath]; exists {
|
||||
fileName := filepath.Base(path)
|
||||
fileName := filepath.Base(strings.TrimSuffix(path, "/"))
|
||||
for i, file := range parentDir.Files {
|
||||
if file.Name == fileName {
|
||||
// Remove file from slice
|
||||
@@ -48,25 +53,26 @@ func (idx *Index) DeleteMetadata(path string, isDir bool, recursive bool) bool {
|
||||
// Remove all subdirectories that start with this path
|
||||
toDelete := []string{}
|
||||
for dirPath := range idx.Directories {
|
||||
if dirPath == indexPath || (len(dirPath) > len(indexPath) &&
|
||||
dirPath[:len(indexPath)] == indexPath &&
|
||||
(indexPath == "/" || dirPath[len(indexPath)] == '/')) {
|
||||
// Match exact path or any subdirectory
|
||||
if dirPath == indexPath || strings.HasPrefix(dirPath, indexPath) {
|
||||
toDelete = append(toDelete, dirPath)
|
||||
}
|
||||
}
|
||||
for _, dirPath := range toDelete {
|
||||
delete(idx.Directories, dirPath)
|
||||
delete(idx.DirectoriesLedger, dirPath)
|
||||
}
|
||||
} else {
|
||||
// Just remove this specific directory
|
||||
delete(idx.Directories, indexPath)
|
||||
delete(idx.DirectoriesLedger, indexPath)
|
||||
}
|
||||
|
||||
// Remove from parent directory's Folders slice
|
||||
if indexPath != "/" {
|
||||
parentPath := idx.MakeIndexPath(filepath.Dir(path))
|
||||
parentPath := utils.AddTrailingSlashIfNotExists(filepath.Dir(strings.TrimSuffix(indexPath, "/")))
|
||||
if parentDir, exists := idx.Directories[parentPath]; exists {
|
||||
dirName := filepath.Base(path)
|
||||
dirName := filepath.Base(strings.TrimSuffix(indexPath, "/"))
|
||||
for i, folder := range parentDir.Folders {
|
||||
if folder.Name == dirName {
|
||||
// Remove folder from slice
|
||||
@@ -113,7 +119,7 @@ func (idx *Index) GetReducedMetadata(target string, isDir bool) (*iteminfo.FileI
|
||||
}
|
||||
return &iteminfo.FileInfo{
|
||||
Path: fp,
|
||||
ItemInfo: item,
|
||||
ItemInfo: item.ItemInfo,
|
||||
}, true
|
||||
}
|
||||
}
|
||||
@@ -153,11 +159,24 @@ func GetIndex(name string) *Index {
|
||||
logger.Errorf("index %s not found", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// ReadOnlyOperation executes a function with read-only access to the index
|
||||
// This provides a safe way to access index data without exposing internal structures
|
||||
func (idx *Index) ReadOnlyOperation(fn func()) {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
fn()
|
||||
}
|
||||
|
||||
// GetDirectories returns the directories map for read-only access
|
||||
// Should only be called within ReadOnlyOperation
|
||||
func (idx *Index) GetDirectories() map[string]*iteminfo.FileInfo {
|
||||
return idx.Directories
|
||||
}
|
||||
|
||||
func GetIndexInfo(sourceName string) (ReducedIndex, error) {
|
||||
idx, ok := indexes[sourceName]
|
||||
if !ok {
|
||||
@@ -178,5 +197,25 @@ func GetIndexInfo(sourceName string) (ReducedIndex, error) {
|
||||
idx.SetUsage(totalBytes)
|
||||
utils.DiskUsageCache.Set(cacheKey, true)
|
||||
}
|
||||
return idx.ReducedIndex, nil
|
||||
|
||||
// Build scanner info for client
|
||||
idx.mu.RLock()
|
||||
scannerInfos := make([]*ScannerInfo, 0, len(idx.scanners))
|
||||
for _, scanner := range idx.scanners {
|
||||
scannerInfos = append(scannerInfos, &ScannerInfo{
|
||||
Path: scanner.scanPath,
|
||||
LastScanned: scanner.lastScanned,
|
||||
Complexity: scanner.complexity,
|
||||
CurrentSchedule: scanner.currentSchedule,
|
||||
QuickScanTime: scanner.quickScanTime,
|
||||
FullScanTime: scanner.fullScanTime,
|
||||
NumDirs: scanner.numDirs,
|
||||
NumFiles: scanner.numFiles,
|
||||
})
|
||||
}
|
||||
idx.mu.RUnlock()
|
||||
|
||||
reducedIdx := idx.ReducedIndex
|
||||
reducedIdx.Scanners = scannerInfos
|
||||
return reducedIdx, nil
|
||||
}
|
||||
@@ -122,9 +122,9 @@ func TestGetFileMetadata(t *testing.T) {
|
||||
func TestUpdateFileMetadata(t *testing.T) {
|
||||
info := &iteminfo.FileInfo{
|
||||
Path: "/testpath/",
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "testfile.txt"},
|
||||
{Name: "anotherfile.txt"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "testfile.txt"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "anotherfile.txt"}},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -169,9 +169,9 @@ func TestSetDirectoryInfo(t *testing.T) {
|
||||
Name: "testpath",
|
||||
Type: "directory",
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "testfile.txt"},
|
||||
{Name: "anotherfile.txt"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "testfile.txt"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "anotherfile.txt"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -182,8 +182,8 @@ func TestSetDirectoryInfo(t *testing.T) {
|
||||
Name: "newPath",
|
||||
Type: "directory",
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "testfile.txt"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "testfile.txt"}},
|
||||
},
|
||||
}
|
||||
index.UpdateMetadata(dir)
|
||||
@@ -211,9 +211,9 @@ func init() {
|
||||
Name: "testpath",
|
||||
Type: "directory",
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "testfile.txt", Size: 100},
|
||||
{Name: "anotherfile.txt", Size: 100},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "testfile.txt", Size: 100}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "anotherfile.txt", Size: 100}},
|
||||
},
|
||||
},
|
||||
"/anotherpath/": {
|
||||
@@ -222,8 +222,8 @@ func init() {
|
||||
Name: "anotherpath",
|
||||
Type: "directory",
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "afile.txt", Size: 100},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "afile.txt", Size: 100}},
|
||||
},
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "directory", Type: "directory", Size: 100},
|
||||
|
||||
+69
-24
@@ -15,28 +15,31 @@ import (
|
||||
var SearchResultsCache = cache.NewCache[[]string](15 * time.Second)
|
||||
|
||||
var (
|
||||
sessionInProgress sync.Map
|
||||
maxSearchResults = 100
|
||||
sessionInProgress sync.Map
|
||||
DefaultSearchResults = 100
|
||||
)
|
||||
|
||||
type SearchResult struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
Modified string `json:"modified,omitempty"`
|
||||
HasPreview bool `json:"hasPreview"`
|
||||
}
|
||||
|
||||
func (idx *Index) Search(search string, scope string, sourceSession string) []SearchResult {
|
||||
func (idx *Index) Search(search string, scope string, sourceSession string, largest bool, limit int) []*SearchResult {
|
||||
// Ensure scope has consistent trailing slash for directory matching
|
||||
if scope != "" && !strings.HasSuffix(scope, "/") {
|
||||
scope = scope + "/"
|
||||
}
|
||||
originalScope := scope // Preserve original scope for largest mode exclusion check
|
||||
if search == "" {
|
||||
scope = ""
|
||||
}
|
||||
runningHash := utils.InsecureRandomIdentifier(4)
|
||||
sessionInProgress.Store(sourceSession, runningHash) // Store the value in the sync.Map
|
||||
searchOptions := iteminfo.ParseSearch(search)
|
||||
results := make(map[string]SearchResult, 0)
|
||||
results := make(map[string]*SearchResult, 0)
|
||||
count := 0
|
||||
var directories []string
|
||||
cachedDirs, ok := SearchResultsCache.Get(idx.Path + scope)
|
||||
@@ -46,11 +49,15 @@ func (idx *Index) Search(search string, scope string, sourceSession string) []Se
|
||||
directories = idx.getDirsInScope(scope)
|
||||
SearchResultsCache.Set(idx.Path+scope, directories)
|
||||
}
|
||||
// When largest=true and no search terms, ensure we run at least once
|
||||
if largest && len(searchOptions.Terms) == 0 {
|
||||
searchOptions.Terms = []string{""}
|
||||
}
|
||||
for _, searchTerm := range searchOptions.Terms {
|
||||
if searchTerm == "" {
|
||||
if searchTerm == "" && !largest {
|
||||
continue
|
||||
}
|
||||
if count > maxSearchResults {
|
||||
if limit > 0 && count >= limit {
|
||||
break
|
||||
}
|
||||
idx.mu.Lock()
|
||||
@@ -59,18 +66,38 @@ func (idx *Index) Search(search string, scope string, sourceSession string) []Se
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if count > maxSearchResults {
|
||||
if limit > 0 && count >= limit {
|
||||
break
|
||||
}
|
||||
reducedDir := iteminfo.ItemInfo{
|
||||
Name: filepath.Base(dirName),
|
||||
Type: "directory",
|
||||
Size: dir.Size,
|
||||
}
|
||||
matches := reducedDir.ContainsSearchTerm(searchTerm, searchOptions)
|
||||
if matches {
|
||||
results[dirName] = SearchResult{Path: dirName, Type: "directory", Size: dir.Size}
|
||||
count++
|
||||
// Skip the scope directory itself when largest=true (only search sub-items)
|
||||
if !(largest && dirName == originalScope) {
|
||||
reducedDir := iteminfo.ItemInfo{
|
||||
Name: filepath.Base(dirName),
|
||||
Type: "directory",
|
||||
Size: dir.Size,
|
||||
}
|
||||
var matches bool
|
||||
if largest {
|
||||
// When largest=true, check size and type conditions, skip name matching
|
||||
largerThan := int64(searchOptions.LargerThan) * 1024 * 1024
|
||||
sizeMatches := largerThan == 0 || reducedDir.Size > largerThan
|
||||
// Check if directories should be excluded (when type:file is specified)
|
||||
dirCondition, hasDirCondition := searchOptions.Conditions["dir"]
|
||||
typeMatches := !hasDirCondition || (hasDirCondition && dirCondition)
|
||||
matches = sizeMatches && typeMatches
|
||||
} else {
|
||||
matches = reducedDir.ContainsSearchTerm(searchTerm, searchOptions)
|
||||
}
|
||||
if matches {
|
||||
results[dirName] = &SearchResult{
|
||||
Path: dirName,
|
||||
Type: "directory",
|
||||
Size: dir.Size,
|
||||
Modified: dir.ModTime.Format(time.RFC3339),
|
||||
HasPreview: dir.HasPreview,
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
// search files first
|
||||
for _, item := range dir.Files {
|
||||
@@ -78,14 +105,32 @@ func (idx *Index) Search(search string, scope string, sourceSession string) []Se
|
||||
value, found := sessionInProgress.Load(sourceSession)
|
||||
if !found || value != runningHash {
|
||||
idx.mu.Unlock()
|
||||
return []SearchResult{}
|
||||
return []*SearchResult{}
|
||||
}
|
||||
if count > maxSearchResults {
|
||||
if limit > 0 && count >= limit {
|
||||
break
|
||||
}
|
||||
matches := item.ContainsSearchTerm(searchTerm, searchOptions)
|
||||
var matches bool
|
||||
if largest {
|
||||
// When largest=true, check size and type conditions, skip name matching
|
||||
largerThan := int64(searchOptions.LargerThan) * 1024 * 1024
|
||||
sizeMatches := largerThan == 0 || item.Size > largerThan
|
||||
// Check if only files should be included (when type:file is specified)
|
||||
dirCondition, hasDirCondition := searchOptions.Conditions["dir"]
|
||||
// For files: include if no dir condition, or if dir condition is false (type:file)
|
||||
typeMatches := !hasDirCondition || (hasDirCondition && !dirCondition)
|
||||
matches = sizeMatches && typeMatches
|
||||
} else {
|
||||
matches = item.ContainsSearchTerm(searchTerm, searchOptions)
|
||||
}
|
||||
if matches {
|
||||
results[fullPath] = SearchResult{Path: fullPath, Type: item.Type, Size: item.Size}
|
||||
results[fullPath] = &SearchResult{
|
||||
Path: fullPath,
|
||||
Type: item.Type,
|
||||
Size: item.Size,
|
||||
Modified: item.ModTime.Format(time.RFC3339),
|
||||
HasPreview: item.HasPreview,
|
||||
}
|
||||
count++
|
||||
}
|
||||
}
|
||||
@@ -94,7 +139,7 @@ func (idx *Index) Search(search string, scope string, sourceSession string) []Se
|
||||
}
|
||||
|
||||
// Sort keys based on the number of elements in the path after splitting by "/"
|
||||
sortedKeys := make([]SearchResult, 0, len(results))
|
||||
sortedKeys := make([]*SearchResult, 0, len(results))
|
||||
for _, v := range results {
|
||||
sortedKeys = append(sortedKeys, v)
|
||||
}
|
||||
|
||||
+145
-34
@@ -24,7 +24,7 @@ func BenchmarkSearchAllIndexes(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Execute the SearchAllIndexes function
|
||||
for _, term := range searchTerms {
|
||||
idx.Search(term, "/", "test")
|
||||
idx.Search(term, "/", "test", false, DefaultSearchResults)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func TestSearchWhileIndexing(t *testing.T) {
|
||||
for i := 0; i < 5; i++ {
|
||||
go idx.CreateMockData(100, 100) // Creating mock data concurrently
|
||||
for _, term := range searchTerms {
|
||||
go idx.Search(term, "/", "test") // Search concurrently
|
||||
go idx.Search(term, "/", "test", false, DefaultSearchResults) // Search concurrently
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,41 +92,41 @@ func TestSearchWhileIndexing(t *testing.T) {
|
||||
func TestSearchIndexes(t *testing.T) {
|
||||
index := Index{
|
||||
Directories: map[string]*iteminfo.FileInfo{
|
||||
"/": {Files: []iteminfo.ItemInfo{{Name: "audio-one.wav", Type: "audio"}}},
|
||||
"/test/": {Files: []iteminfo.ItemInfo{{Name: "audio-one.wav", Type: "audio"}}},
|
||||
"/test/path/": {Files: []iteminfo.ItemInfo{{Name: "file.txt", Type: "text"}}},
|
||||
"/new/test/": {Files: []iteminfo.ItemInfo{
|
||||
{Name: "audio.wav", Type: "audio"},
|
||||
{Name: "video.mp4", Type: "video"},
|
||||
{Name: "video.MP4", Type: "video"},
|
||||
"/": {Files: []iteminfo.ExtendedItemInfo{{ItemInfo: iteminfo.ItemInfo{Name: "audio-one.wav", Type: "audio"}}}},
|
||||
"/test/": {Files: []iteminfo.ExtendedItemInfo{{ItemInfo: iteminfo.ItemInfo{Name: "audio-one.wav", Type: "audio"}}}},
|
||||
"/test/path/": {Files: []iteminfo.ExtendedItemInfo{{ItemInfo: iteminfo.ItemInfo{Name: "file.txt", Type: "text"}}}},
|
||||
"/new/test/": {Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "audio.wav", Type: "audio"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "video.mp4", Type: "video"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "video.MP4", Type: "video"}},
|
||||
}},
|
||||
"/first Dir/": {
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "space jam.zip", Size: 100, Type: "archive"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "space jam.zip", Size: 100, Type: "archive"}},
|
||||
},
|
||||
},
|
||||
"/new/test/path/": {Files: []iteminfo.ItemInfo{{Name: "archive.zip", Type: "archive"}}},
|
||||
"/new/test/path/": {Files: []iteminfo.ExtendedItemInfo{{ItemInfo: iteminfo.ItemInfo{Name: "archive.zip", Type: "archive"}}}},
|
||||
"/firstDir/": {
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "archive.zip", Size: 100, Type: "archive"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "archive.zip", Size: 100, Type: "archive"}},
|
||||
},
|
||||
Folders: []iteminfo.ItemInfo{
|
||||
{Name: "thisIsDir", Type: "directory", Size: 2 * 1024 * 1024},
|
||||
},
|
||||
},
|
||||
"/firstDir/thisIsDir/": {
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "hi.txt", Type: "text"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "hi.txt", Type: "text"}},
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 2 * 1024 * 1024,
|
||||
},
|
||||
},
|
||||
"/new+folder/Pictures/": {
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "consoletest.mp4", Size: 196091904, Type: "video/mp4"},
|
||||
{Name: "playwright.gif", Size: 2416640, Type: "image/gif"},
|
||||
{Name: "toggle.gif", Size: 65536, Type: "image/gif"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "consoletest.mp4", Size: 196091904, Type: "video/mp4"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "playwright.gif", Size: 2416640, Type: "image/gif"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "toggle.gif", Size: 65536, Type: "image/gif"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -135,12 +135,12 @@ func TestSearchIndexes(t *testing.T) {
|
||||
tests := []struct {
|
||||
search string
|
||||
scope string
|
||||
expectedResult []SearchResult
|
||||
expectedResult []*SearchResult
|
||||
}{
|
||||
{
|
||||
search: "audio",
|
||||
scope: "/new/",
|
||||
expectedResult: []SearchResult{
|
||||
expectedResult: []*SearchResult{
|
||||
{
|
||||
Path: "/new/test/audio.wav",
|
||||
Type: "audio",
|
||||
@@ -151,7 +151,7 @@ func TestSearchIndexes(t *testing.T) {
|
||||
{
|
||||
search: "test",
|
||||
scope: "/",
|
||||
expectedResult: []SearchResult{
|
||||
expectedResult: []*SearchResult{
|
||||
{
|
||||
Path: "/test/",
|
||||
Type: "directory",
|
||||
@@ -172,7 +172,7 @@ func TestSearchIndexes(t *testing.T) {
|
||||
{
|
||||
search: "archive",
|
||||
scope: "/",
|
||||
expectedResult: []SearchResult{
|
||||
expectedResult: []*SearchResult{
|
||||
{
|
||||
Path: "/firstDir/archive.zip",
|
||||
Type: "archive",
|
||||
@@ -188,7 +188,7 @@ func TestSearchIndexes(t *testing.T) {
|
||||
{
|
||||
search: "arch",
|
||||
scope: "/firstDir/",
|
||||
expectedResult: []SearchResult{
|
||||
expectedResult: []*SearchResult{
|
||||
{
|
||||
Path: "/firstDir/archive.zip",
|
||||
Type: "archive",
|
||||
@@ -199,7 +199,7 @@ func TestSearchIndexes(t *testing.T) {
|
||||
{
|
||||
search: "space jam",
|
||||
scope: "/first Dir/",
|
||||
expectedResult: []SearchResult{
|
||||
expectedResult: []*SearchResult{
|
||||
{
|
||||
Path: "/first Dir/space jam.zip",
|
||||
Type: "archive",
|
||||
@@ -210,7 +210,7 @@ func TestSearchIndexes(t *testing.T) {
|
||||
{
|
||||
search: "isdir",
|
||||
scope: "/",
|
||||
expectedResult: []SearchResult{
|
||||
expectedResult: []*SearchResult{
|
||||
{
|
||||
Path: "/firstDir/thisIsDir/",
|
||||
Type: "directory",
|
||||
@@ -221,7 +221,7 @@ func TestSearchIndexes(t *testing.T) {
|
||||
{
|
||||
search: "IsDir type:largerThan=1",
|
||||
scope: "/",
|
||||
expectedResult: []SearchResult{
|
||||
expectedResult: []*SearchResult{
|
||||
{
|
||||
Path: "/firstDir/thisIsDir/",
|
||||
Type: "directory",
|
||||
@@ -232,7 +232,7 @@ func TestSearchIndexes(t *testing.T) {
|
||||
{
|
||||
search: "video",
|
||||
scope: "/",
|
||||
expectedResult: []SearchResult{
|
||||
expectedResult: []*SearchResult{
|
||||
{
|
||||
Path: "/new/test/video.MP4",
|
||||
Type: "video",
|
||||
@@ -248,7 +248,7 @@ func TestSearchIndexes(t *testing.T) {
|
||||
{
|
||||
search: "audio",
|
||||
scope: "/",
|
||||
expectedResult: []SearchResult{
|
||||
expectedResult: []*SearchResult{
|
||||
{
|
||||
Path: "/audio-one.wav",
|
||||
Type: "audio",
|
||||
@@ -269,7 +269,7 @@ func TestSearchIndexes(t *testing.T) {
|
||||
{
|
||||
search: "cons",
|
||||
scope: "/new+folder/Pictures/",
|
||||
expectedResult: []SearchResult{
|
||||
expectedResult: []*SearchResult{
|
||||
{
|
||||
Path: "/new+folder/Pictures/consoletest.mp4",
|
||||
Type: "video/mp4",
|
||||
@@ -280,7 +280,7 @@ func TestSearchIndexes(t *testing.T) {
|
||||
{
|
||||
search: "toggle",
|
||||
scope: "/new+folder/Pictures/",
|
||||
expectedResult: []SearchResult{
|
||||
expectedResult: []*SearchResult{
|
||||
{
|
||||
Path: "/new+folder/Pictures/toggle.gif",
|
||||
Type: "image/gif",
|
||||
@@ -292,8 +292,119 @@ func TestSearchIndexes(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.search, func(t *testing.T) {
|
||||
result := index.Search(tt.search, tt.scope, "")
|
||||
assert.ElementsMatch(t, tt.expectedResult, result)
|
||||
result := index.Search(tt.search, tt.scope, "", false, DefaultSearchResults)
|
||||
// Convert results to comparable format (without Modified field)
|
||||
expected := make([]SearchResult, len(tt.expectedResult))
|
||||
for i, r := range tt.expectedResult {
|
||||
expected[i] = SearchResult{Path: r.Path, Type: r.Type, Size: r.Size}
|
||||
}
|
||||
actual := make([]SearchResult, len(result))
|
||||
for i, r := range result {
|
||||
actual[i] = SearchResult{Path: r.Path, Type: r.Type, Size: r.Size}
|
||||
}
|
||||
assert.ElementsMatch(t, expected, actual)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchLargestModeExcludesRoot(t *testing.T) {
|
||||
index := Index{
|
||||
Directories: map[string]*iteminfo.FileInfo{
|
||||
"/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "root-file.txt", Type: "text", Size: 100}},
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 3209322496, // Large root directory size
|
||||
},
|
||||
},
|
||||
"/subdir/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "sub-file.txt", Type: "text", Size: 200}},
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 5 * 1024 * 1024, // 5MB subdirectory
|
||||
},
|
||||
},
|
||||
"/another-dir/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "large-file.bin", Type: "binary", Size: 10 * 1024 * 1024}}, // 10MB file
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 10 * 1024 * 1024,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Test that when largest=true and scope="/", the root directory "/" is NOT included
|
||||
result := index.Search("", "/", "test-session", true, DefaultSearchResults)
|
||||
|
||||
// Verify that "/" is NOT in the results
|
||||
rootFound := false
|
||||
for _, r := range result {
|
||||
if r.Path == "/" {
|
||||
rootFound = true
|
||||
t.Errorf("Root directory '/' should not be included in results when largest=true, but found: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
if rootFound {
|
||||
t.Error("Root directory was incorrectly included in results")
|
||||
}
|
||||
|
||||
// The test passes if root is excluded
|
||||
// (subdirectories and files may or may not be included depending on size filtering logic)
|
||||
}
|
||||
|
||||
func TestSearchLargestModeExcludesScopeDirectory(t *testing.T) {
|
||||
index := Index{
|
||||
Directories: map[string]*iteminfo.FileInfo{
|
||||
"/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "root-file.txt", Type: "text", Size: 50}},
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 100,
|
||||
},
|
||||
},
|
||||
"/test/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file1.txt", Type: "text", Size: 100}},
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 2 * 1024 * 1024, // 2MB directory
|
||||
},
|
||||
},
|
||||
"/test/subdir/": {
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "file2.txt", Type: "text", Size: 200}},
|
||||
},
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Size: 3 * 1024 * 1024, // 3MB subdirectory
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Test that when largest=true and scope="/test/", the scope directory "/test/" is NOT included
|
||||
result := index.Search("", "/test/", "test-session", true, DefaultSearchResults)
|
||||
|
||||
// Verify that "/test/" is NOT in the results
|
||||
scopeDirFound := false
|
||||
for _, r := range result {
|
||||
if r.Path == "/test/" {
|
||||
scopeDirFound = true
|
||||
t.Errorf("Scope directory '/test/' should not be included in results when largest=true, but found: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
if scopeDirFound {
|
||||
t.Error("Scope directory was incorrectly included in results")
|
||||
}
|
||||
|
||||
// The main assertion: scope directory should be excluded
|
||||
// Note: When search is empty, scope gets cleared, so we search all directories
|
||||
// but still exclude the original scope directory "/test/" from results
|
||||
// Files and subdirectories may or may not be included depending on size/type filtering
|
||||
}
|
||||
@@ -26,8 +26,9 @@ func getPartitionSize(path string) (uint64, error) {
|
||||
|
||||
func getFileDetails(sys any) (uint64, uint64, uint64, bool) {
|
||||
if stat, ok := sys.(*syscall.Stat_t); ok {
|
||||
// Use allocated size for `du`-like behavior
|
||||
realSize := uint64(stat.Blocks * 512)
|
||||
// Use actual file size, not allocated blocks
|
||||
// This matches what ls -l shows and what FileInfoFaster returns
|
||||
realSize := uint64(stat.Size)
|
||||
return realSize, uint64(stat.Nlink), stat.Ino, true
|
||||
}
|
||||
return 0, 1, 0, false
|
||||
|
||||
@@ -21,17 +21,32 @@ const (
|
||||
FormatBmp
|
||||
// FormatHeic is a Format of type Heic
|
||||
FormatHeic
|
||||
// FormatWebp is a Format of type Webp
|
||||
FormatWebp
|
||||
// FormatPbm is a Format of type Pbm
|
||||
FormatPbm
|
||||
// FormatPgm is a Format of type Pgm
|
||||
FormatPgm
|
||||
// FormatPpm is a Format of type Ppm
|
||||
FormatPpm
|
||||
// FormatPam is a Format of type Pam
|
||||
FormatPam
|
||||
)
|
||||
|
||||
const _FormatName = "jpegpnggiftiffbmpheic"
|
||||
const _FormatName = "jpegpnggiftiffbmpheicwebppbmpgmppmpam"
|
||||
|
||||
var _FormatMap = map[Format]string{
|
||||
0: _FormatName[0:4], // jpeg
|
||||
1: _FormatName[4:7], // png
|
||||
2: _FormatName[7:10], // gif
|
||||
3: _FormatName[10:14], // tiff
|
||||
4: _FormatName[14:17], // bmp
|
||||
5: _FormatName[17:21], // heic
|
||||
0: _FormatName[0:4], // jpeg
|
||||
1: _FormatName[4:7], // png
|
||||
2: _FormatName[7:10], // gif
|
||||
3: _FormatName[10:14], // tiff
|
||||
4: _FormatName[14:17], // bmp
|
||||
5: _FormatName[17:21], // heic
|
||||
6: _FormatName[21:25], // webp
|
||||
7: _FormatName[25:28], // pbm
|
||||
8: _FormatName[28:31], // pgm
|
||||
9: _FormatName[31:34], // ppm
|
||||
10: _FormatName[34:37], // pam
|
||||
}
|
||||
|
||||
// String implements the Stringer interface.
|
||||
@@ -43,12 +58,17 @@ func (x Format) String() string {
|
||||
}
|
||||
|
||||
var _FormatValue = map[string]Format{
|
||||
_FormatName[0:4]: 0, // jpeg
|
||||
_FormatName[4:7]: 1, // png
|
||||
_FormatName[7:10]: 2, // gif
|
||||
_FormatName[10:14]: 3, // tiff
|
||||
_FormatName[14:17]: 4, // bmp
|
||||
_FormatName[17:21]: 5, // heic
|
||||
_FormatName[0:4]: 0, // jpeg
|
||||
_FormatName[4:7]: 1, // png
|
||||
_FormatName[7:10]: 2, // gif
|
||||
_FormatName[10:14]: 3, // tiff
|
||||
_FormatName[14:17]: 4, // bmp
|
||||
_FormatName[17:21]: 5, // heic
|
||||
_FormatName[21:25]: 6, // webp
|
||||
_FormatName[25:28]: 7, // pbm
|
||||
_FormatName[28:31]: 8, // pgm
|
||||
_FormatName[31:34]: 9, // ppm
|
||||
_FormatName[34:37]: 10, // pam
|
||||
}
|
||||
|
||||
// ParseFormat attempts to convert a string to a Format
|
||||
|
||||
@@ -276,7 +276,7 @@ func TestService_Resize(t *testing.T) {
|
||||
|
||||
for name, test := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
svc := NewPreviewGenerator(1, "", "")
|
||||
svc := NewPreviewGenerator(1, "")
|
||||
source := test.source(t)
|
||||
defer source.Close()
|
||||
|
||||
@@ -434,7 +434,7 @@ func TestService_FormatFromExtension(t *testing.T) {
|
||||
|
||||
for name, test := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
svc := NewPreviewGenerator(1, "", "")
|
||||
svc := NewPreviewGenerator(1, "")
|
||||
got, err := svc.FormatFromExtension(test.ext)
|
||||
require.Truef(t, errors.Is(err, test.wantErr), "error = %v, wantErr %v", err, test.wantErr)
|
||||
if err != nil {
|
||||
|
||||
+23
-97
@@ -9,9 +9,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -31,18 +29,16 @@ var (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
ffmpegPath string
|
||||
ffprobePath string
|
||||
fileCache diskcache.Interface
|
||||
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.VideoService
|
||||
imageService *ffmpeg.ImageService
|
||||
videoService *ffmpeg.FFmpegService
|
||||
imageService *ffmpeg.FFmpegService
|
||||
}
|
||||
|
||||
func NewPreviewGenerator(concurrencyLimit int, ffmpegPath string, cacheDir string) *Service {
|
||||
func NewPreviewGenerator(concurrencyLimit int, cacheDir string) *Service {
|
||||
if concurrencyLimit < 1 {
|
||||
concurrencyLimit = 1
|
||||
}
|
||||
@@ -50,21 +46,14 @@ func NewPreviewGenerator(concurrencyLimit int, ffmpegPath string, cacheDir strin
|
||||
ffmpegConcurrencyLimit := (concurrencyLimit + 1) / 2
|
||||
var fileCache diskcache.Interface
|
||||
// Use file cache if cacheDir is specified
|
||||
if cacheDir != "" {
|
||||
var err error
|
||||
fileCache, err = diskcache.NewFileCache(cacheDir)
|
||||
if err != nil {
|
||||
if cacheDir == "tmp" {
|
||||
logger.Error("The cache dir could not be created. Make sure the user that you executed the program with has access to create directories in the local path. filebrowser is trying to use the default `server.cacheDir: tmp` , but you can change this location if you need to. Please see configuration wiki for more information about this error. https://github.com/gtsteffaniak/filebrowser/wiki/Configuration")
|
||||
}
|
||||
logger.Fatalf("failed to create file cache path, which is now require to run the server: %v", err)
|
||||
}
|
||||
} else {
|
||||
// No-op cache if no cacheDir is specified
|
||||
fileCache = diskcache.NewNoOp()
|
||||
var err error
|
||||
fileCache, err = diskcache.NewFileCache(cacheDir)
|
||||
if err != nil {
|
||||
logger.Error("The cache dir could not be created. Make sure the user that you executed the program with has access to create directories in the local path. See ")
|
||||
logger.Fatalf("failed to create file cache path, which is now require to run the server: %v", err)
|
||||
}
|
||||
// Create directories recursively
|
||||
err := os.MkdirAll(filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "docs"), fileutils.PermDir)
|
||||
err = os.MkdirAll(filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "docs"), fileutils.PermDir)
|
||||
if err != nil {
|
||||
logger.Error(err)
|
||||
}
|
||||
@@ -76,38 +65,17 @@ func NewPreviewGenerator(concurrencyLimit int, ffmpegPath string, cacheDir strin
|
||||
if err != nil {
|
||||
logger.Error(err)
|
||||
}
|
||||
ffmpegMainPath, err := CheckValidFFmpeg(ffmpegPath)
|
||||
if err != nil && ffmpegPath != "" {
|
||||
logger.Fatalf("the configured ffmpeg path does not contain a valid ffmpeg binary %s, err: %v", ffmpegPath, err)
|
||||
}
|
||||
ffprobePath, errprobe := CheckValidFFprobe(ffmpegPath)
|
||||
if errprobe != nil && ffmpegPath != "" {
|
||||
logger.Fatalf("the configured ffmpeg path is not a valid ffprobe binary %s, err: %v", ffmpegPath, err)
|
||||
}
|
||||
if errprobe == nil && err == nil {
|
||||
settings.Config.Integrations.Media.FfmpegPath = filepath.Base(ffmpegMainPath)
|
||||
}
|
||||
logger.Debugf("Media Enabled : %v", ffmpegMainPath != "" && ffprobePath != "")
|
||||
logger.Debugf("FFmpeg Concurrency Limit : %d", ffmpegConcurrencyLimit)
|
||||
settings.Config.Env.MuPdfAvailable = docEnabled()
|
||||
logger.Debugf("MuPDF Enabled : %v", settings.Config.Env.MuPdfAvailable)
|
||||
|
||||
// Create shared ffmpeg services
|
||||
var videoService *ffmpeg.VideoService
|
||||
var imageService *ffmpeg.ImageService
|
||||
|
||||
if ffmpegMainPath != "" && ffprobePath != "" {
|
||||
videoService = ffmpeg.NewVideoService(ffmpegMainPath, ffprobePath, ffmpegConcurrencyLimit, settings.Config.Integrations.Media.Debug)
|
||||
imageService = ffmpeg.NewImageService(ffmpegMainPath, ffprobePath, concurrencyLimit, settings.Config.Integrations.Media.Debug, filepath.Join(settings.Config.Server.CacheDir, "heic"))
|
||||
}
|
||||
videoService := ffmpeg.NewFFmpegService(ffmpegConcurrencyLimit, settings.Config.Integrations.Media.Debug, "")
|
||||
imageService := ffmpeg.NewFFmpegService(concurrencyLimit, settings.Config.Integrations.Media.Debug, filepath.Join(settings.Config.Server.CacheDir, "heic"))
|
||||
|
||||
settings.Env.MuPdfAvailable = docEnabled()
|
||||
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
|
||||
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
|
||||
return &Service{
|
||||
ffmpegPath: ffmpegMainPath,
|
||||
ffprobePath: ffprobePath,
|
||||
fileCache: fileCache,
|
||||
debug: settings.Config.Integrations.Media.Debug,
|
||||
// CGo library (go-fitz) is NOT thread-safe - only 1 concurrent operation allowed
|
||||
docSemaphore: make(chan struct{}, 1),
|
||||
fileCache: fileCache,
|
||||
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,
|
||||
@@ -142,11 +110,11 @@ func (s *Service) releaseOffice() {
|
||||
<-s.officeSem
|
||||
}
|
||||
|
||||
func StartPreviewGenerator(concurrencyLimit int, ffmpegPath, cacheDir string) error {
|
||||
func StartPreviewGenerator(concurrencyLimit int, cacheDir string) error {
|
||||
if service != nil {
|
||||
logger.Errorf("WARNING: StartPreviewGenerator called multiple times! This will create multiple semaphores!")
|
||||
}
|
||||
service = NewPreviewGenerator(concurrencyLimit, ffmpegPath, cacheDir)
|
||||
service = NewPreviewGenerator(concurrencyLimit, cacheDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -162,10 +130,10 @@ func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, prev
|
||||
|
||||
// Generate fast cache key based on file metadata
|
||||
var cacheHash string
|
||||
if file.AudioMeta != nil && file.AudioMeta.AlbumArt != "" {
|
||||
if file.Metadata != nil && file.Metadata.AlbumArt != "" {
|
||||
// For audio with album art, hash the album art content
|
||||
hasher := md5.New()
|
||||
_, _ = hasher.Write([]byte(file.AudioMeta.AlbumArt))
|
||||
_, _ = hasher.Write([]byte(file.Metadata.AlbumArt))
|
||||
cacheHash = hex.EncodeToString(hasher.Sum(nil))
|
||||
} else {
|
||||
// For all other files, use fast metadata-based hash
|
||||
@@ -255,8 +223,8 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
|
||||
}
|
||||
} else if strings.HasPrefix(file.Type, "audio") {
|
||||
// Extract album artwork from audio files
|
||||
if file.AudioMeta != nil && file.AudioMeta.AlbumArt != "" {
|
||||
imageBytes, err = base64.StdEncoding.DecodeString(file.AudioMeta.AlbumArt)
|
||||
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)
|
||||
}
|
||||
@@ -358,45 +326,3 @@ func DelThumbs(ctx context.Context, file iteminfo.ExtendedFileInfo) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CheckValidFFmpeg checks for a valid ffmpeg executable.
|
||||
// If a path is provided, it looks there. Otherwise, it searches the system's PATH.
|
||||
func CheckValidFFmpeg(path string) (string, error) {
|
||||
return checkExecutable(path, "ffmpeg")
|
||||
}
|
||||
|
||||
// CheckValidFFprobe checks for a valid ffprobe executable.
|
||||
// If a path is provided, it looks there. Otherwise, it searches the system's PATH.
|
||||
func CheckValidFFprobe(path string) (string, error) {
|
||||
return checkExecutable(path, "ffprobe")
|
||||
}
|
||||
|
||||
// checkExecutable is an internal helper function to find and validate an executable.
|
||||
// It checks a specific path if provided, otherwise falls back to searching the system PATH.
|
||||
func checkExecutable(providedPath, execName string) (string, error) {
|
||||
// Add .exe extension for Windows systems
|
||||
if runtime.GOOS == "windows" {
|
||||
execName += ".exe"
|
||||
}
|
||||
|
||||
var finalPath string
|
||||
var err error
|
||||
|
||||
if providedPath != "" {
|
||||
// A path was provided, so we'll use it.
|
||||
finalPath = filepath.Join(providedPath, execName)
|
||||
} else {
|
||||
// No path was provided, so search the system's PATH for the executable.
|
||||
finalPath, err = exec.LookPath(execName)
|
||||
if err != nil {
|
||||
// The executable was not found in the system's PATH.
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the executable is valid by running the "-version" command.
|
||||
cmd := exec.Command(finalPath, "-version")
|
||||
err = cmd.Run()
|
||||
|
||||
return finalPath, err
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
#listingView.normal .item,
|
||||
.button,
|
||||
button,
|
||||
.button-action,
|
||||
.input,
|
||||
input,
|
||||
#listingView.gallery .item img,
|
||||
|
||||
+851
-62
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -37,6 +37,129 @@ definitions:
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
http.ShareResponse:
|
||||
properties:
|
||||
allowCreate:
|
||||
description: allow creating files
|
||||
type: boolean
|
||||
allowDelete:
|
||||
type: boolean
|
||||
allowModify:
|
||||
description: allow modifying files
|
||||
type: boolean
|
||||
allowReplacements:
|
||||
description: allow replacements of files
|
||||
type: boolean
|
||||
allowedUsernames:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
banner:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
disableAnonymous:
|
||||
type: boolean
|
||||
disableDownload:
|
||||
description: don't allow downloading files
|
||||
type: boolean
|
||||
disableFileViewer:
|
||||
description: don't allow viewing files
|
||||
type: boolean
|
||||
disableShareCard:
|
||||
type: boolean
|
||||
disableSidebar:
|
||||
type: boolean
|
||||
disableThumbnails:
|
||||
type: boolean
|
||||
downloadURL:
|
||||
type: string
|
||||
downloads:
|
||||
type: integer
|
||||
downloadsLimit:
|
||||
type: integer
|
||||
enableOnlyOffice:
|
||||
type: boolean
|
||||
enforceDarkLightMode:
|
||||
description: '"dark" or "light"'
|
||||
type: string
|
||||
expire:
|
||||
type: integer
|
||||
extractEmbeddedSubtitles:
|
||||
description: can be io intensive for large files and take 10-30 seconds.
|
||||
type: boolean
|
||||
favicon:
|
||||
type: string
|
||||
hasPassword:
|
||||
type: boolean
|
||||
hash:
|
||||
type: string
|
||||
hideNavButtons:
|
||||
type: boolean
|
||||
keepAfterExpiration:
|
||||
type: boolean
|
||||
maxBandwidth:
|
||||
type: integer
|
||||
password_hash:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
pathExists:
|
||||
type: boolean
|
||||
perUserDownloadLimit:
|
||||
type: boolean
|
||||
quickDownload:
|
||||
type: boolean
|
||||
shareTheme:
|
||||
type: string
|
||||
shareType:
|
||||
description: 'type of share: normal, upload, max'
|
||||
type: string
|
||||
sidebarLinks:
|
||||
description: customizable sidebar links
|
||||
items:
|
||||
$ref: '#/definitions/users.SidebarLink'
|
||||
type: array
|
||||
source:
|
||||
description: Override embedded field to show source name
|
||||
type: string
|
||||
themeColor:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
token:
|
||||
description: |-
|
||||
Token is a random value that will only be set when PasswordHash is set. It is
|
||||
URL-Safe and is used to download links in password-protected shares via a
|
||||
query arg.
|
||||
type: string
|
||||
userDownloads:
|
||||
additionalProperties:
|
||||
type: integer
|
||||
description: Track downloads per username
|
||||
type: object
|
||||
userID:
|
||||
type: integer
|
||||
username:
|
||||
type: string
|
||||
version:
|
||||
type: integer
|
||||
viewMode:
|
||||
description: 'default view mode for anonymous users: "list", "compact", "normal",
|
||||
"gallery"'
|
||||
type: string
|
||||
type: object
|
||||
http.duplicateGroup:
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
files:
|
||||
items:
|
||||
$ref: '#/definitions/indexing.SearchResult'
|
||||
type: array
|
||||
size:
|
||||
type: integer
|
||||
type: object
|
||||
indexing.IndexStatus:
|
||||
enum:
|
||||
- ready
|
||||
@@ -49,8 +172,9 @@ definitions:
|
||||
- UNAVAILABLE
|
||||
indexing.ReducedIndex:
|
||||
properties:
|
||||
assessment:
|
||||
type: string
|
||||
complexity:
|
||||
description: '0-10 scale: 0=unknown, 1=simple, 2-6=normal, 7-9=complex, 10=highlyComplex'
|
||||
type: integer
|
||||
fullScanDurationSeconds:
|
||||
type: integer
|
||||
lastIndexedUnixTime:
|
||||
@@ -65,6 +189,10 @@ definitions:
|
||||
type: integer
|
||||
quickScanDurationSeconds:
|
||||
type: integer
|
||||
scanners:
|
||||
items:
|
||||
$ref: '#/definitions/indexing.ScannerInfo'
|
||||
type: array
|
||||
status:
|
||||
$ref: '#/definitions/indexing.IndexStatus'
|
||||
total:
|
||||
@@ -72,8 +200,34 @@ definitions:
|
||||
used:
|
||||
type: integer
|
||||
type: object
|
||||
indexing.ScannerInfo:
|
||||
properties:
|
||||
complexity:
|
||||
description: '0-10 scale: 0=unknown, 1=simple, 2-6=normal, 7-9=complex, 10=highlyComplex'
|
||||
type: integer
|
||||
currentSchedule:
|
||||
type: integer
|
||||
fullScanTime:
|
||||
type: integer
|
||||
isRoot:
|
||||
type: boolean
|
||||
lastScanned:
|
||||
type: string
|
||||
numDirs:
|
||||
type: integer
|
||||
numFiles:
|
||||
type: integer
|
||||
path:
|
||||
type: string
|
||||
quickScanTime:
|
||||
type: integer
|
||||
type: object
|
||||
indexing.SearchResult:
|
||||
properties:
|
||||
hasPreview:
|
||||
type: boolean
|
||||
modified:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
size:
|
||||
@@ -81,12 +235,37 @@ definitions:
|
||||
type:
|
||||
type: string
|
||||
type: object
|
||||
iteminfo.ExtendedItemInfo:
|
||||
properties:
|
||||
hasPreview:
|
||||
description: whether the file has a thumbnail preview
|
||||
type: boolean
|
||||
hidden:
|
||||
description: whether the file is hidden
|
||||
type: boolean
|
||||
metadata:
|
||||
allOf:
|
||||
- $ref: '#/definitions/iteminfo.MediaMetadata'
|
||||
description: optional media metadata (audio/video only)
|
||||
modified:
|
||||
description: modification time
|
||||
type: string
|
||||
name:
|
||||
description: name of the file
|
||||
type: string
|
||||
size:
|
||||
description: length in bytes for regular files
|
||||
type: integer
|
||||
type:
|
||||
description: type of the file, either "directory" or a file mimetype
|
||||
type: string
|
||||
type: object
|
||||
iteminfo.FileInfo:
|
||||
properties:
|
||||
files:
|
||||
description: files in the directory
|
||||
description: files in the directory with optional metadata
|
||||
items:
|
||||
$ref: '#/definitions/iteminfo.ItemInfo'
|
||||
$ref: '#/definitions/iteminfo.ExtendedItemInfo'
|
||||
type: array
|
||||
folders:
|
||||
description: folders in the directory
|
||||
@@ -136,6 +315,33 @@ definitions:
|
||||
description: type of the file, either "directory" or a file mimetype
|
||||
type: string
|
||||
type: object
|
||||
iteminfo.MediaMetadata:
|
||||
properties:
|
||||
album:
|
||||
description: album name
|
||||
type: string
|
||||
albumArt:
|
||||
description: base64 encoded album art / video thumbnail
|
||||
type: string
|
||||
artist:
|
||||
description: track artist
|
||||
type: string
|
||||
duration:
|
||||
description: duration in seconds
|
||||
type: integer
|
||||
genre:
|
||||
description: music/video genre
|
||||
type: string
|
||||
title:
|
||||
description: track/video title
|
||||
type: string
|
||||
track:
|
||||
description: track number
|
||||
type: integer
|
||||
year:
|
||||
description: release year
|
||||
type: integer
|
||||
type: object
|
||||
settings.Auth:
|
||||
properties:
|
||||
adminPassword:
|
||||
@@ -488,6 +694,10 @@ definitions:
|
||||
description: path to the cache directory, used for thumbnails and other cached
|
||||
files
|
||||
type: string
|
||||
cacheDirCleanup:
|
||||
description: 'whether to automatically cleanup the cache directory (default:
|
||||
true)'
|
||||
type: boolean
|
||||
database:
|
||||
description: path to the database file
|
||||
type: string
|
||||
@@ -514,6 +724,9 @@ definitions:
|
||||
description: used by integrations if set, this is the base domain that an
|
||||
integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
type: string
|
||||
listen:
|
||||
description: 'address to listen on (default: 0.0.0.0)'
|
||||
type: string
|
||||
logging:
|
||||
items:
|
||||
$ref: '#/definitions/settings.LogConfig'
|
||||
@@ -583,26 +796,28 @@ definitions:
|
||||
description: conditional rules to apply when indexing to include/exclude certain
|
||||
items
|
||||
createUserDir:
|
||||
description: create a user directory for each user
|
||||
description: create a user directory for each user under defaultUserScope
|
||||
+ username
|
||||
type: boolean
|
||||
defaultEnabled:
|
||||
description: should be added as a default source for new users?
|
||||
type: boolean
|
||||
defaultUserScope:
|
||||
description: default "/" should match folders under path
|
||||
description: defaults to root of index "/" should match folders under path
|
||||
type: string
|
||||
denyByDefault:
|
||||
description: deny access unless an "allow" access rule was specifically created.
|
||||
type: boolean
|
||||
disableIndexing:
|
||||
description: disable the indexing of this source
|
||||
description: '(optional) not recommended: disable the indexing of this source'
|
||||
type: boolean
|
||||
disabled:
|
||||
description: disable the source, this is useful so you don't need to remove
|
||||
it from the config file
|
||||
type: boolean
|
||||
indexingIntervalMinutes:
|
||||
description: optional manual overide interval in minutes to re-index the source
|
||||
description: '(optional) not recommended: manual overide interval in minutes
|
||||
to re-index the source'
|
||||
type: integer
|
||||
private:
|
||||
description: designate as source as private -- currently just means no sharing
|
||||
@@ -650,8 +865,7 @@ definitions:
|
||||
description: debug onlyoffice editor
|
||||
type: boolean
|
||||
defaultLandingPage:
|
||||
description: 'default landing page to use if no redirect is specified: eg.
|
||||
/files/mysource/mysubpath, /settings, etc.'
|
||||
description: 'deprecated: determined by sidebar link order since 1.1.0'
|
||||
type: string
|
||||
deleteWithoutConfirming:
|
||||
description: delete files without confirmation
|
||||
@@ -703,9 +917,9 @@ definitions:
|
||||
description: 'login method to use: eg. password, proxy, oidc'
|
||||
type: string
|
||||
permissions:
|
||||
$ref: '#/definitions/users.Permissions'
|
||||
$ref: '#/definitions/settings.UserDefaultsPermissions'
|
||||
preview:
|
||||
$ref: '#/definitions/users.Preview'
|
||||
$ref: '#/definitions/settings.UserDefaultsPreview'
|
||||
quickDownload:
|
||||
description: show icon to download in one click
|
||||
type: boolean
|
||||
@@ -731,6 +945,151 @@ definitions:
|
||||
description: 'view mode to use: eg. normal, list, grid, or compact'
|
||||
type: string
|
||||
type: object
|
||||
settings.UserDefaultsPermissions:
|
||||
properties:
|
||||
admin:
|
||||
description: allow admin access
|
||||
type: boolean
|
||||
api:
|
||||
description: allow api access
|
||||
type: boolean
|
||||
create:
|
||||
description: allow creating or uploading files
|
||||
type: boolean
|
||||
delete:
|
||||
description: allow deleting files
|
||||
type: boolean
|
||||
download:
|
||||
description: allow downloading files
|
||||
type: boolean
|
||||
modify:
|
||||
description: allow modifying files
|
||||
type: boolean
|
||||
realtime:
|
||||
description: allow realtime updates
|
||||
type: boolean
|
||||
share:
|
||||
description: allow sharing files
|
||||
type: boolean
|
||||
type: object
|
||||
settings.UserDefaultsPreview:
|
||||
properties:
|
||||
autoplayMedia:
|
||||
description: autoplay media files in preview
|
||||
type: boolean
|
||||
defaultMediaPlayer:
|
||||
description: disable the styled feature-rich media player for browser default
|
||||
type: boolean
|
||||
disableHideSidebar:
|
||||
description: keep sidebar open when previewing files
|
||||
type: boolean
|
||||
folder:
|
||||
description: show thumbnails for folders that have previewable contents
|
||||
type: boolean
|
||||
highQuality:
|
||||
description: use high quality thumbnails
|
||||
type: boolean
|
||||
image:
|
||||
description: show thumbnails for image files
|
||||
type: boolean
|
||||
motionVideoPreview:
|
||||
description: show multiple frames for videos in thumbnail preview when hovering
|
||||
type: boolean
|
||||
office:
|
||||
description: show thumbnails for office files
|
||||
type: boolean
|
||||
popup:
|
||||
description: show larger popup preview when hovering over thumbnail
|
||||
type: boolean
|
||||
video:
|
||||
description: show thumbnails for video files
|
||||
type: boolean
|
||||
type: object
|
||||
share.CommonShare:
|
||||
properties:
|
||||
allowCreate:
|
||||
description: allow creating files
|
||||
type: boolean
|
||||
allowDelete:
|
||||
type: boolean
|
||||
allowModify:
|
||||
description: allow modifying files
|
||||
type: boolean
|
||||
allowReplacements:
|
||||
description: allow replacements of files
|
||||
type: boolean
|
||||
allowedUsernames:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
banner:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
disableAnonymous:
|
||||
type: boolean
|
||||
disableDownload:
|
||||
description: don't allow downloading files
|
||||
type: boolean
|
||||
disableFileViewer:
|
||||
description: don't allow viewing files
|
||||
type: boolean
|
||||
disableShareCard:
|
||||
type: boolean
|
||||
disableSidebar:
|
||||
type: boolean
|
||||
disableThumbnails:
|
||||
type: boolean
|
||||
downloadURL:
|
||||
type: string
|
||||
downloadsLimit:
|
||||
type: integer
|
||||
enableOnlyOffice:
|
||||
type: boolean
|
||||
enforceDarkLightMode:
|
||||
description: '"dark" or "light"'
|
||||
type: string
|
||||
extractEmbeddedSubtitles:
|
||||
description: can be io intensive for large files and take 10-30 seconds.
|
||||
type: boolean
|
||||
favicon:
|
||||
type: string
|
||||
hasPassword:
|
||||
type: boolean
|
||||
hideNavButtons:
|
||||
type: boolean
|
||||
keepAfterExpiration:
|
||||
type: boolean
|
||||
maxBandwidth:
|
||||
type: integer
|
||||
path:
|
||||
type: string
|
||||
perUserDownloadLimit:
|
||||
type: boolean
|
||||
quickDownload:
|
||||
type: boolean
|
||||
shareTheme:
|
||||
type: string
|
||||
shareType:
|
||||
description: 'type of share: normal, upload, max'
|
||||
type: string
|
||||
sidebarLinks:
|
||||
description: customizable sidebar links
|
||||
items:
|
||||
$ref: '#/definitions/users.SidebarLink'
|
||||
type: array
|
||||
source:
|
||||
description: backend source is path to maintain between name changes
|
||||
type: string
|
||||
themeColor:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
viewMode:
|
||||
description: 'default view mode for anonymous users: "list", "compact", "normal",
|
||||
"gallery"'
|
||||
type: string
|
||||
type: object
|
||||
share.CreateBody:
|
||||
properties:
|
||||
allowCreate:
|
||||
@@ -782,6 +1141,8 @@ definitions:
|
||||
type: boolean
|
||||
favicon:
|
||||
type: string
|
||||
hasPassword:
|
||||
type: boolean
|
||||
hash:
|
||||
type: string
|
||||
hideNavButtons:
|
||||
@@ -803,6 +1164,11 @@ definitions:
|
||||
shareType:
|
||||
description: 'type of share: normal, upload, max'
|
||||
type: string
|
||||
sidebarLinks:
|
||||
description: customizable sidebar links
|
||||
items:
|
||||
$ref: '#/definitions/users.SidebarLink'
|
||||
type: array
|
||||
source:
|
||||
description: backend source is path to maintain between name changes
|
||||
type: string
|
||||
@@ -870,6 +1236,8 @@ definitions:
|
||||
type: boolean
|
||||
favicon:
|
||||
type: string
|
||||
hasPassword:
|
||||
type: boolean
|
||||
hash:
|
||||
type: string
|
||||
hideNavButtons:
|
||||
@@ -891,6 +1259,11 @@ definitions:
|
||||
shareType:
|
||||
description: 'type of share: normal, upload, max'
|
||||
type: string
|
||||
sidebarLinks:
|
||||
description: customizable sidebar links
|
||||
items:
|
||||
$ref: '#/definitions/users.SidebarLink'
|
||||
type: array
|
||||
source:
|
||||
description: backend source is path to maintain between name changes
|
||||
type: string
|
||||
@@ -904,8 +1277,15 @@ definitions:
|
||||
URL-Safe and is used to download links in password-protected shares via a
|
||||
query arg.
|
||||
type: string
|
||||
userDownloads:
|
||||
additionalProperties:
|
||||
type: integer
|
||||
description: Track downloads per username
|
||||
type: object
|
||||
userID:
|
||||
type: integer
|
||||
version:
|
||||
type: integer
|
||||
viewMode:
|
||||
description: 'default view mode for anonymous users: "list", "compact", "normal",
|
||||
"gallery"'
|
||||
@@ -1005,6 +1385,24 @@ definitions:
|
||||
description: show thumbnail preview image for video files
|
||||
type: boolean
|
||||
type: object
|
||||
users.SidebarLink:
|
||||
properties:
|
||||
category:
|
||||
description: 'Category type: "source", "share", "tool", "custom", etc.'
|
||||
type: string
|
||||
icon:
|
||||
description: Material icon name
|
||||
type: string
|
||||
name:
|
||||
description: Display name of the link
|
||||
type: string
|
||||
sourceName:
|
||||
description: Source identifier for source-type links
|
||||
type: string
|
||||
target:
|
||||
description: Target path/URL for the link (relative for source/share)
|
||||
type: string
|
||||
type: object
|
||||
users.Sorting:
|
||||
properties:
|
||||
asc:
|
||||
@@ -1039,8 +1437,7 @@ definitions:
|
||||
description: debug onlyoffice editor
|
||||
type: boolean
|
||||
defaultLandingPage:
|
||||
description: 'default landing page to use: eg. /files/mysource/mysubpath,
|
||||
/settings, etc.'
|
||||
description: 'deprecated: determined by sidebar link order instead'
|
||||
type: string
|
||||
deleteWithoutConfirming:
|
||||
description: delete files without confirmation
|
||||
@@ -1121,6 +1518,11 @@ definitions:
|
||||
showSelectMultiple:
|
||||
description: show select multiple files on desktop
|
||||
type: boolean
|
||||
sidebarLinks:
|
||||
description: customizable sidebar links
|
||||
items:
|
||||
$ref: '#/definitions/users.SidebarLink'
|
||||
type: array
|
||||
singleClick:
|
||||
description: open directory on single click, also enables middle click to
|
||||
open in new tab
|
||||
@@ -1154,7 +1556,8 @@ paths:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Delete a user or group from an allow or deny list for a sourcePath
|
||||
and indexPath.
|
||||
and indexPath. When cascade=true, removes the user/group from the specified
|
||||
path and all subpaths.
|
||||
parameters:
|
||||
- description: Source path prefix (e.g. mnt/storage)
|
||||
in: query
|
||||
@@ -1181,6 +1584,10 @@ paths:
|
||||
name: value
|
||||
required: true
|
||||
type: string
|
||||
- description: 'Cascade delete to all subpaths (default: false)'
|
||||
in: query
|
||||
name: cascade
|
||||
type: boolean
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -1257,6 +1664,48 @@ paths:
|
||||
summary: List access rules
|
||||
tags:
|
||||
- Access
|
||||
patch:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Updates the path for a specific access rule
|
||||
parameters:
|
||||
- description: Source, old path, and new path
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
properties:
|
||||
newPath:
|
||||
type: string
|
||||
oldPath:
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
type: object
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Rule path updated successfully
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Bad request - missing or invalid parameters
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Update access rule path
|
||||
tags:
|
||||
- Access
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
@@ -1714,6 +2163,43 @@ paths:
|
||||
summary: List API keys
|
||||
tags:
|
||||
- API Keys
|
||||
/api/duplicates:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Finds duplicate files based on size and fuzzy filename matching
|
||||
parameters:
|
||||
- description: Source name for the desired source
|
||||
in: query
|
||||
name: source
|
||||
required: true
|
||||
type: string
|
||||
- description: path within user scope to search
|
||||
in: query
|
||||
name: scope
|
||||
type: string
|
||||
- description: 'Minimum file size in megabytes (default: 1)'
|
||||
in: query
|
||||
name: minSizeMb
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: List of duplicate file groups
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/http.duplicateGroup'
|
||||
type: array
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Find Duplicate Files
|
||||
tags:
|
||||
- Duplicates
|
||||
/api/jobs/{action}/{target}:
|
||||
get:
|
||||
consumes:
|
||||
@@ -2119,6 +2605,10 @@ paths:
|
||||
in: query
|
||||
name: override
|
||||
type: boolean
|
||||
- description: Explicitly specify if the resource is a directory
|
||||
in: query
|
||||
name: isDir
|
||||
type: boolean
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -2325,6 +2815,44 @@ paths:
|
||||
summary: Get share links by path
|
||||
tags:
|
||||
- Shares
|
||||
patch:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Updates the path for a specific share link identified by hash
|
||||
parameters:
|
||||
- description: Hash and new path
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
properties:
|
||||
hash:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
type: object
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Updated share link
|
||||
schema:
|
||||
$ref: '#/definitions/http.ShareResponse'
|
||||
"400":
|
||||
description: Bad request - missing or invalid parameters
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Update share link path
|
||||
tags:
|
||||
- Shares
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
@@ -2592,13 +3120,9 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/users.User'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Updated user details
|
||||
schema:
|
||||
$ref: '#/definitions/users.User'
|
||||
"204":
|
||||
description: No Content - User updated successfully
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
@@ -2763,6 +3287,56 @@ paths:
|
||||
tags:
|
||||
- Public Shares
|
||||
/public/api/resources:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns metadata for files or directories accessible via a public
|
||||
share link. Browsing is disabled for upload-only shares.
|
||||
parameters:
|
||||
- description: Share hash for authentication
|
||||
in: query
|
||||
name: hash
|
||||
required: true
|
||||
type: string
|
||||
- description: Path within the share to retrieve information for. Defaults to
|
||||
share root.
|
||||
in: query
|
||||
name: path
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: File or directory metadata
|
||||
schema:
|
||||
$ref: '#/definitions/iteminfo.FileInfo'
|
||||
"403":
|
||||
description: Share unavailable or access denied
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: Share not found or file not found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"501":
|
||||
description: Browsing disabled for upload shares
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Get file/directory information from a public share
|
||||
tags:
|
||||
- Public Shares
|
||||
post:
|
||||
consumes:
|
||||
- multipart/form-data
|
||||
@@ -2899,55 +3473,32 @@ paths:
|
||||
summary: Update a file in a public share
|
||||
tags:
|
||||
- Public Shares
|
||||
/public/api/share:
|
||||
/public/api/shareinfo:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns metadata for files or directories accessible via a public
|
||||
share link. Browsing is disabled for upload-only shares.
|
||||
description: Returns information about a share link based on its hash. This
|
||||
endpoint is publicly accessible and can be used with or without authentication.
|
||||
parameters:
|
||||
- description: Share hash for authentication
|
||||
- description: Hash of the share link
|
||||
in: query
|
||||
name: hash
|
||||
required: true
|
||||
type: string
|
||||
- description: Path within the share to retrieve information for. Defaults to
|
||||
share root.
|
||||
in: query
|
||||
name: path
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: File or directory metadata
|
||||
description: Share information
|
||||
schema:
|
||||
$ref: '#/definitions/iteminfo.FileInfo'
|
||||
"403":
|
||||
description: Share unavailable or access denied
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/definitions/share.CommonShare'
|
||||
"404":
|
||||
description: Share not found or file not found
|
||||
description: Share hash not found
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"501":
|
||||
description: Browsing disabled for upload shares
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Get file/directory information from a public share
|
||||
summary: Get share information by hash
|
||||
tags:
|
||||
- Public Shares
|
||||
- Shares
|
||||
swagger: "2.0"
|
||||
+11
-21
@@ -11,34 +11,24 @@
|
||||
],
|
||||
"settings": {
|
||||
"vue-i18n": {
|
||||
// Path to your locale message directory or a glob pattern.
|
||||
// The plugin will use these files to determine available keys.
|
||||
// Make sure 'en.json' is present here.
|
||||
"localeDir": "./src/i18n/*.json"
|
||||
|
||||
// If your i18n instance is initialized with a fallbackLocale,
|
||||
// the linter might use that. Ensure 'en' is effectively the master.
|
||||
// fallbackLocale: 'en', // This might be part of your i18n setup, not ESLint settings
|
||||
"localeDir": "./src/i18n/en.json",
|
||||
"messageSyntaxVersion": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"no-unreachable": "off",
|
||||
// vue-i18n rules:
|
||||
// This rule will check if the key used in $t() exists in your locale messages.
|
||||
// By default, it checks against all locales. We want it to primarily use 'en.json'
|
||||
// as the source of truth. The plugin often infers this from your i18n setup,
|
||||
// but we want to be explicit or ensure our sync script makes 'en.json' the master.
|
||||
// The localeDir setting above points to en.json as the single source of truth.
|
||||
"@intlify/vue-i18n/no-missing-keys": "error",
|
||||
// Optional: Warn about unused keys in your 'en.json'
|
||||
// This requires configuring the `localeDir` setting below.
|
||||
"@intlify/vue-i18n/no-unused-keys": ["error", {
|
||||
"src": "./src", // Path to your source files
|
||||
"extensions": [".js", ".vue"]
|
||||
// Important: This tells the rule to check unused keys specifically in en.json
|
||||
// by making it the single source of truth for what "should" exist.
|
||||
// However, the rule usually checks keys NOT used in your Vue code.
|
||||
// The primary goal is `no-missing-keys` for keys used in the template.
|
||||
}],
|
||||
// Check for unused keys in en.json (keys that exist but are never used in code)
|
||||
"@intlify/vue-i18n/no-unused-keys": [
|
||||
"error",
|
||||
{
|
||||
"src": "./src",
|
||||
"extensions": [".js", ".vue", ".ts"]
|
||||
}
|
||||
],
|
||||
|
||||
"@intlify/vue-i18n/no-raw-text": [
|
||||
"error",
|
||||
|
||||
Generated
+7557
File diff suppressed because it is too large.
Load diff
@@ -9,12 +9,11 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rm -rf ../backend/http/dist/* && rm -rf ../backend/http/embed/* && vite build --outDir ../backend/http/dist && cp -r ../backend/http/dist/* ../backend/http/embed",
|
||||
"build:windows": "vite build --outDir ../backend/http/dist && robocopy ../backend/http/dist ../backend/http/embed /e",
|
||||
"build:docker": "vite build",
|
||||
"watch": "vite build --watch --outDir ../backend/http/dist",
|
||||
"typecheck": "vue-tsc -p ./tsconfig.json --noEmit",
|
||||
"lint": "eslint --ext .js,.vue,ts src",
|
||||
"lint:fix": "eslint --fix src/",
|
||||
"lint": "eslint --ext .js,.vue,.ts src/",
|
||||
"lint:fix": "eslint --fix --ext .js,.vue,.ts src/",
|
||||
"i18n:sync": "node ./scripts/sync-translations.js",
|
||||
"i18n:check": "node ./scripts/sync-translations.js --check",
|
||||
"format": "prettier --write .",
|
||||
|
||||
@@ -9,6 +9,7 @@ server:
|
||||
disablePreviewResize: false # disable resizing of previews for faster loading over slow connections
|
||||
disableTypeDetectionByHeader: false # disable type detection by header, useful if filesystem is slow.
|
||||
port: 80 # port to listen on
|
||||
listen: "" # address to listen on (default: 0.0.0.0)
|
||||
baseURL: "/" # base URL for the server, the subpath that the server is running on.
|
||||
logging:
|
||||
- levels: "" # separated list of log levels to enable. (eg. "info|warning|error|debug")
|
||||
@@ -25,8 +26,8 @@ server:
|
||||
denyByDefault: false # deny access unless an "allow" access rule was specifically created.
|
||||
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
|
||||
indexingIntervalMinutes: 0 # optional manual overide interval in minutes to re-index the source
|
||||
disableIndexing: false # disable the indexing of this source
|
||||
indexingIntervalMinutes: 0 # (optional) not recommended: manual overide interval in minutes to re-index the source
|
||||
disableIndexing: false # (optional) not recommended: disable the indexing of this source
|
||||
conditionals: # conditional rules to apply when indexing to include/exclude certain items
|
||||
ignoreHidden: false # exclude hidden files and folders.
|
||||
ignoreZeroSizeFolders: false # ignore folders with 0 size
|
||||
@@ -42,12 +43,13 @@ server:
|
||||
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
|
||||
defaultUserScope: "/" # default "/" should match folders under path
|
||||
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
|
||||
createUserDir: false # create a user directory for each user under defaultUserScope + username
|
||||
externalUrl: "" # used by share links if set (eg. http://mydomain.com)
|
||||
internalUrl: "" # used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
cacheDir: "tmp" # path to the cache directory, used for thumbnails and other cached files
|
||||
cacheDirCleanup: true # whether to automatically cleanup the cache directory (default: true)
|
||||
maxArchiveSize: 50 # max pre-archive combined size of files/folder that are allowed to be archived (in GB)
|
||||
filesystem: # filesystem settings
|
||||
createFilePermission: "644" # Unix permissions like 644, 755, 2755 (default: 644) validate:required,file_permission
|
||||
@@ -133,16 +135,16 @@ userDefaults:
|
||||
lockPassword: false # disable the user from changing their password
|
||||
disableSettings: false # disable the user from viewing the settings page
|
||||
preview:
|
||||
disableHideSidebar: false # disable the hide sidebar preview for previews and editors
|
||||
highQuality: false # generate high quality thumbnail preview images
|
||||
image: false # show thumbnail preview image for image files
|
||||
video: false # show thumbnail preview image for video files
|
||||
motionVideoPreview: false # show multiple frames for videos in thumbnail preview when hovering
|
||||
office: false # show thumbnail preview image for office files
|
||||
popup: false # show larger popup preview when hovering over thumbnail
|
||||
autoplayMedia: false # autoplay media files in preview
|
||||
defaultMediaPlayer: false # disable html5 media player and use the default media player
|
||||
folder: false # show thumbnail preview image for folder files
|
||||
disableHideSidebar: false # keep sidebar open when previewing files
|
||||
highQuality: true # use high quality thumbnails
|
||||
image: true # show thumbnails for image files
|
||||
video: true # show thumbnails for video files
|
||||
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering
|
||||
office: true # show thumbnails for office files
|
||||
popup: true # show larger popup preview when hovering over thumbnail
|
||||
autoplayMedia: true # autoplay media files in preview
|
||||
defaultMediaPlayer: false # disable the styled feature-rich media player for browser default
|
||||
folder: true # show thumbnails for folders that have previewable contents
|
||||
permissions:
|
||||
api: false # allow api access
|
||||
admin: false # allow admin access
|
||||
@@ -151,7 +153,7 @@ userDefaults:
|
||||
realtime: false # allow realtime updates
|
||||
delete: false # allow deleting files
|
||||
create: false # allow creating or uploading files
|
||||
download: false # allow downloading files
|
||||
download: true # allow downloading files
|
||||
loginMethod: "password" # login method to use: eg. password, proxy, oidc
|
||||
disableUpdateNotifications: false # disable update notifications banner for admin users
|
||||
deleteWithoutConfirming: false # delete files without confirmation
|
||||
@@ -159,11 +161,10 @@ userDefaults:
|
||||
maxConcurrentUpload: 10
|
||||
uploadChunkSizeMb: 10
|
||||
clearAll: false
|
||||
disableOnlyOfficeExt: ".md .txt .pdf" # list of file extensions to disable onlyoffice editor for
|
||||
disableOnlyOfficeExt: ".md .txt .pdf .html .xml" # list of file extensions to disable onlyoffice editor for
|
||||
customTheme: "" # Name of theme to use chosen from custom themes config.
|
||||
showSelectMultiple: false # show select multiple files on desktop
|
||||
debugOffice: false # debug onlyoffice editor
|
||||
defaultLandingPage: "" # default landing page to use if no redirect is specified: eg. /files/mysource/mysubpath, /settings, etc.
|
||||
integrations:
|
||||
office: # validate:omitempty
|
||||
url: "" # The URL to the OnlyOffice Document Server, needs to be accessible to the user. validate:required
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Light background circle that matches light mode -->
|
||||
<circle cx="16" cy="16" r="16" fill="#f5f5f5"/>
|
||||
|
||||
<!-- Back folder (orange, barely higher and left) -->
|
||||
<path d="M5 8h6l2 2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2z"
|
||||
fill="#FF9800"/>
|
||||
|
||||
<!-- Front folder (blue, almost same position) -->
|
||||
<path d="M6 9h6l2 2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V11a2 2 0 0 1 2-2z"
|
||||
fill="#2196F3"/>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 527 B |
@@ -37,7 +37,6 @@
|
||||
<!-- Injected variables come from backend server -->
|
||||
<script>
|
||||
window.globalVars = {{ .globalVars }};
|
||||
window.shareVars = {{ .shareVars }};
|
||||
var dynamicManifest = {
|
||||
"name": window.globalVars.name || 'FileBrowser Quantum',
|
||||
"icons": [
|
||||
@@ -69,7 +68,7 @@
|
||||
:root {
|
||||
--background: {{ .htmlVars.lightBackground }};
|
||||
--alt-background: #ddd;
|
||||
--surfacePrimary: gray;
|
||||
--surfacePrimary: #ebebeb;
|
||||
--surfaceSecondary: lightgray;
|
||||
--textPrimary: #546e7a;
|
||||
--textSecondary: gray;
|
||||
|
||||
@@ -42,14 +42,32 @@ export async function add(source, path, body) {
|
||||
/**
|
||||
* @param {string} source
|
||||
* @param {string} path
|
||||
* @param {{ allow: boolean; ruleCategory: string; value: string; }} body
|
||||
* @param {{ allow: boolean; ruleCategory: string; value: string; cascade?: boolean; }} body
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function del(source, path, body) {
|
||||
const ruleType = body.allow ? 'allow' : 'deny';
|
||||
const { ruleCategory, value } = body;
|
||||
const apiPath = getApiPath('api/access', { source, path, ruleType, ruleCategory, value });
|
||||
const { ruleCategory, value, cascade } = body;
|
||||
const params = { source, path, ruleType, ruleCategory, value };
|
||||
if (cascade) {
|
||||
params.cascade = 'true';
|
||||
}
|
||||
const apiPath = getApiPath('api/access', params);
|
||||
return fetchJSON(apiPath, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
/**
|
||||
* @param {string} source
|
||||
* @param {string} oldPath
|
||||
* @param {string} newPath
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function updatePath(source, oldPath, newPath) {
|
||||
const apiPath = getApiPath('api/access');
|
||||
return fetchJSON(apiPath, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ source, oldPath, newPath }),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fetchURL, adjustedData } from './utils'
|
||||
import { getApiPath, doubleEncode, getPublicApiPath } from '@/utils/url.js'
|
||||
import { mutations,state } from '@/store'
|
||||
import { state } from '@/store'
|
||||
import { notify } from '@/notify'
|
||||
import { globalVars } from '@/utils/constants'
|
||||
|
||||
@@ -77,12 +77,8 @@ export async function put(source, path, content = '') {
|
||||
if (!source) {
|
||||
throw new Error('no source provided')
|
||||
}
|
||||
try {
|
||||
return await resourceAction(source, path, 'PUT', content)
|
||||
} catch (err) {
|
||||
notify.showError(err.message || 'Error putting resource')
|
||||
throw err
|
||||
}
|
||||
// resourceAction already handles error notification, just propagate
|
||||
return await resourceAction(source, path, 'PUT', content)
|
||||
}
|
||||
|
||||
export function download(format, files, shareHash = "") {
|
||||
@@ -121,7 +117,8 @@ export function post(
|
||||
content = "",
|
||||
overwrite = false,
|
||||
onupload,
|
||||
headers = {}
|
||||
headers = {},
|
||||
isDir = false
|
||||
) {
|
||||
if (!source || source === undefined || source === null) {
|
||||
throw new Error('no source provided')
|
||||
@@ -131,6 +128,7 @@ export function post(
|
||||
path: doubleEncode(path),
|
||||
source: doubleEncode(source),
|
||||
override: overwrite,
|
||||
...(isDir && { isDir: 'true' })
|
||||
});
|
||||
|
||||
const request = new XMLHttpRequest();
|
||||
@@ -223,14 +221,6 @@ export async function moveCopy(
|
||||
|
||||
// Await all promises and ensure errors propagate
|
||||
await Promise.all(promises)
|
||||
setTimeout(() => {
|
||||
notify.showSuccess(
|
||||
action === 'copy' ? 'Resources copied successfully' : 'Resources moved successfully'
|
||||
)
|
||||
}, 125);
|
||||
setTimeout(() => {
|
||||
mutations.setReload(true);
|
||||
}, 125);
|
||||
} catch (err) {
|
||||
notify.showError(err.message || 'Error moving/copying resources')
|
||||
throw err // Re-throw the error to propagate it back to the caller
|
||||
|
||||
+36
-34
@@ -1,8 +1,8 @@
|
||||
import { adjustedData } from "./utils";
|
||||
import { notify } from "@/notify";
|
||||
import { getApiPath, getPublicApiPath, encodedPath, doubleEncode } from "@/utils/url.js";
|
||||
import { globalVars,shareInfo } from "@/utils/constants";
|
||||
import { state, mutations } from "@/store";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import { state } from "@/store";
|
||||
|
||||
// ============================================================================
|
||||
// PUBLIC API ENDPOINTS (hash-based authentication)
|
||||
@@ -112,16 +112,22 @@ export function post(
|
||||
content = "",
|
||||
overwrite = false,
|
||||
onupload,
|
||||
headers = {}
|
||||
headers = {},
|
||||
isDir = false
|
||||
) {
|
||||
if (!hash || hash === undefined || hash === null) {
|
||||
throw new Error('no hash provided')
|
||||
}
|
||||
let sharePassword = localStorage.getItem("sharepass:" + hash);
|
||||
if (sharePassword) {
|
||||
headers["X-SHARE-PASSWORD"] = sharePassword;
|
||||
}
|
||||
try {
|
||||
const apiPath = getPublicApiPath("resources", {
|
||||
targetPath: doubleEncode(path),
|
||||
hash: hash,
|
||||
override: overwrite,
|
||||
...(isDir && { isDir: 'true' })
|
||||
});
|
||||
|
||||
const request = new XMLHttpRequest();
|
||||
@@ -130,7 +136,6 @@ export function post(
|
||||
for (const header in headers) {
|
||||
request.setRequestHeader(header, headers[header]);
|
||||
}
|
||||
|
||||
if (typeof onupload === "function") {
|
||||
request.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
@@ -179,16 +184,19 @@ export function post(
|
||||
}
|
||||
}
|
||||
|
||||
async function resourceAction(path, method, content) {
|
||||
if (!shareInfo.isValid) {
|
||||
throw new Error('invalid share')
|
||||
}
|
||||
async function resourceAction(hash, path, method, content, token = "") {
|
||||
try {
|
||||
let headers = {};
|
||||
let sharePassword = localStorage.getItem("sharepass:" + hash);
|
||||
if (sharePassword) {
|
||||
headers["X-SHARE-PASSWORD"] = sharePassword;
|
||||
}
|
||||
path = doubleEncode(path)
|
||||
const apiPath = getPublicApiPath('resources', { path, hash: shareInfo.hash, token: shareInfo.token })
|
||||
const apiPath = getPublicApiPath('resources', { path, hash: hash, token: token })
|
||||
const response = await fetch(apiPath, {
|
||||
method,
|
||||
body: content,
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = new Error(response.statusText);
|
||||
@@ -212,31 +220,22 @@ async function resourceAction(path, method, content) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(path) {
|
||||
if (!shareInfo.isValid) {
|
||||
throw new Error('invalid share')
|
||||
}
|
||||
export async function remove(hash, path) {
|
||||
try {
|
||||
return await resourceAction(path, 'DELETE')
|
||||
return await resourceAction(hash, path, 'DELETE')
|
||||
} catch (err) {
|
||||
notify.showError(err.message || 'Error deleting resource')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export async function put(path, content = '') {
|
||||
if (!shareInfo.isValid) {
|
||||
throw new Error('invalid share')
|
||||
}
|
||||
try {
|
||||
return await resourceAction(path, 'PUT', content)
|
||||
} catch (err) {
|
||||
notify.showError(err.message || 'Error putting resource')
|
||||
throw err
|
||||
}
|
||||
export async function put(hash, path, content = '') {
|
||||
// resourceAction already handles error notification, just propagate
|
||||
return await resourceAction(hash, path, 'PUT', content)
|
||||
}
|
||||
|
||||
export async function moveCopy(
|
||||
hash,
|
||||
items,
|
||||
action = 'copy',
|
||||
overwrite = false,
|
||||
@@ -246,7 +245,7 @@ export async function moveCopy(
|
||||
overwrite: overwrite,
|
||||
action: action,
|
||||
rename: rename,
|
||||
hash: shareInfo.hash
|
||||
hash: hash
|
||||
}
|
||||
try {
|
||||
// Create an array of fetch calls
|
||||
@@ -272,16 +271,19 @@ export async function moveCopy(
|
||||
|
||||
// Await all promises and ensure errors propagate
|
||||
await Promise.all(promises)
|
||||
setTimeout(() => {
|
||||
notify.showSuccess(
|
||||
action === 'copy' ? 'Resources copied successfully' : 'Resources moved successfully'
|
||||
)
|
||||
}, 125);
|
||||
setTimeout(() => {
|
||||
mutations.setReload(true);
|
||||
}, 125);
|
||||
} catch (err) {
|
||||
notify.showError(err.message || 'Error moving/copying resources')
|
||||
console.error(err.message || 'Error moving/copying resources')
|
||||
throw err // Re-throw the error to propagate it back to the caller
|
||||
}
|
||||
}
|
||||
|
||||
export async function getShareInfo(hash) {
|
||||
try {
|
||||
const apiPath = getPublicApiPath('shareinfo', { hash: hash })
|
||||
const response = await fetch(apiPath)
|
||||
return response.json()
|
||||
} catch (err) {
|
||||
notify.showError(err.message || 'Error getting share info')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,23 @@ import { fetchURL } from "./utils";
|
||||
import { notify } from "@/notify"; // Import notify for error handling
|
||||
import { getApiPath } from "@/utils/url.js";
|
||||
|
||||
export default async function search(base, source, query) {
|
||||
export default async function search(base, source, query, largest = false) {
|
||||
try {
|
||||
query = encodeURIComponent(query);
|
||||
if (!base.endsWith("/")) {
|
||||
base += "/";
|
||||
}
|
||||
const apiPath = getApiPath("api/search", { scope: encodeURIComponent(base), query: query, source: encodeURIComponent(source) });
|
||||
const params = {
|
||||
scope: encodeURIComponent(base),
|
||||
query: query,
|
||||
source: encodeURIComponent(source)
|
||||
};
|
||||
|
||||
if (largest) {
|
||||
params.largest = "true";
|
||||
}
|
||||
|
||||
const apiPath = getApiPath("api/search", params);
|
||||
const res = await fetchURL(apiPath);
|
||||
let data = await res.json();
|
||||
|
||||
@@ -18,3 +28,29 @@ export default async function search(base, source, query) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function findDuplicates(base, source, minSizeMb, useChecksum = false) {
|
||||
try {
|
||||
if (!base.endsWith("/")) {
|
||||
base += "/";
|
||||
}
|
||||
const params = {
|
||||
scope: encodeURIComponent(base),
|
||||
source: encodeURIComponent(source),
|
||||
minSizeMb: minSizeMb.toString()
|
||||
};
|
||||
|
||||
if (useChecksum) {
|
||||
params.useChecksum = "true";
|
||||
}
|
||||
|
||||
const apiPath = getApiPath("api/duplicates", params);
|
||||
const res = await fetchURL(apiPath);
|
||||
const data = await res.json();
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
notify.showError(err.message || "Error occurred while finding duplicates");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,13 @@ import { getApiPath } from "@/utils/url.js";
|
||||
|
||||
// List all shares
|
||||
export async function list() {
|
||||
try {
|
||||
const apiPath = getApiPath("api/shares");
|
||||
return fetchJSON(apiPath);
|
||||
return await fetchJSON(apiPath);
|
||||
} catch (/** @type {any} */ err) {
|
||||
notify.showError(err.message || "Error listing shares");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Get share information
|
||||
@@ -37,11 +42,16 @@ export async function get(path, source) {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function remove(hash) {
|
||||
const params = { hash };
|
||||
const apiPath = getApiPath("api/share", params);
|
||||
await fetchURL(apiPath, {
|
||||
method: "DELETE",
|
||||
});
|
||||
try {
|
||||
const params = { hash };
|
||||
const apiPath = getApiPath("api/share", params);
|
||||
await fetchURL(apiPath, {
|
||||
method: "DELETE",
|
||||
});
|
||||
} catch (/** @type {any} */ err) {
|
||||
notify.showError(err.message || "Error deleting share");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new share
|
||||
@@ -50,11 +60,36 @@ export async function remove(hash) {
|
||||
* @returns {Promise<Share>}
|
||||
*/
|
||||
export async function create(bodyObj = {}) {
|
||||
const apiPath = getApiPath("api/share");
|
||||
return fetchJSON(apiPath, {
|
||||
try {
|
||||
const apiPath = getApiPath("api/share");
|
||||
return await fetchJSON(apiPath, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(bodyObj || {}),
|
||||
});
|
||||
} catch (/** @type {any} */ err) {
|
||||
notify.showError(err.message || "Error creating share");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Update share path
|
||||
/**
|
||||
* @param {string} hash
|
||||
* @param {string} newPath
|
||||
* @returns {Promise<Share>}
|
||||
*/
|
||||
export async function updatePath(hash, newPath) {
|
||||
try {
|
||||
const apiPath = getApiPath("api/share");
|
||||
return await fetchJSON(apiPath, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ hash, path: newPath }),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (/** @type {any} */ err) {
|
||||
notify.showError(err.message || "Error updating share path");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -183,12 +183,23 @@ export async function update(user, which = ['all']) {
|
||||
if (user.username === 'anonymous') {
|
||||
return
|
||||
}
|
||||
// If which is not ["all"], filter user data to only include specified keys
|
||||
let userData = user
|
||||
if (which.length !== 1 || which[0] !== 'all') {
|
||||
userData = {}
|
||||
which.forEach(key => {
|
||||
if (key in user) {
|
||||
userData[key] = user[key]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const apiPath = getApiPath('api/users', { id: user.id })
|
||||
await fetchURL(apiPath, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
which: which,
|
||||
data: user
|
||||
data: userData
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { state } from "@/store";
|
||||
import { renew } from "@/utils/auth";
|
||||
import { notify } from "@/notify";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
export async function fetchURL(url, opts, auth = true) {
|
||||
opts = opts || {};
|
||||
@@ -21,7 +21,7 @@ export async function fetchURL(url, opts, auth = true) {
|
||||
} catch (e) {
|
||||
let message = e;
|
||||
if (e == "TypeError: Failed to fetch") {
|
||||
message = "Failed to connect to the server, is it still running?";
|
||||
message = i18n.global.t("errors.failedToConnectToServer");
|
||||
}
|
||||
const error = new Error(message);
|
||||
throw error;
|
||||
@@ -46,7 +46,6 @@ export async function fetchJSON(url, opts) {
|
||||
if (res.status < 300) {
|
||||
return res.json();
|
||||
} else {
|
||||
notify.showError("received status: "+res.status+" on url " + url);
|
||||
throw new Error(res.status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,15 +26,6 @@ vi.mock('@/utils/constants', () => {
|
||||
externalLinks: [],
|
||||
updateAvailable: "",
|
||||
},
|
||||
shareInfo: {
|
||||
isShare: false,
|
||||
disableThumbnails: false,
|
||||
hash: "",
|
||||
enforceDarkLightMode: "",
|
||||
disableSidebar: false,
|
||||
isValid: true,
|
||||
},
|
||||
serverHasMultipleSources: false,
|
||||
logoURL: "test-logo.png",
|
||||
origin: "http://localhost",
|
||||
settings: [],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div id="breadcrumbs" :class="{ 'add-padding': addPadding }">
|
||||
<ul v-if="items.length > 0">
|
||||
<li>
|
||||
<router-link :to="base" :aria-label="$t('files.home')" :title="$t('files.home')">
|
||||
<router-link :to="base" :aria-label="$t('general.home')" :title="$t('general.home')">
|
||||
<i class="material-icons">home</i>
|
||||
</router-link>
|
||||
</li>
|
||||
@@ -18,31 +18,17 @@
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="showGallerySize" class="gallery-size card">
|
||||
{{ $t("files.size") }}<span class="sr-only">:</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
|
||||
<input
|
||||
v-model="gallerySize"
|
||||
type="range"
|
||||
id="gallery-size"
|
||||
name="gallery-size"
|
||||
min="1"
|
||||
max="8"
|
||||
@input="updateGallerySize"
|
||||
@change="commitGallerySize"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { state, mutations, getters } from "@/store";
|
||||
import { state, getters } from "@/store";
|
||||
import { encodedPath } from "@/utils/url.js";
|
||||
|
||||
export default {
|
||||
name: "breadcrumbs",
|
||||
data() {
|
||||
return {
|
||||
gallerySize: state.user.gallerySize,
|
||||
base: "/files/",
|
||||
path: "",
|
||||
};
|
||||
@@ -69,9 +55,6 @@ export default {
|
||||
addPadding() {
|
||||
return getters.isStickySidebar() || getters.isShare();
|
||||
},
|
||||
showGallerySize() {
|
||||
return getters.isCardView() && state.req?.items?.length > 0;
|
||||
},
|
||||
items() {
|
||||
const req = state.req;
|
||||
if (!req.items || !req.path) {
|
||||
@@ -118,19 +101,9 @@ export default {
|
||||
|
||||
} else {
|
||||
this.path = encodedPath(state.req.path);
|
||||
if (state.serverHasMultipleSources) {
|
||||
this.base = `/files/${state.req.source}/`;
|
||||
} else {
|
||||
this.base = "/files/";
|
||||
}
|
||||
this.base = `/files/${state.req.source}/`;
|
||||
}
|
||||
},
|
||||
updateGallerySize(event) {
|
||||
this.gallerySize = parseInt(event.target.value, 10);
|
||||
},
|
||||
commitGallerySize() {
|
||||
mutations.setGallerySize(this.gallerySize);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -225,13 +198,4 @@ export default {
|
||||
filter: contrast(0.8) hue-rotate(200deg) saturate(1);
|
||||
}
|
||||
|
||||
.gallery-size {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
padding: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
input[type="range"] {
|
||||
accent-color: var(--primaryColor);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div @click="preventDefaults" class="button-group">
|
||||
<button v-if="isDisabled" >
|
||||
<button v-if="isDisabled" disabled>
|
||||
{{ disableMessage }}
|
||||
</button>
|
||||
<template v-else>
|
||||
@@ -48,13 +48,13 @@ export default {
|
||||
e.stopPropagation();
|
||||
},
|
||||
setActiveButton(index, value) {
|
||||
if (value === "Only Folders" && this.activeButton !== index) {
|
||||
if (value === "type:folder" && this.activeButton !== index) {
|
||||
this.$emit("disableAll");
|
||||
}
|
||||
if (value === "Only Folders" && this.activeButton === index) {
|
||||
if (value === "type:folder" && this.activeButton === index) {
|
||||
this.$emit("enableAll");
|
||||
}
|
||||
if (value === "Only Files" && this.activeButton !== index) {
|
||||
if (value === "type:file" && this.activeButton !== index) {
|
||||
this.$emit("enableAll");
|
||||
}
|
||||
// If the clicked button is already active, de-select it
|
||||
|
||||
Loaded 100 of 248 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user