Beta/v0.8.8 (#1323)

Co-authored-by: Markos <xmarkos@gmail.com>
Co-authored-by: Zhiyuan Zheng <zhzy0077@hotmail.com>
Co-authored-by: Kurami <kurami32_kbm@proton.me>
Co-authored-by: MrPanda376 <112415827+MrPanda376@users.noreply.github.com>
Co-authored-by: MrPanda376 <luca.silvestrin.2008@gmail.com>
This commit is contained in:
authored and GitHub committed 2025-10-04 14:28:15 -05:00
1 parent fbc29056aa
commit 39514169f1
173 files changed
+9020 -2883

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
backend/**.db
backend/tmp
backend/dist
backend/backend*
backend/vendor/**
frontend/dist/**
+5 -9
View File
@@ -75,6 +75,10 @@ jobs:
dockerfile: ./_docker/Dockerfile.playwright-oidc
- name: sharing
dockerfile: ./_docker/Dockerfile.playwright-sharing
- name: no-config
dockerfile: ./_docker/Dockerfile.playwright-no-config
- name: settings
dockerfile: ./_docker/Dockerfile.playwright-settings
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -112,7 +116,6 @@ jobs:
cache-to: |
type=gha,mode=max,scope=${{ github.ref_name }}-playwright-base
push_pr_to_registry:
if: github.event.pull_request.head.repo.fork == false
name: Build docker image
runs-on: ubuntu-latest
steps:
@@ -122,16 +125,9 @@ jobs:
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: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: gtstef/filebrowser
- name: Build and push
uses: docker/build-push-action@v6
with:
@@ -144,4 +140,4 @@ jobs:
VERSION=${{ steps.meta.outputs.version }}
REVISION=${{ steps.meta.outputs.revision }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: type=gha,mode=max
+8 -1
View File
@@ -21,7 +21,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
go-version: 'stable'
- uses: golangci/golangci-lint-action@v7
with:
version: 'v2.1.6'
@@ -50,3 +50,10 @@ jobs:
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run test
check-translations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run i18n:check
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
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,linux/arm64
platforms: linux/amd64
file: ./_docker/Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
@@ -0,0 +1,41 @@
name: Update Build Images
on:
workflow_dispatch:
jobs:
publish_playwright_base:
name: Publish playwright-base to GHCR
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/gtsteffaniak/playwright-base
tags: latest
- name: Build and push playwright-base
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
file: _docker/Dockerfile.playwright-base
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=playwright-base
cache-to: type=gha,mode=max,scope=playwright-base
+2
View File
@@ -8,6 +8,7 @@ rice-box.go
/backend/backend
/backend/filebrowser
/backend/filebrowser.exe
frontend/tsconfig.tsbuildinfo
/backend/backend.exe
/backend/tmp
/backend/dist
@@ -20,6 +21,7 @@ rice-box.go
/backend/vendor
/backend/*.cov
/backend/test_config.yaml
/backend/test-*.yaml
/backend/generated.yaml
/backend/srv
/backend/http/dist
+41
View File
@@ -2,6 +2,47 @@
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).
## v0.8.8-beta
**New Features**:
- Ability to split configuration files via yaml anchoring #1267
- see [How to: Use multiple config files](https://github.com/gtsteffaniak/filebrowser/wiki/How-to:-Use-multiple-config-files)
- added [Init Script Help wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Init-Script-Help) to examples how you can bootstrap filebrowser quantum programatically
- Add capability to edit files permissions #813
- see updated full config for help using `server.filesystem`
- better copy/move prompt "in progress" indicator
- better login error message feedback
- Add capability to disable certain video previews at server level. See full config for help.
- Added ability to play media in same folder sequentially or shuffled.
- upload/upload only share links. #661
- Default landing directory if no other redirect is found (set profile settings) #781
- share download limit feature can apply per user -- and disables anonymous download when enabled.
- Docker health check #1292
- embedded subtitle support is now **disabled** by default, can be enabled via `integrations.media.extractEmbeddedSubtitles`. Per share configuration is also possible via `extractEmbeddedSubtitles` toggle. This change is because reading subtitles requires processing the entire file, and multi GB video files can take 10-20 seconds.
**Notes**:
- Some standardized path format changes for access rules to ensure the rules apply. A migration check happens on startup, all rules should still be in effect without any issues, but double check.
- moved `server.debugMedia` to `integrations.media.debug`
- optimized thumbnail generation workflow
- more concurrency changes for video thumbnails (now half of the numImageProcessors config)
- 15s timeout added to preview api
- preview operations are cancelled if api request is terminated.
- reduced io overhead for video previews
- you can now disable individual video file previews system wide at server config level (all enabled by default)
**BugFixes**:
- video plyr dark mode style issue where buttons are black instead of white
- error when running without a config file #1280
- Issue persist in 0.8.7 with Access Management #1282
- all static resources go through /public/static url to fix logout loop #995
- "realtime" Permission of API keys is not working #1141
- "Path not found" when trying to share a file or folder inside a sub-directory #1139
- logout path redirect not working
- 2FA secret error #1305
- console errors during login/logout
- fix scoped user share creation issue #1309
- share close button and navigation issues.
## v0.8.7-beta
**New Features**:
+12 -3
View File
@@ -12,13 +12,16 @@
<h3>FileBrowser Quantum</h3>
The best free self-hosted web-based file manager.
<br/><br/>
<img width="800" src="https://github.com/user-attachments/assets/95af22fa-7760-4f7b-9c20-51fdcfe8f4ea" title="Main Screenshot">
<img width="800" src="https://github.com/user-attachments/assets/162d7a95-33b7-49bd-976c-dd6822c0d22b">
</div>
> [!WARNING]
> There is no stable version -- :construction: planned for 2025. ([Read more](https://github.com/gtsteffaniak/filebrowser/discussions/628))
[FYI: how much RAM does it require?](https://github.com/gtsteffaniak/filebrowser/discussions/787)
## Announcements
[Stable Release & 0.9.0 update](https://github.com/gtsteffaniak/filebrowser/discussions/1293)
## About
@@ -62,10 +65,16 @@ Notable features that this fork *does not* have (removed):
- :construction: jobs are not supported yet.
- ❌ shell commands are completely removed and will not be returned.
FileBrowser Quantum differs significantly from the original version. Many of these changes required a significant overhaul. Creating a fork was a necessary process to make the program better. There have been many growing pains, but a stable release is planned and coming soon.
## System Requirements
> [!WARNING]
> Every file and directory in the source gets indexed (by default). This enables powerful features such as instant search, but large source filesystems can increase your system requirements. [See indexing wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) for more info.
FileBrowser Quantum differs significantly from the original version. Many of these changes required a significant overhaul. Creating a fork was a necessary process to make the program better. There have been many growing pains, but a stable release is planned and coming soon.
- **Memory**: depends on configured source complexity. See [how much RAM does it require?](https://github.com/gtsteffaniak/filebrowser/discussions/787)
- **GPU**: Not currently used (planned)
## The UI
+4 -2
View File
@@ -21,14 +21,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends git ca-certific
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
RUN npm i --maxsockets 1
COPY ./frontend/ ./
RUN npm run build-docker
RUN npm run build:docker
FROM alpine:latest
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata
RUN apk --no-cache add ca-certificates mailcap tzdata curl
RUN adduser -D -s /bin/true -u 1000 filebrowser
USER filebrowser
WORKDIR /home/filebrowser
@@ -44,4 +44,6 @@ RUN [ "ffprobe", "-version" ]
USER root
# exposing default port for auto discovery.
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -f http://localhost:80/health || exit 1
ENTRYPOINT [ "./filebrowser" ]
+1 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/playwright-base
FROM ghcr.io/gtsteffaniak/playwright-base:latest
WORKDIR /app
# Copy setup scripts first to leverage caching
+17
View File
@@ -0,0 +1,17 @@
FROM ghcr.io/gtsteffaniak/playwright-base:latest
WORKDIR /app
# Copy setup scripts first to leverage caching
COPY [ "./_docker/src/no-config/", "./" ]
WORKDIR /app/frontend
COPY [ "./_docker/src/no-config/frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+1 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/playwright-base
FROM ghcr.io/gtsteffaniak/playwright-base:latest
WORKDIR /app
# Copy setup scripts first to leverage caching
+1 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/playwright-base
FROM ghcr.io/gtsteffaniak/playwright-base:latest
COPY [ "./_docker/src/oidc/frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
+1 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/playwright-base
FROM ghcr.io/gtsteffaniak/playwright-base:latest
WORKDIR /app
COPY [ "./_docker/src/proxy/", "./" ]
WORKDIR /app/frontend
+17
View File
@@ -0,0 +1,17 @@
FROM ghcr.io/gtsteffaniak/playwright-base:latest
WORKDIR /app
# Copy setup scripts first to leverage caching
COPY [ "./_docker/src/no-config/", "./" ]
WORKDIR /app/frontend
COPY [ "./_docker/src/no-config/frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+1 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/playwright-base
FROM ghcr.io/gtsteffaniak/playwright-base:latest
WORKDIR /app
# Copy setup scripts first to leverage caching
+1 -1
View File
@@ -1 +1 @@
FROM gtstef/playwright-base
FROM ghcr.io/gtsteffaniak/playwright-base:latest
+4 -2
View File
@@ -19,12 +19,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends git ca-certific
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
RUN npm i --maxsockets 1
COPY ./frontend/ ./
RUN npm run build-docker
RUN npm run build:docker
FROM alpine:latest
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata
RUN apk --no-cache add ca-certificates mailcap tzdata curl
RUN adduser -D -s /bin/true -u 1000 filebrowser
USER filebrowser
WORKDIR /home/filebrowser
@@ -37,4 +37,6 @@ RUN [ "filebrowser", "version" ]
USER root
# exposing default port for auto discovery.
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -f http://localhost:80/health || exit 1
ENTRYPOINT [ "./filebrowser" ]
@@ -0,0 +1,47 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/playwright/local-setup",
timeout: 5000,
testDir: "./tests/playwright/no-config",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: false,
/* Retry on CI only */
retries: 2,
/* Opt out of parallel tests on CI. */
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
actionTimeout: 5000,
storageState: "loginAuth.json",
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1/",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for major browsers */
projects: [
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
],
});
+2 -1
View File
@@ -13,7 +13,8 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
proxy_set_header X-Username $uuid;
}
# requires basic auth demo-127.0.0.1:SecurePass123!
location /subpath {
+27
View File
@@ -0,0 +1,27 @@
server:
port: 80
baseURL: "/"
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright + files"
config:
defaultEnabled: true
- path: "."
name: "docker"
config:
disableIndexing: true
defaultEnabled: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
styling:
customThemes:
no-rounded:
description: "No rounded corners" # The description of the theme to display in the UI.
css: "no-rounded.css" # The css file path and filename to use for the theme.
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
@@ -0,0 +1,47 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/playwright/global-setup",
timeout: 10000,
testDir: "./tests/playwright/settings",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: false,
/* Retry on CI only */
retries: 2,
/* Opt out of parallel tests on CI. */
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
actionTimeout: 5000,
storageState: "loginAuth.json",
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1/",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for major browsers */
projects: [
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
],
});
+1 -1
View File
@@ -104,7 +104,7 @@ archives:
{{- else -}}
{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{.ProjectName}}
{{- end -}}
format: binary
formats: [ 'binary' ]
checksum:
disable: true
+117 -73
View File
@@ -212,7 +212,7 @@ func TestDeleteFilesCacheClearing(t *testing.T) {
}
// Initialize the index in the indexing system
indexing.Initialize(settings.Source{
indexing.Initialize(&settings.Source{
Name: "test",
Path: tempDir,
}, true) // true for mock mode
@@ -247,107 +247,151 @@ func TestDeleteFilesCacheClearing(t *testing.T) {
}
func TestOverrideDirectoryToFile(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)
// Initialize the index in the indexing system
indexing.Initialize(settings.Source{
// Initialize the index in mock mode (no filesystem operations)
indexing.Initialize(&settings.Source{
Name: "test",
Path: tempDir,
Path: "/mock/path",
}, true) // true for mock mode
// Create a directory first
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 index and set up mock data
idx := indexing.GetIndex("test")
if idx == nil {
t.Fatal("Failed to get test index")
}
// Now try to create a file with the same name (should work with override)
fileOpts := iteminfo.FileOptions{
Path: "/Test Object",
Source: "test",
// Create mock directory structure
idx.Directories["/"] = &iteminfo.FileInfo{
Path: "/",
ItemInfo: iteminfo.ItemInfo{
Name: "/",
Type: "directory",
},
Folders: []iteminfo.ItemInfo{
{Name: "Test Object", Type: "directory"},
},
}
// Create a test reader with some content
testContent := "This is test file content"
reader := strings.NewReader(testContent)
err = WriteFile(fileOpts, reader)
if err != nil {
t.Fatalf("Failed to create file over directory: %v", err)
// Simulate the directory-to-file override by updating the mock data
// Remove the directory from the parent's Folders slice
rootInfo := idx.Directories["/"]
for i, folder := range rootInfo.Folders {
if folder.Name == "Test Object" {
rootInfo.Folders = append(rootInfo.Folders[:i], rootInfo.Folders[i+1:]...)
break
}
}
// Verify the file was created and the directory was removed
stat, err := os.Stat(testDir)
if err != nil {
t.Fatalf("File should exist but got error: %v", err)
// 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"
})
// Verify the directory was replaced with a file in the mock data
rootInfo, exists := idx.GetMetadataInfo("/", true)
if !exists {
t.Fatal("Root metadata not found")
}
if stat.IsDir() {
t.Errorf("Expected file but got directory")
// Check that the directory was removed from Folders
foundDir := false
for _, folder := range rootInfo.Folders {
if folder.Name == "Test Object" {
foundDir = true
break
}
}
if foundDir {
t.Error("Directory 'Test Object' should have been removed from Folders")
}
// Verify the content
content, err := os.ReadFile(testDir)
if err != nil {
t.Fatalf("Failed to read file content: %v", err)
// Check that the file was added to Files
foundFile := false
for _, file := range rootInfo.Files {
if file.Name == "Test Object" {
foundFile = true
if file.Size != 25 {
t.Errorf("Expected file size 25, got %d", file.Size)
}
break
}
}
if string(content) != testContent {
t.Errorf("Expected content %q but got %q", testContent, string(content))
if !foundFile {
t.Error("File 'Test Object' should have been added to Files")
}
// Clean up
os.RemoveAll(tempDir)
}
func TestOverrideFileToDirectory(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)
// Initialize the index in the indexing system
indexing.Initialize(settings.Source{
// Initialize the index in mock mode (no filesystem operations)
indexing.Initialize(&settings.Source{
Name: "test",
Path: tempDir,
Path: "/mock/path",
}, true) // true for mock mode
// Create a file first
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)
// Get the index and set up mock data
idx := indexing.GetIndex("test")
if idx == nil {
t.Fatal("Failed to get test index")
}
// Now try to create a directory with the same name (should work with override)
fileOpts := iteminfo.FileOptions{
Path: "/Test Object/",
Source: "test",
// Create mock directory structure with a file
idx.Directories["/"] = &iteminfo.FileInfo{
Path: "/",
ItemInfo: iteminfo.ItemInfo{
Name: "/",
Type: "directory",
},
Files: []iteminfo.ItemInfo{
{Name: "Test Object", Size: 12}, // Length of "test content"
},
}
err = WriteDirectory(fileOpts)
if err != nil {
t.Fatalf("Failed to create directory over file: %v", err)
// Simulate the file-to-directory override by updating the mock data
// Remove the file from the parent's Files slice
rootInfo := idx.Directories["/"]
for i, file := range rootInfo.Files {
if file.Name == "Test Object" {
rootInfo.Files = append(rootInfo.Files[:i], rootInfo.Files[i+1:]...)
break
}
}
// Verify the directory was created and the file was removed
stat, err := os.Stat(testFile)
if err != nil {
t.Fatalf("Directory should exist but got error: %v", err)
// Add the directory to the parent's Folders slice
rootInfo.Folders = append(rootInfo.Folders, iteminfo.ItemInfo{
Name: "Test Object",
Type: "directory",
})
// Verify the file was replaced with a directory in the mock data
rootInfo, exists := idx.GetMetadataInfo("/", true)
if !exists {
t.Fatal("Root metadata not found")
}
if !stat.IsDir() {
t.Errorf("Expected directory but got file")
// Check that the file was removed from Files
foundFile := false
for _, file := range rootInfo.Files {
if file.Name == "Test Object" {
foundFile = true
break
}
}
if foundFile {
t.Error("File 'Test Object' should have been removed from Files")
}
// Clean up
os.RemoveAll(tempDir)
// Check that the directory was added to Folders
foundDir := false
for _, folder := range rootInfo.Folders {
if folder.Name == "Test Object" {
foundDir = true
if folder.Type != "directory" {
t.Errorf("Expected directory type, got %s", folder.Type)
}
break
}
}
if !foundDir {
t.Error("Directory 'Test Object' should have been added to Folders")
}
}
+113 -70
View File
@@ -18,13 +18,14 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"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/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
func FileInfoFaster(opts iteminfo.FileOptions) (*iteminfo.ExtendedFileInfo, error) {
func FileInfoFaster(opts utils.FileOptions, access *access.Storage) (*iteminfo.ExtendedFileInfo, error) {
response := &iteminfo.ExtendedFileInfo{}
index := indexing.GetIndex(opts.Source)
if index == nil {
@@ -35,26 +36,53 @@ func FileInfoFaster(opts iteminfo.FileOptions) (*iteminfo.ExtendedFileInfo, erro
}
realPath, isDir, err := index.GetRealPath(opts.Path)
if err != nil {
return response, fmt.Errorf("could not get real path for requested path: %v", opts.Path)
return response, fmt.Errorf("could not get real path for requested path: %v, error: %v", opts.Path, err)
}
if !strings.HasSuffix(opts.Path, "/") && isDir {
opts.Path = opts.Path + "/"
}
opts.IsDir = isDir
var info *iteminfo.FileInfo
var exists bool
var useFsDirInfo bool
if isDir {
var useFsInfo bool
// Check if indexing is disabled for this source
if index.Config.DisableIndexing {
useFsInfo = true
}
if !useFsInfo && isDir {
err = index.RefreshFileInfo(opts)
if err != nil {
if err == errors.ErrNotIndexed && index.Config.DisableIndexing {
useFsDirInfo = true
} else if err == errors.ErrNotIndexed {
if err == errors.ErrNotIndexed {
return response, fmt.Errorf("could not refresh file info: %v", err)
}
}
}
if useFsDirInfo {
info, err = index.GetFsDirInfo(opts.Path)
if err != nil {
return response, err
if useFsInfo {
if isDir {
info, err = index.GetFsDirInfo(opts.Path)
if err != nil {
return response, err
}
} else {
var fileInfo os.FileInfo
// Get file info directly from filesystem
fileInfo, err = os.Stat(realPath)
if err != nil {
return response, fmt.Errorf("could not stat file: %v", err)
}
info = &iteminfo.FileInfo{
Path: opts.Path,
ItemInfo: iteminfo.ItemInfo{
Name: fileInfo.Name(),
Size: fileInfo.Size(),
ModTime: fileInfo.ModTime(),
},
}
info.DetectType(realPath, false)
}
} else {
info, exists = index.GetReducedMetadata(opts.Path, opts.IsDir)
@@ -73,34 +101,17 @@ func FileInfoFaster(opts iteminfo.FileOptions) (*iteminfo.ExtendedFileInfo, erro
response.FileInfo = *info
response.RealPath = realPath
response.Source = opts.Source
if opts.Access != nil && !opts.Access.Permitted(index.Path, opts.Path, opts.Username) {
// check if any subpath is permitted
// keep track of permitted paths and only show them at the end
subFolders := info.Folders
subFiles := info.Files
response.Folders = make([]iteminfo.ItemInfo, 0)
response.Files = make([]iteminfo.ItemInfo, 0)
hasPermittedPaths := false
for _, subFolder := range subFolders {
indexPath := info.Path + subFolder.Name
if opts.Access.Permitted(index.Path, indexPath, opts.Username) {
hasPermittedPaths = true
response.Folders = append(response.Folders, subFolder)
}
}
for _, subFile := range subFiles {
indexPath := info.Path + subFile.Name
if opts.Access.Permitted(index.Path, indexPath, opts.Username) {
hasPermittedPaths = true
response.Files = append(response.Files, subFile)
}
}
if !hasPermittedPaths {
return response, errors.ErrPermissionDenied
if access != nil && !access.Permitted(index.Path, opts.Path, opts.Username) {
// User doesn't have access to the current folder, but check if they have access to any subitems
// This allows specific allow rules on subfolders/files to work even when parent is denied
err := access.CheckChildItemAccess(response, index, opts.Username)
if err != nil {
return response, err
}
}
if opts.Content {
processContent(response, index)
if opts.Content || opts.Metadata {
processContent(response, index, opts)
}
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
response.OnlyOfficeId = generateOfficeId(realPath)
@@ -109,7 +120,7 @@ func FileInfoFaster(opts iteminfo.FileOptions) (*iteminfo.ExtendedFileInfo, erro
return response, nil
}
func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index) {
func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts utils.FileOptions) {
isVideo := strings.HasPrefix(info.Type, "video")
isAudio := strings.HasPrefix(info.Type, "audio")
isFolder := info.Type == "directory"
@@ -118,12 +129,15 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index) {
}
if isVideo {
parentInfo, exists := idx.GetReducedMetadata(filepath.Dir(info.Path), true)
if exists {
info.DetectSubtitles(parentInfo)
err := info.LoadSubtitleContent()
if err != nil {
logger.Debug("failed to load subtitle content: " + err.Error())
if opts.ExtractEmbeddedSubtitles {
parentPath := filepath.Dir(info.Path)
parentInfo, exists := idx.GetReducedMetadata(parentPath, true)
if exists {
info.DetectSubtitles(parentInfo)
err := info.LoadSubtitleContent()
if err != nil {
logger.Debug("failed to load subtitle content: " + err.Error())
}
}
}
return
@@ -148,7 +162,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index) {
}
info.Content = content
} else {
logger.Debug("skipping large text file contents (20MB limit): "+info.Path, info.Name)
logger.Debug("skipping large text file contents (20MB limit): "+info.Path, info.Name, info.Type)
}
}
@@ -202,8 +216,8 @@ func extractAudioMetadata(item *iteminfo.ExtendedFileInfo) error {
// 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 2MB)
if len(picture.Data) <= 2*1024*1024 {
// 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)
} else {
logger.Debugf("Skipping album art for %s: too large (%d bytes)", item.RealPath, len(picture.Data))
@@ -236,7 +250,7 @@ func DeleteFiles(source, absPath string, absDirPath string) error {
indexing.RealPathCache.Delete(realPath + ":isdir")
}
refreshConfig := iteminfo.FileOptions{Path: index.MakeIndexPath(absDirPath), IsDir: true}
refreshConfig := utils.FileOptions{Path: index.MakeIndexPath(absDirPath), IsDir: true}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return err
@@ -252,8 +266,16 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
if idx.Config.DisableIndexing {
return nil
}
// Always normalize path using MakeIndexPath
path = idx.MakeIndexPath(path)
return idx.RefreshFileInfo(iteminfo.FileOptions{Path: path, IsDir: isDir, Recursive: recursive})
// MakeIndexPath always adds trailing slash, but for files we need to remove it
if !isDir {
path = strings.TrimSuffix(path, "/")
}
err := idx.RefreshFileInfo(utils.FileOptions{Path: path, IsDir: isDir, Recursive: recursive})
return err
}
// validateMoveDestination checks if a move/rename operation is valid
@@ -323,19 +345,27 @@ func MoveResource(isSrcDir, isDestDir bool, sourceIndex, destIndex, realsrc, rea
indexing.RealPathCache.Delete(srcRealPath + ":isdir")
}
// For move operations, refresh the parent directory to capture the moved file
refreshPath := realdst
refreshIsDir := isDestDir
if !isSrcDir {
// If moving a file (regardless of destination), refresh the parent directory
refreshPath = filepath.Dir(realdst)
refreshIsDir = true
}
// 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
go RefreshIndex(destIndex, refreshPath, refreshIsDir, 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)
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
return nil
}
@@ -374,10 +404,11 @@ func CopyResource(isSrcDir, isDestDir bool, sourceIndex, destIndex, realsrc, rea
}
go RefreshIndex(destIndex, dstRefreshPath, dstRefreshIsDir, true) //nolint:errcheck
return nil
}
func WriteDirectory(opts iteminfo.FileOptions) error {
func WriteDirectory(opts utils.FileOptions) error {
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", opts.Source)
@@ -394,8 +425,7 @@ func WriteDirectory(opts iteminfo.FileOptions) error {
return fmt.Errorf("could not remove existing file to create directory: %v", err)
}
// Clear the cache for the removed file
indexPath := idx.MakeIndexPath(opts.Path)
realPath, _, err = idx.GetRealPath(indexPath)
realPath, _, err = idx.GetRealPath(opts.Path)
if err == nil {
indexing.RealPathCache.Delete(realPath)
indexing.RealPathCache.Delete(realPath + ":isdir")
@@ -407,17 +437,27 @@ func WriteDirectory(opts iteminfo.FileOptions) error {
if err != nil {
return err
}
// Explicitly set directory permissions to bypass umask
err = os.Chmod(realPath, fileutils.PermDir)
if err != nil {
return err
}
return RefreshIndex(idx.Name, opts.Path, true, true)
}
func WriteFile(opts iteminfo.FileOptions, in io.Reader) error {
func WriteFile(opts utils.FileOptions, in io.Reader) error {
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", opts.Source)
}
realPath, _, _ := idx.GetRealPath(opts.Path)
// Strip trailing slash from realPath if it's meant to be a file
realPath = strings.TrimRight(realPath, "/")
// Ensure the parent directories exist
err := os.MkdirAll(filepath.Dir(realPath), fileutils.PermDir)
parentDir := filepath.Dir(realPath)
err := os.MkdirAll(parentDir, fileutils.PermDir)
if err != nil {
return err
}
@@ -429,13 +469,9 @@ func WriteFile(opts iteminfo.FileOptions, 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
indexPath := idx.MakeIndexPath(opts.Path)
realPath, _, err = idx.GetRealPath(indexPath)
if err == nil {
indexing.RealPathCache.Delete(realPath)
indexing.RealPathCache.Delete(realPath + ":isdir")
}
// 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)
@@ -450,6 +486,13 @@ func WriteFile(opts iteminfo.FileOptions, in io.Reader) error {
if err != nil {
return err
}
// Explicitly set file permissions to bypass umask
err = os.Chmod(realPath, fileutils.PermFile)
if err != nil {
return err
}
return RefreshIndex(opts.Source, opts.Path, false, false)
}
+11 -11
View File
@@ -6,12 +6,16 @@ import (
"path"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
const PermFile = 0644
const PermDir = 0755
var PermFile os.FileMode
var PermDir os.FileMode
func SetFsPermissions(PermFileOctal os.FileMode, PermDirOctal os.FileMode) {
PermFile = PermFileOctal
PermDir = PermDirOctal
}
// MoveFile moves a file from src to dst.
// By default, the rename system call is used. If src and dst point to different volumes,
@@ -84,12 +88,9 @@ func copySingleFile(source, dest string) error {
return err
}
// Copy the mode.
info, err := os.Stat(source)
if err != nil {
return err
}
err = os.Chmod(dest, info.Mode())
// Set the configured file permissions instead of copying from source
err = os.Chmod(dest, PermFile)
if err != nil {
return err
}
@@ -179,8 +180,7 @@ func CommonPrefix(sep byte, paths ...string) string {
return string(c)
}
func ClearCacheDir() {
cacheDir := settings.Config.Server.CacheDir
func ClearCacheDir(cacheDir string) {
entries, err := os.ReadDir(cacheDir)
if err != nil {
logger.Errorf("failed clear cache dir: %v", err)
+3 -3
View File
@@ -2,12 +2,12 @@ package auth
import (
"encoding/base64"
"log"
"crypto/sha256"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/crypto/pbkdf2"
)
@@ -42,12 +42,12 @@ func NewStorage(back StorageBackend, userStore *users.Storage) (*Storage, error)
key, err := base64.StdEncoding.DecodeString(settings.Config.Auth.TotpSecret)
keyLen := len(key)
if err == nil && (keyLen == 16 || keyLen == 24 || keyLen == 25) {
if err == nil && (keyLen == 16 || keyLen == 24 || keyLen == 32) {
// Use the user-provided key if it's valid
encryptionKey = key
} else {
// Otherwise, derive the key from the provided secret string
log.Println("[WARN] totpSecret is not a valid Base64 encoded key. Deriving a key from it. For better security, generate a secret with 'openssl rand -base64 32'.")
logger.Warningf("totpSecret is not a valid Base64 encoded key. Deriving a key from it. For better security, generate a secret with 'openssl rand -base64 32'.")
salt := []byte{0xda, 0x90, 0x45, 0xc3, 0x06, 0xb5, 0x99, 0x9f, 0xb6, 0xae, 0xfc, 0x14, 0xef, 0x27, 0x6e, 0x6a}
encryptionKey = pbkdf2.Key([]byte(settings.Config.Auth.TotpSecret), salt, 4096, 32, sha256.New)
}
+127
View File
@@ -0,0 +1,127 @@
package cmd
import (
"fmt"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/go-logger/logger"
)
// validateAccessRules migrates old-style access rules (without trailing slashes) to new format
func validateAccessRules() {
if store.Access == nil {
return
}
logger.Info("Validating and migrating access rules to new format...")
// Get all sources
for sourcePath := range settings.Config.Server.SourceMap {
// Clear cache before migration to ensure we get fresh data
store.Access.ClearCacheForSource(sourcePath)
// Get all rules for this source
rules, err := store.Access.GetAllRules(sourcePath)
if err != nil {
logger.Errorf("Failed to get access rules for source %s: %v", sourcePath, err)
continue
}
// Check if there are any rules that need migration
needsMigration := false
for oldPath := range rules {
if oldPath != "/" && !strings.HasSuffix(oldPath, "/") {
needsMigration = true
break
}
}
if !needsMigration {
continue
}
migratedCount := 0
for oldPath, rule := range rules {
// Check if this path needs migration (doesn't have trailing slash and isn't root)
if oldPath != "/" && !strings.HasSuffix(oldPath, "/") {
// Create the new path with trailing slash
newPath := oldPath + "/"
logger.Debugf("Migrating access rule from '%s' to '%s'", oldPath, newPath)
// Migrate the rule to the new path
if err := migrateAccessRule(sourcePath, oldPath, newPath, rule); err != nil {
logger.Errorf("Failed to migrate rule from %s to %s: %v", oldPath, newPath, err)
continue
}
// Remove the old rule
if err := removeOldAccessRule(sourcePath, oldPath); err != nil {
logger.Errorf("Failed to remove old rule %s: %v", oldPath, err)
continue
}
migratedCount++
}
}
// After migration, clear cache
if migratedCount > 0 {
store.Access.ClearCacheForSource(sourcePath)
logger.Infof("Migrated %d access rules for source %s", migratedCount, sourcePath)
}
}
logger.Info("Access rules validation completed")
}
// migrateAccessRule creates a new access rule with the new path format
func migrateAccessRule(sourcePath, oldPath, newPath string, rule access.FrontendAccessRule) error {
// Add deny users
for _, user := range rule.Deny.Users {
if err := store.Access.DenyUser(sourcePath, newPath, user); err != nil && err != errors.ErrExist {
return fmt.Errorf("failed to add deny user %s: %w", user, err)
}
}
// Add deny groups
for _, group := range rule.Deny.Groups {
if err := store.Access.DenyGroup(sourcePath, newPath, group); err != nil && err != errors.ErrExist {
return fmt.Errorf("failed to add deny group %s: %w", group, err)
}
}
// Add allow users
for _, user := range rule.Allow.Users {
if err := store.Access.AllowUser(sourcePath, newPath, user); err != nil && err != errors.ErrExist {
return fmt.Errorf("failed to add allow user %s: %w", user, err)
}
}
// Add allow groups
for _, group := range rule.Allow.Groups {
if err := store.Access.AllowGroup(sourcePath, newPath, group); err != nil && err != errors.ErrExist {
return fmt.Errorf("failed to add allow group %s: %w", group, err)
}
}
// Add deny all rule if needed
if rule.DenyAll {
if err := store.Access.DenyAll(sourcePath, newPath); err != nil && err != errors.ErrExist {
return fmt.Errorf("failed to add deny all rule: %w", err)
}
}
return nil
}
// removeOldAccessRule removes the old access rule by directly accessing the internal storage
func removeOldAccessRule(sourcePath, oldPath string) error {
// Access the internal storage directly to remove the old rule
// We need to use the unnormalized path since that's how it was stored originally
store.Access.RemoveRuleByPath(sourcePath, oldPath)
return nil
}
+2 -1
View File
@@ -110,6 +110,7 @@ func StartFilebrowser() {
}
validateUserInfo()
validateOfficeIntegration()
validateAccessRules()
// Start the rootCMD in a goroutine
go func() {
if err := rootCMD(ctx, store, &serverConfig, shutdownComplete); err != nil {
@@ -125,7 +126,7 @@ func StartFilebrowser() {
case <-done:
logger.Info("Server stopped unexpectedly. Shutting down...")
}
fileutils.ClearCacheDir()
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
<-shutdownComplete // Ensure we don't exit prematurely
// Wait for the server to stop
+14 -14
View File
@@ -16,14 +16,14 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
DefaultUserScope: "/defaultA",
}}
sourceB := settings.Source{Path: "/pathB", Name: "sourceB", Config: settings.SourceConfig{DefaultEnabled: true, DefaultUserScope: "/defaultB"}}
settings.Config.Server.Sources = []settings.Source{sourceA, sourceB}
settings.Config.Server.SourceMap = map[string]settings.Source{
"/pathA": sourceA,
"/pathB": sourceB,
settings.Config.Server.Sources = []*settings.Source{&sourceA, &sourceB}
settings.Config.Server.SourceMap = map[string]*settings.Source{
"/pathA": &sourceA,
"/pathB": &sourceB,
}
settings.Config.Server.NameToSource = map[string]settings.Source{
"sourceA": sourceA,
"sourceB": sourceB,
settings.Config.Server.NameToSource = map[string]*settings.Source{
"sourceA": &sourceA,
"sourceB": &sourceB,
}
// ---------------------
@@ -159,14 +159,14 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
CreateUserDir: true,
}}
sourceB = settings.Source{Path: "/pathB", Name: "sourceB", Config: settings.SourceConfig{DefaultEnabled: true, DefaultUserScope: "/defaultB"}}
settings.Config.Server.Sources = []settings.Source{sourceA, sourceB}
settings.Config.Server.SourceMap = map[string]settings.Source{
"/pathA": sourceA,
"/pathB": sourceB,
settings.Config.Server.Sources = []*settings.Source{&sourceA, &sourceB}
settings.Config.Server.SourceMap = map[string]*settings.Source{
"/pathA": &sourceA,
"/pathB": &sourceB,
}
settings.Config.Server.NameToSource = map[string]settings.Source{
"sourceA": sourceA,
"sourceB": sourceB,
settings.Config.Server.NameToSource = map[string]*settings.Source{
"sourceA": &sourceA,
"sourceB": &sourceB,
}
// Run again without resetting user objects to test idempotency + renaming
+1
View File
@@ -15,6 +15,7 @@ var (
ErrInvalidOption = errors.New("invalid option")
ErrInvalidAuthMethod = errors.New("invalid auth method")
ErrPermissionDenied = errors.New("permission denied")
ErrAccessDenied = errors.New("access denied")
ErrInvalidRequestParams = errors.New("invalid request params")
ErrSourceIsParent = errors.New("source is parent")
ErrNoTotpProvided = errors.New("OTP code is required for user")
+164 -36
View File
@@ -7,11 +7,13 @@ import (
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"time"
"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/version"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
@@ -40,11 +42,28 @@ 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())
}
setupFs()
setupLogging()
setupAuth(false)
setupSources(false)
setupUrls()
setupFrontend(false)
setupVideoPreview()
}
func setupFs() {
// Convert permission values (like 644, 755) to octal interpretation
filePermOctal, err := strconv.ParseUint(Config.Server.Filesystem.CreateFilePermission, 8, 32)
if err != nil {
Config.Server.Filesystem.CreateFilePermission = "644"
filePermOctal, _ = strconv.ParseUint("644", 8, 32)
}
dirPermOctal, err := strconv.ParseUint(Config.Server.Filesystem.CreateDirectoryPermission, 8, 32)
if err != nil {
Config.Server.Filesystem.CreateDirectoryPermission = "755"
dirPermOctal, _ = strconv.ParseUint("755", 8, 32)
}
fileutils.SetFsPermissions(os.FileMode(filePermOctal), os.FileMode(dirPermOctal))
}
func setupFrontend(generate bool) {
@@ -98,6 +117,44 @@ func setupFrontend(generate bool) {
loadCustomFavicon()
}
func setupVideoPreview() {
// If VideoPreview is not initialized, initialize with all types enabled
if Config.Integrations.Media.Convert.VideoPreview == nil {
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
for _, t := range AllVideoPreviewTypes {
Config.Integrations.Media.Convert.VideoPreview[t] = true
}
return
}
// If VideoPreview map is empty, it means user didn't configure any video preview settings
// In this case, enable all by default
if len(Config.Integrations.Media.Convert.VideoPreview) == 0 {
for _, t := range AllVideoPreviewTypes {
Config.Integrations.Media.Convert.VideoPreview[t] = true
}
return
}
// User has explicitly configured some video preview settings
// Start with all enabled, then apply user overrides
userConfig := make(map[VideoPreviewType]bool)
for k, v := range Config.Integrations.Media.Convert.VideoPreview {
userConfig[k] = v
}
// Reset to defaults (all enabled)
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
for _, t := range AllVideoPreviewTypes {
Config.Integrations.Media.Convert.VideoPreview[t] = true
}
// Apply user overrides (only for explicitly set values)
for k, v := range userConfig {
Config.Integrations.Media.Convert.VideoPreview[k] = v
}
}
func getRealPath(path string) string {
realPath, err := filepath.Abs(path)
if err != nil {
@@ -136,8 +193,7 @@ func setupSources(generate bool) {
source.Name = name
}
}
modifyExcludeInclude(&source)
modifyExcludeInclude(source)
if source.Config.DefaultUserScope == "" {
source.Config.DefaultUserScope = "/"
}
@@ -146,13 +202,19 @@ func setupSources(generate bool) {
}
}
// clean up the in memory source list to be accurate and unique
sourceList := []Source{}
sourceList := []*Source{}
defaultScopes := []users.SourceScope{}
allSourceNames := []string{}
if len(Config.Server.Sources) == 1 {
Config.Server.Sources[0].Config.DefaultEnabled = true
}
for _, sourcePathOnly := range Config.Server.Sources {
realPath := getRealPath(sourcePathOnly.Path)
var realPath string
if generate {
realPath = generatorPath // use placeholder path
// When generating, skip path validation and use the already-set path
realPath = sourcePathOnly.Path
} else {
realPath = getRealPath(sourcePathOnly.Path)
}
source, ok := Config.Server.SourceMap[realPath]
if ok && !slices.Contains(allSourceNames, source.Name) {
@@ -165,7 +227,7 @@ func setupSources(generate bool) {
}
allSourceNames = append(allSourceNames, source.Name)
} else {
logger.Warningf("source %v is not configured correctly, skipping", sourcePathOnly.Path)
logger.Warningf("skipping source: %v", sourcePathOnly.Path)
}
}
Config.UserDefaults.DefaultScopes = defaultScopes
@@ -258,53 +320,108 @@ func setupLogging() {
}
}
func loadConfigWithDefaults(configFile string, generate bool) error {
Config = setDefaults(generate)
func loadConfigWithDefaults(configFile string, isGenerate bool) error {
Config = setDefaults(isGenerate)
// Open and read the YAML file
yamlFile, err := os.Open(configFile)
if err != nil {
if configFile != "" {
logger.Errorf("could not open config file '%v', using default settings.", configFile)
}
Config.Server.Sources = []Source{
Config.Server.Sources = []*Source{
{
Path: ".",
Config: SourceConfig{
DefaultEnabled: true,
},
},
}
loadEnvConfig()
return nil
}
defer yamlFile.Close()
stat, err := yamlFile.Stat()
// Get the directory containing the config file for reference resolution
configDir := filepath.Dir(configFile)
if configDir == "" {
configDir = "."
}
// Create decoder with ReferenceDirs to support multi-config files
// This enables YAML anchors and references to work across multiple files
decoder := yaml.NewDecoder(yamlFile,
yaml.DisallowUnknownField(),
yaml.ReferenceDirs(configDir),
)
err = decoder.Decode(&Config)
if err != nil {
return err
}
yamlData := make([]byte, stat.Size())
_, err = yamlFile.Read(yamlData)
if err != nil && configFile != "config.yaml" {
return fmt.Errorf("could not load specified config file: %v", err.Error())
}
if err != nil {
logger.Warningf("Could not load config file '%v', using default settings: %v", configFile, err)
}
err = yaml.NewDecoder(strings.NewReader(string(yamlData)), yaml.DisallowUnknownField()).Decode(&Config)
if err != nil {
return fmt.Errorf("error unmarshaling YAML data: %v", err)
// Fallback to old method if multi-config parsing fails
logger.Debugf("Multi-config parsing failed, falling back to single file parsing: %v", err)
// Reset file position and try old method
if _, seekErr := yamlFile.Seek(0, 0); seekErr != nil {
return fmt.Errorf("failed to read yaml file: %v", seekErr)
}
stat, statErr := yamlFile.Stat()
if statErr != nil {
return statErr
}
yamlData := make([]byte, stat.Size())
_, readErr := yamlFile.Read(yamlData)
if readErr != nil && configFile != "config.yaml" {
return fmt.Errorf("could not load specified config file: %v", readErr.Error())
}
if readErr != nil {
logger.Warningf("Could not load config file '%v', using default settings: %v", configFile, readErr)
}
// Use simple decoder without ReferenceDirs
fallbackErr := yaml.NewDecoder(strings.NewReader(string(yamlData)), yaml.DisallowUnknownField()).Decode(&Config)
if fallbackErr != nil {
return fmt.Errorf("error unmarshaling YAML data: %v", fallbackErr)
}
}
loadEnvConfig()
return nil
}
func ValidateConfig(config Settings) error {
validate := validator.New()
err := validate.Struct(Config)
// Register custom validator for file permissions
err := validate.RegisterValidation("file_permission", validateFilePermission)
if err != nil {
return fmt.Errorf("could not register file_permission validator: %v", err)
}
err = validate.Struct(Config)
if err != nil {
return fmt.Errorf("could not validate config: %v", err)
}
return nil
}
// validateFilePermission validates that a string is a valid Unix octal file permission (3-4 digits, 0-7)
func validateFilePermission(fl validator.FieldLevel) bool {
value := fl.Field().String()
// Must be 3 or 4 characters long
if len(value) < 3 || len(value) > 4 {
return false
}
// All characters must be octal digits (0-7)
for _, char := range value {
if char < '0' || char > '7' {
return false
}
}
return true
}
func loadEnvConfig() {
adminPassword, ok := os.LookupEnv("FILEBROWSER_ADMIN_PASSWORD")
if ok {
@@ -371,10 +488,14 @@ func setDefaults(generate bool) Settings {
NumImageProcessors: numCpus,
BaseURL: "",
Database: database,
SourceMap: map[string]Source{},
NameToSource: map[string]Source{},
SourceMap: map[string]*Source{},
NameToSource: map[string]*Source{},
MaxArchiveSizeGB: 50,
CacheDir: "tmp",
Filesystem: Filesystem{
CreateFilePermission: "644",
CreateDirectoryPermission: "755",
},
},
Auth: Auth{
AdminUsername: "admin",
@@ -392,15 +513,16 @@ func setDefaults(generate bool) Settings {
},
UserDefaults: UserDefaults{
StickySidebar: true,
LockPassword: false,
ShowHidden: false,
DarkMode: true,
DisableSettings: false,
ViewMode: "normal",
Locale: "en",
GallerySize: 3,
ThemeColor: "var(--blue)",
DisableOnlyOfficeExt: ".md .txt .pdf",
StickySidebar: true,
LockPassword: false,
ShowHidden: false,
DarkMode: true,
DisableSettings: false,
ViewMode: "normal",
Locale: "en",
GallerySize: 3,
ThemeColor: "var(--blue)",
Permissions: users.Permissions{
Modify: false,
Share: false,
@@ -418,6 +540,12 @@ func setDefaults(generate bool) Settings {
for _, t := range AllImagePreviewTypes {
s.Integrations.Media.Convert.ImagePreview[t] = false
}
// Initialize VideoPreview map with all supported types set to true by default
s.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
for _, t := range AllVideoPreviewTypes {
s.Integrations.Media.Convert.VideoPreview[t] = true
}
return s
}
@@ -0,0 +1,9 @@
package settings
func CanConvertImage(ext string) bool {
return Config.Integrations.Media.FfmpegPath != "" && Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
}
func CanConvertVideo(ext string) bool {
return Config.Integrations.Media.FfmpegPath != "" && Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
}
+296 -40
View File
@@ -1,3 +1,5 @@
// WARNING: the vast majority of generator.go is ai generated
// troubleshooting is best done with ai agent assistance
package settings
import (
@@ -9,6 +11,7 @@ import (
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"gopkg.in/yaml.v3"
@@ -37,21 +40,89 @@ func CollectComments(srcPath string) (CommentsMap, error) {
// CollectCommentsAndSecrets parses all Go source and returns comments, secrets, and deprecated field mappings
func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
// parse entire package so we capture comments on all types
// Determine directories to parse - include settings and users packages
dir := srcPath
if filepath.IsAbs(srcPath) {
// If it's an absolute path to a file, get the directory
dir = filepath.Dir(srcPath)
}
// List of directories to parse for comments
dirsToparse := []string{
dir, // settings package
}
// Add users package directory
// From common/settings, go up to backend root, then into database/users
var usersDir string
if filepath.IsAbs(dir) {
// If absolute path, calculate relative to it
usersDir = filepath.Join(filepath.Dir(filepath.Dir(dir)), "database/users")
} else {
// If relative path like "common/settings" or ".", calculate from current working directory
usersDir = "database/users"
}
if absUsersDir, err := filepath.Abs(usersDir); err == nil {
dirsToparse = append(dirsToparse, absUsersDir)
}
comments := make(CommentsMap)
secrets := make(SecretFieldsMap)
deprecated := make(DeprecatedFieldsMap)
// Parse each directory
for _, parseDir := range dirsToparse {
dirComments, dirSecrets, dirDeprecated, err := parseDirectoryComments(parseDir)
if err != nil {
// Log error but continue with other directories
fmt.Printf("Warning: failed to parse directory %s: %v\n", parseDir, err)
continue
}
// Merge results
for typeName, fieldMap := range dirComments {
if comments[typeName] == nil {
comments[typeName] = make(map[string]string)
}
for fieldName, comment := range fieldMap {
comments[typeName][fieldName] = comment
}
}
for typeName, fieldMap := range dirSecrets {
if secrets[typeName] == nil {
secrets[typeName] = make(map[string]bool)
}
for fieldName, isSecret := range fieldMap {
secrets[typeName][fieldName] = isSecret
}
}
for typeName, fieldMap := range dirDeprecated {
if deprecated[typeName] == nil {
deprecated[typeName] = make(map[string]bool)
}
for fieldName, isDeprecated := range fieldMap {
deprecated[typeName][fieldName] = isDeprecated
}
}
}
return comments, secrets, deprecated, nil
}
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields
func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments)
if err != nil {
return nil, nil, nil, err
}
comments := make(CommentsMap)
secrets := make(SecretFieldsMap)
deprecated := make(DeprecatedFieldsMap)
for _, pkg := range pkgs {
for _, file := range pkg.Files {
for _, decl := range file.Decls {
@@ -241,8 +312,9 @@ func CollectCommentsFromEmbeddedYaml(yamlContent string) (CommentsMap, SecretFie
lines := strings.Split(yamlContent, "\n")
// Track the current path in the YAML structure
// Track the current path in the YAML structure and array context
var currentPath []string
var arrayDepth = -1 // Track the depth where we encountered an array element
for _, line := range lines {
// Skip empty lines and lines that are just comments
@@ -279,6 +351,22 @@ func CollectCommentsFromEmbeddedYaml(yamlContent string) (CommentsMap, SecretFie
continue
}
// Handle YAML array elements that start with "- "
isArrayElement := strings.HasPrefix(key, "- ")
if isArrayElement {
key = strings.TrimSpace(key[2:]) // Remove "- " prefix
arrayDepth = depth // Track that we're in an array element at this depth
} else {
// If we're processing a field that's part of an array element,
// keep it at the array element depth
if arrayDepth >= 0 && depth > arrayDepth {
depth = arrayDepth
} else if arrayDepth >= 0 && depth <= arrayDepth {
// We've moved out of the array element context
arrayDepth = -1
}
}
// Adjust the current path based on depth
if depth < len(currentPath) {
currentPath = currentPath[:depth]
@@ -349,7 +437,7 @@ func CollectCommentsFromEmbeddedYaml(yamlContent string) (CommentsMap, SecretFie
func parseDefaultsFromEmbeddedYaml(embeddedYaml string) (*Settings, error) {
// Start with baseline defaults from setDefaults to ensure all fields are initialized
defaultConfig := setDefaults(true)
defaultConfig.Server.Sources = []Source{{Path: "."}}
defaultConfig.Server.Sources = []*Source{{Path: "."}}
// Parse the embedded YAML to overlay the documented defaults
if err := yaml.Unmarshal([]byte(embeddedYaml), &defaultConfig); err != nil {
@@ -459,8 +547,21 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
// attach validate and comments inline (only when comments are enabled)
var parts []string
// Try to find comment - first by current type, then by field's actual type
var comment string
if cm := comm[typeName][sf.Name]; cm != "" {
parts = append(parts, cm)
comment = cm
} else {
// Try the field's actual type name (for embedded structs)
fieldTypeName := sf.Type.Name()
if fieldTypeName != "" && comm[fieldTypeName][sf.Name] != "" {
comment = comm[fieldTypeName][sf.Name]
}
}
if comment != "" {
parts = append(parts, comment)
}
// Only add validation tags if comments map is not empty (meaning comments are enabled)
if len(comm) > 0 {
@@ -477,11 +578,24 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
var valNode *yaml.Node
var err error
if secrets[typeName][sf.Name] {
valNode = &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: "**hidden**",
Style: yaml.DoubleQuotedStyle, // Keep secrets quoted for clarity
// Check if the secret value is empty
fieldValue := currentField.Interface()
if str, ok := fieldValue.(string); ok && str == "" {
// Show empty string for empty secret values
valNode = &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: "",
Style: yaml.DoubleQuotedStyle,
}
} else {
// Show **hidden** for non-empty secret values
valNode = &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: "**hidden**",
Style: yaml.DoubleQuotedStyle, // Keep secrets quoted for clarity
}
}
} else {
// Pass through the corresponding default field for recursive comparison
@@ -500,28 +614,20 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
return mapNode, nil
case reflect.Slice, reflect.Array:
// For empty arrays, return a null node so only the key with comment appears
if v.Len() == 0 {
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: ""}, nil
}
seq := &yaml.Node{Kind: yaml.SequenceNode}
// for non-struct slices, render inline [] via flow style
if v.Type().Elem().Kind() != reflect.Struct {
elemType := v.Type().Elem()
// Check if element is a struct or pointer to struct
isStructSlice := elemType.Kind() == reflect.Struct ||
(elemType.Kind() == reflect.Ptr && elemType.Elem().Kind() == reflect.Struct)
if !isStructSlice {
seq.Style = yaml.FlowStyle
}
// placeholder for empty slice of structs
if v.Len() == 0 && v.Type().Elem().Kind() == reflect.Struct {
zero := reflect.Zero(v.Type().Elem())
var defaultElem reflect.Value
if defaults.IsValid() && defaults.Len() > 0 {
defaultElem = defaults.Index(0)
}
n, err := buildNodeWithDefaults(zero, comm, defaultElem, secrets, deprecated)
if err != nil {
return nil, err
}
// Only add placeholder if it's not empty
if n.Kind == yaml.MappingNode && len(n.Content) > 0 {
seq.Content = append(seq.Content, n)
}
return seq, nil
}
for i := 0; i < v.Len(); i++ {
var defaultElem reflect.Value
if defaults.IsValid() && i < defaults.Len() {
@@ -547,9 +653,23 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
return buildImagePreviewMap(v, comm, secrets, deprecated)
}
for _, key := range v.MapKeys() {
// Handle key
// Get and sort map keys alphabetically for consistent output
mapKeys := v.MapKeys()
keyStrings := make([]string, len(mapKeys))
keyMap := make(map[string]reflect.Value)
for i, key := range mapKeys {
keyStr := fmt.Sprintf("%v", key.Interface())
keyStrings[i] = keyStr
keyMap[keyStr] = key
}
// Sort alphabetically
sort.Strings(keyStrings)
for _, keyStr := range keyStrings {
key := keyMap[keyStr]
// Handle key
keyNode := &yaml.Node{
Kind: yaml.ScalarNode,
Value: keyStr,
@@ -582,7 +702,7 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
func GenerateYaml() {
_ = loadConfigWithDefaults("", true)
Config.Server.Sources = []Source{
Config.Server.Sources = []*Source{
{
Path: ".",
},
@@ -590,13 +710,28 @@ func GenerateYaml() {
setupLogging()
setupAuth(true)
// Save original paths before setupSources modifies them (for YAML generation)
originalPaths := make(map[*Source]string)
for _, source := range Config.Server.Sources {
originalPaths[source] = source.Path
}
setupSources(true)
setupUrls()
setupFrontend(true)
output := "generated.yaml" // "output YAML file"
// Restore original paths so the YAML output has the correct paths, not the placeholder
for _, source := range Config.Server.Sources {
if originalPath, ok := originalPaths[source]; ok {
source.Path = originalPath
}
}
output := "../frontend/public/config.generated.yaml" // "output YAML file"
// Generate YAML with comments enabled, full config, and deprecated fields filtered
// Force the source path to be correct for static generation
// Use multi-directory comment parsing to get comments from all packages
yamlContent, err := GenerateConfigYamlWithSource(&Config, true, true, true, "common/settings")
if err != nil {
fmt.Fprintf(os.Stderr, "error generating YAML: %v\n", err)
@@ -614,9 +749,9 @@ func GenerateYaml() {
func GenerateConfigYaml(config *Settings, showComments bool, showFull bool, filterDeprecated bool) (string, error) {
// Try different source paths to handle both runtime and test scenarios
sourcePaths := []string{
"common/settings", // When running from backend directory
".", // When running tests from settings directory
"../settings", // Alternative test path
".", // When running tests from settings directory
"../common/settings", // When running from backend directory
"common/settings", // Alternative path
}
for _, sourcePath := range sourcePaths {
@@ -641,14 +776,19 @@ func GenerateConfigYamlWithEmbedded(config *Settings, showComments bool, showFul
var deprecated DeprecatedFieldsMap
var err error
if showComments && embeddedYaml != "" {
// Parse comments from the embedded YAML content
if embeddedYaml != "" {
// Always parse the embedded YAML to get secrets and deprecated fields
comm, secrets, deprecated, err = CollectCommentsFromEmbeddedYaml(embeddedYaml)
if err != nil {
return "", fmt.Errorf("error parsing embedded YAML comments: %w", err)
}
// If not showing comments, clear the comments map but keep secrets and deprecated
if !showComments {
comm = make(CommentsMap)
}
} else {
// Create empty maps
// Create empty maps only if no embedded YAML is available
comm = make(CommentsMap)
secrets = make(SecretFieldsMap)
deprecated = make(DeprecatedFieldsMap)
@@ -699,6 +839,95 @@ func GenerateConfigYamlWithEmbedded(config *Settings, showComments bool, showFul
return yamlOutput, nil
}
// identifySecretFieldsByReflection identifies secret fields by known field names
func identifySecretFieldsByReflection(v reflect.Value, typeName string, secrets SecretFieldsMap) {
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return
}
t := v.Type()
if secrets[typeName] == nil {
secrets[typeName] = make(map[string]bool)
}
// Known secret field names
secretFields := map[string]bool{
"Key": true,
"AdminUsername": true,
"AdminPassword": true,
"TotpSecret": true,
"ClientID": true,
"ClientSecret": true,
"Secret": true,
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if secretFields[field.Name] {
secrets[typeName][field.Name] = true
}
// Recursively check nested structs
fieldValue := v.Field(i)
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
nestedTypeName := field.Type.Name()
if field.Type.Kind() == reflect.Ptr {
nestedTypeName = field.Type.Elem().Name()
}
identifySecretFieldsByReflection(fieldValue, nestedTypeName, secrets)
}
}
}
// identifyDeprecatedFieldsByReflection identifies deprecated fields by known field names
func identifyDeprecatedFieldsByReflection(v reflect.Value, typeName string, deprecated DeprecatedFieldsMap) {
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return
}
t := v.Type()
if deprecated[typeName] == nil {
deprecated[typeName] = make(map[string]bool)
}
// Known deprecated field names
deprecatedFields := map[string]bool{
"IndexAlbumArt": true,
"DisableOfficePreviewExt": true,
}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if deprecatedFields[field.Name] {
deprecated[typeName][field.Name] = true
}
// Recursively check nested structs
fieldValue := v.Field(i)
if fieldValue.Kind() == reflect.Struct || (fieldValue.Kind() == reflect.Ptr && !fieldValue.IsNil() && fieldValue.Elem().Kind() == reflect.Struct) {
nestedTypeName := field.Type.Name()
if field.Type.Kind() == reflect.Ptr {
nestedTypeName = field.Type.Elem().Name()
}
identifyDeprecatedFieldsByReflection(fieldValue, nestedTypeName, deprecated)
}
}
}
// GenerateConfigYamlWithEmptyMaps generates YAML without comment parsing when source files are unavailable
func GenerateConfigYamlWithEmptyMaps(config *Settings, showFull bool) (string, error) {
// Create empty maps
@@ -706,6 +935,9 @@ func GenerateConfigYamlWithEmptyMaps(config *Settings, showFull bool) (string, e
secrets := make(SecretFieldsMap)
deprecated := make(DeprecatedFieldsMap)
// Identify secret fields by reflection since we can't parse source files
identifySecretFieldsByReflection(reflect.ValueOf(config), "Settings", secrets)
var node *yaml.Node
var err error
@@ -723,7 +955,7 @@ func GenerateConfigYamlWithEmptyMaps(config *Settings, showFull bool) (string, e
if err != nil || readErr != nil {
// Fallback to setDefaults
defaultConfigValue := setDefaults(true)
defaultConfigValue.Server.Sources = []Source{{Path: "."}}
defaultConfigValue.Server.Sources = []*Source{{Path: "."}}
defaultConfig = &defaultConfigValue
}
@@ -771,6 +1003,30 @@ func GenerateConfigYamlWithSource(config *Settings, showComments bool, showFull
comm = make(CommentsMap)
}
// If secrets map is empty (directory parsing failed), use reflection to identify secrets
secretsEmpty := true
for typeName := range secrets {
if len(secrets[typeName]) > 0 {
secretsEmpty = false
break
}
}
if secretsEmpty {
identifySecretFieldsByReflection(reflect.ValueOf(config), "Settings", secrets)
}
// If deprecated map is empty (directory parsing failed), use reflection to identify deprecated fields
deprecatedEmpty := true
for typeName := range deprecated {
if len(deprecated[typeName]) > 0 {
deprecatedEmpty = false
break
}
}
if deprecatedEmpty {
identifyDeprecatedFieldsByReflection(reflect.ValueOf(config), "Settings", deprecated)
}
// If not filtering deprecated fields, clear the deprecated map
if !filterDeprecated {
deprecated = make(DeprecatedFieldsMap)
@@ -792,7 +1048,7 @@ func GenerateConfigYamlWithSource(config *Settings, showComments bool, showFull
if err != nil || readErr != nil {
// Fallback to setDefaults
defaultConfigValue := setDefaults(true)
defaultConfigValue.Server.Sources = []Source{{Path: "."}}
defaultConfigValue.Server.Sources = []*Source{{Path: "."}}
defaultConfig = &defaultConfigValue
}
+228
View File
@@ -0,0 +1,228 @@
package settings
import (
"os"
"path/filepath"
"testing"
)
func TestMultiConfigLoad(t *testing.T) {
// Setup test directory
testDir := t.TempDir()
// Create anchors file that will be referenced
anchorsConfig := `
# Simple anchors for testing
simple_sources: &simple_sources
- path: "/srv/files"
name: "Files"
- path: "/srv/media"
name: "Media"
simple_logging: &simple_logging
- levels: "info|warning|error"
output: "stdout"
simple_auth: &simple_auth
methods:
password:
enabled: true
minLength: 8
`
anchorsPath := filepath.Join(testDir, "anchors.yaml")
if err := os.WriteFile(anchorsPath, []byte(anchorsConfig), 0644); err != nil {
t.Fatalf("Failed to write anchors config: %v", err)
}
// Create main config file that references the anchors
mainConfig := `
server:
port: 9000
baseURL: "/test/"
minSearchLength: 2
sources: *simple_sources
logging: *simple_logging
auth:
tokenExpirationHours: 3
<<: *simple_auth
frontend:
name: "Test FileBrowser"
userDefaults:
darkMode: true
viewMode: "grid"
locale: "en"
`
mainPath := filepath.Join(testDir, "config.yaml")
if err := os.WriteFile(mainPath, []byte(mainConfig), 0644); err != nil {
t.Fatalf("Failed to write main config: %v", err)
}
// Test loading the multi-config setup
err := loadConfigWithDefaults(mainPath, false)
if err != nil {
t.Fatalf("Failed to load multi-config: %v", err)
}
// Verify that values from different files were loaded correctly
if Config.Server.Port != 9000 {
t.Errorf("Expected server port 9000, got %d", Config.Server.Port)
}
if Config.Server.BaseURL != "/test/" {
t.Errorf("Expected baseURL '/test/', got '%s'", Config.Server.BaseURL)
}
if Config.Server.MinSearchLength != 2 {
t.Errorf("Expected minSearchLength 2, got %d", Config.Server.MinSearchLength)
}
if Config.Auth.TokenExpirationHours != 3 {
t.Errorf("Expected token expiration 3 hours, got %d", Config.Auth.TokenExpirationHours)
}
if !Config.Auth.Methods.PasswordAuth.Enabled {
t.Error("Expected password auth to be enabled")
}
if Config.Auth.Methods.PasswordAuth.MinLength != 8 {
t.Errorf("Expected password min length 8, got %d", Config.Auth.Methods.PasswordAuth.MinLength)
}
if len(Config.Server.Sources) != 2 {
t.Errorf("Expected 2 sources, got %d", len(Config.Server.Sources))
}
if Config.Frontend.Name != "Test FileBrowser" {
t.Errorf("Expected frontend name 'Test FileBrowser', got '%s'", Config.Frontend.Name)
}
if Config.UserDefaults.ViewMode != "grid" {
t.Errorf("Expected view mode 'grid', got '%s'", Config.UserDefaults.ViewMode)
}
}
func TestMultiConfigFallback(t *testing.T) {
// Setup test directory
testDir := t.TempDir()
// Create a simple config file (no anchors)
simpleConfig := `
server:
port: 8080
baseURL: "/"
sources:
- path: "/srv"
auth:
tokenExpirationHours: 2
frontend:
name: "Simple FileBrowser"
`
configPath := filepath.Join(testDir, "simple-config.yaml")
if err := os.WriteFile(configPath, []byte(simpleConfig), 0644); err != nil {
t.Fatalf("Failed to write simple config: %v", err)
}
// Test that fallback works for simple configs
err := loadConfigWithDefaults(configPath, false)
if err != nil {
t.Fatalf("Failed to load simple config: %v", err)
}
// Verify values were loaded correctly
if Config.Server.Port != 8080 {
t.Errorf("Expected server port 8080, got %d", Config.Server.Port)
}
if Config.Frontend.Name != "Simple FileBrowser" {
t.Errorf("Expected frontend name 'Simple FileBrowser', got '%s'", Config.Frontend.Name)
}
}
func TestMultiConfigWithNestedReferences(t *testing.T) {
// Note: Logger output may appear during tests - this is normal
// Setup test directory
testDir := t.TempDir()
// Create anchors file with nested structure
anchorsConfig := `
# Base logging configuration
base_logging: &base_logging
- levels: "info|warning|error"
output: "stdout"
# Base permissions
base_permissions: &base_permissions
modify: false
share: false
admin: false
api: false
`
anchorsPath := filepath.Join(testDir, "anchors.yaml")
if err := os.WriteFile(anchorsPath, []byte(anchorsConfig), 0644); err != nil {
t.Fatalf("Failed to write anchors config: %v", err)
}
// Create main config that uses references
mainConfig := `
server:
port: 8080
sources:
- path: "/srv"
logging: *base_logging
auth:
tokenExpirationHours: 2
userDefaults:
darkMode: true
locale: "en"
viewMode: "normal"
permissions: *base_permissions
frontend:
name: "Nested Reference Test"
`
mainPath := filepath.Join(testDir, "config.yaml")
if err := os.WriteFile(mainPath, []byte(mainConfig), 0644); err != nil {
t.Fatalf("Failed to write main config: %v", err)
}
// Test loading the nested reference setup
err := loadConfigWithDefaults(mainPath, false)
if err != nil {
t.Fatalf("Failed to load nested config: %v", err)
}
// Verify nested references work
if len(Config.Server.Logging) == 0 {
t.Error("Expected logging configuration to be loaded")
}
if Config.Server.Logging[0].Output != "stdout" {
t.Errorf("Expected logging output 'stdout', got '%s'", Config.Server.Logging[0].Output)
}
// Verify user defaults
if Config.UserDefaults.DarkMode != true {
t.Errorf("Expected dark mode true, got %v", Config.UserDefaults.DarkMode)
}
if Config.UserDefaults.Locale != "en" {
t.Errorf("Expected locale 'en', got '%s'", Config.UserDefaults.Locale)
}
// Verify permissions were loaded from anchor
if Config.UserDefaults.Permissions.Modify != false {
t.Errorf("Expected modify permission false (from base), got %v", Config.UserDefaults.Permissions.Modify)
}
if Config.UserDefaults.Permissions.Share != false {
t.Errorf("Expected share permission false (from base), got %v", Config.UserDefaults.Permissions.Share)
}
}
+3
View File
@@ -54,6 +54,9 @@ func ApplyUserDefaults(u *users.User) {
u.GallerySize = Config.UserDefaults.GallerySize
u.QuickDownload = Config.UserDefaults.QuickDownload
u.LockPassword = Config.UserDefaults.LockPassword
u.DisableOnlyOfficeExt = Config.UserDefaults.DisableOnlyOfficeExt
u.FileLoading = Config.UserDefaults.FileLoading
u.DisableOfficePreviewExt = Config.UserDefaults.DisableOfficePreviewExt
if len(u.Scopes) == 0 {
for _, source := range Config.Server.Sources {
if source.Config.DefaultEnabled {
+68 -10
View File
@@ -33,18 +33,23 @@ type Server struct {
Port int `json:"port"` // port to listen on
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
Logging []LogConfig `json:"logging" yaml:"logging"`
DebugMedia bool `json:"debugMedia"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
Database string `json:"database"` // path to the database file
Sources []Source `json:"sources" validate:"required,dive"`
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
// 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
MuPdfAvailable bool `json:"-"` // used internally if compiled with mupdf support
EmbeddedFs bool `json:"-"` // used internally if compiled with embedded fs support
SourceMap map[string]*Source `json:"-" validate:"omitempty"` // uses realpath as key
NameToSource map[string]*Source `json:"-" validate:"omitempty"` // uses name as key
MuPdfAvailable bool `json:"-"` // used internally if compiled with mupdf support
EmbeddedFs bool `json:"-"` // used internally if compiled with embedded fs support
}
type Filesystem struct {
CreateFilePermission string `json:"createFilePermission" validate:"required,file_permission"` // Unix permissions like 644, 755, 2755 (default: 644)
CreateDirectoryPermission string `json:"createDirectoryPermission" validate:"required,file_permission"` // Unix permissions like 755, 2755, 1777 (default: 755)
}
type Integrations struct {
@@ -62,12 +67,15 @@ type OnlyOffice struct {
}
type Media struct {
FfmpegPath string `json:"ffmpegPath"` // path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
Convert FfmpegConvert `json:"convert"` // config for ffmpeg conversion settings
FfmpegPath string `json:"ffmpegPath"` // path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
Convert FfmpegConvert `json:"convert"` // config for ffmpeg conversion settings
Debug bool `json:"debug"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles"` // extract embedded subtitles from media files
}
type FfmpegConvert struct {
ImagePreview map[ImagePreviewType]bool `json:"imagePreview"` // supported image preview formats. default is heic
ImagePreview map[ImagePreviewType]bool `json:"imagePreview"` // supported image preview formats. defaults to false for all types unless explicitly enabled.
VideoPreview map[VideoPreviewType]bool `json:"videoPreview"` // supported video preview formats. defaults to true for all types unless explicitly disabled.
}
type ImagePreviewType string
@@ -87,6 +95,55 @@ var AllImagePreviewTypes = []ImagePreviewType{
//RAWImagePreview,
}
type VideoPreviewType string
const (
MP4VideoPreview VideoPreviewType = "mp4"
WebMVideoPreview VideoPreviewType = "webm"
MOVVideoPreview VideoPreviewType = "mov"
AVIVideoPreview VideoPreviewType = "avi"
MKVVideoPreview VideoPreviewType = "mkv"
FLVVideoPreview VideoPreviewType = "flv"
WMVVideoPreview VideoPreviewType = "wmv"
M4VVideoPreview VideoPreviewType = "m4v"
ThreeGPVideoPreview VideoPreviewType = "3gp"
ThreeGP2VideoPreview VideoPreviewType = "3g2"
TSVideoPreview VideoPreviewType = "ts"
M2TSVideoPreview VideoPreviewType = "m2ts"
VOBVideoPreview VideoPreviewType = "vob"
ASFVideoPreview VideoPreviewType = "asf"
MPGVideoPreview VideoPreviewType = "mpg"
MPEGVideoPreview VideoPreviewType = "mpeg"
F4VVideoPreview VideoPreviewType = "f4v"
OGVVideoPreview VideoPreviewType = "ogv"
)
func (v VideoPreviewType) String() string {
return string(v)
}
// AllVideoPreviewTypes contains all supported video preview types.
var AllVideoPreviewTypes = []VideoPreviewType{
MP4VideoPreview,
WebMVideoPreview,
MOVVideoPreview,
AVIVideoPreview,
MKVVideoPreview,
FLVVideoPreview,
WMVVideoPreview,
M4VVideoPreview,
ThreeGPVideoPreview,
ThreeGP2VideoPreview,
TSVideoPreview,
M2TSVideoPreview,
VOBVideoPreview,
ASFVideoPreview,
MPGVideoPreview,
MPEGVideoPreview,
F4VVideoPreview,
OGVVideoPreview,
}
type LogConfig struct {
Levels string `json:"levels" yaml:"levels"` // separated list of log levels to enable. (eg. "info|warning|error|debug")
ApiLevels string `json:"apiLevels" yaml:"apiLevels"` // separated list of log levels to enable for the API. (eg. "info|warning|error")
@@ -202,4 +259,5 @@ type UserDefaults struct {
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
DefaultLandingPage string `json:"defaultLandingPage"` // default landing page to use if no redirect is specified: eg. /files/mysource/mysubpath, /settings, etc.
}
@@ -0,0 +1,317 @@
package settings
import (
"fmt"
"os"
"strings"
"testing"
)
func TestVideoPreviewDefaults(t *testing.T) {
// Test 1: No videoPreview config at all - should enable all
t.Run("NoVideoPreviewConfig", func(t *testing.T) {
config := `
server:
sources:
- path: "."
integrations:
media:
convert:
imagePreview:
heic: false
office:
url: "http://localhost:8080"
secret: "test-secret"
`
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// All video formats should be enabled by default
for _, format := range AllVideoPreviewTypes {
if !config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
}
}
})
})
// Test 2: Empty videoPreview config - should enable all
t.Run("EmptyVideoPreviewConfig", func(t *testing.T) {
config := `
server:
sources:
- path: "."
integrations:
media:
convert:
imagePreview:
heic: false
videoPreview: {}
office:
url: "http://localhost:8080"
secret: "test-secret"
`
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// All video formats should be enabled by default
for _, format := range AllVideoPreviewTypes {
if !config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
}
}
})
})
// Test 3: Partial videoPreview config - should enable all except explicitly disabled
t.Run("PartialVideoPreviewConfig", func(t *testing.T) {
config := `
server:
sources:
- path: "."
integrations:
media:
convert:
imagePreview:
heic: false
videoPreview:
mp4: true
webm: false
mkv: false
wmv: false
office:
url: "http://localhost:8080"
secret: "test-secret"
`
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// Check explicitly set formats
if !config.Integrations.Media.Convert.VideoPreview[MP4VideoPreview] {
t.Error("Expected mp4 to be enabled")
}
if config.Integrations.Media.Convert.VideoPreview[WebMVideoPreview] {
t.Error("Expected webm to be disabled")
}
if config.Integrations.Media.Convert.VideoPreview[MKVVideoPreview] {
t.Error("Expected mkv to be disabled")
}
if config.Integrations.Media.Convert.VideoPreview[WMVVideoPreview] {
t.Error("Expected wmv to be disabled")
}
// Check that unmentioned formats are still enabled
unmentionedFormats := []VideoPreviewType{
AVIVideoPreview, MOVVideoPreview, FLVVideoPreview,
M4VVideoPreview, ThreeGPVideoPreview, TSVideoPreview, VOBVideoPreview,
}
for _, format := range unmentionedFormats {
if !config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled (not mentioned in config), but it was disabled", format)
}
}
})
})
// Test 4: All videoPreview config - should respect all settings
t.Run("AllVideoPreviewConfig", func(t *testing.T) {
config := `
server:
sources:
- path: "."
integrations:
media:
convert:
imagePreview:
heic: false
videoPreview:
mp4: true
webm: true
mov: true
avi: false
mkv: true
flv: false
wmv: true
m4v: false
3gp: true
3g2: false
ts: true
m2ts: false
vob: true
asf: false
mpg: true
mpeg: false
f4v: true
ogv: false
office:
url: "http://localhost:8080"
secret: "test-secret"
`
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// Check all explicitly set formats
expectedEnabled := []VideoPreviewType{
MP4VideoPreview, WebMVideoPreview, MOVVideoPreview, MKVVideoPreview,
WMVVideoPreview, ThreeGPVideoPreview, TSVideoPreview, VOBVideoPreview,
MPGVideoPreview, F4VVideoPreview,
}
expectedDisabled := []VideoPreviewType{
AVIVideoPreview, FLVVideoPreview, M4VVideoPreview,
ThreeGP2VideoPreview, M2TSVideoPreview, ASFVideoPreview,
MPEGVideoPreview, OGVVideoPreview,
}
for _, format := range expectedEnabled {
if !config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled, but it was disabled", format)
}
}
for _, format := range expectedDisabled {
if config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be disabled, but it was enabled", format)
}
}
})
})
// Test 5: No integrations section at all - should enable all
t.Run("NoIntegrationsSection", func(t *testing.T) {
config := `
server:
sources:
- path: "."
integrations:
office:
url: "http://localhost:8080"
secret: "test-secret"
`
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// All video formats should be enabled by default
for _, format := range AllVideoPreviewTypes {
if !config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
}
}
})
})
}
func TestVideoPreviewFormatCategories(t *testing.T) {
// Test that all video format categories are properly supported
t.Run("FormatCategories", func(t *testing.T) {
config := `
server:
sources:
- path: "."
integrations:
office:
url: "http://localhost:8080"
secret: "test-secret"
`
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// Test format categories
categories := map[string][]VideoPreviewType{
"Common Web Formats": {MP4VideoPreview, WebMVideoPreview, OGVVideoPreview},
"Apple Formats": {MOVVideoPreview, M4VVideoPreview},
"Microsoft Formats": {AVIVideoPreview, WMVVideoPreview, ASFVideoPreview},
"Open Source": {MKVVideoPreview, FLVVideoPreview},
"Mobile Formats": {ThreeGPVideoPreview, ThreeGP2VideoPreview},
"Broadcast/Professional": {TSVideoPreview, M2TSVideoPreview},
"Legacy Formats": {VOBVideoPreview, MPGVideoPreview, MPEGVideoPreview, F4VVideoPreview},
}
for category, formats := range categories {
t.Run(category, func(t *testing.T) {
for _, format := range formats {
if !config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s (%s) to be enabled by default, but it was disabled", format, category)
}
}
})
}
})
})
}
func TestVideoPreviewFormatCount(t *testing.T) {
// Test that we have the expected number of video formats
t.Run("FormatCount", func(t *testing.T) {
expectedCount := 18
actualCount := len(AllVideoPreviewTypes)
if actualCount != expectedCount {
t.Errorf("Expected %d video formats, but got %d", expectedCount, actualCount)
}
// Test that all formats are unique
formatMap := make(map[VideoPreviewType]bool)
for _, format := range AllVideoPreviewTypes {
if formatMap[format] {
t.Errorf("Duplicate video format found: %s", format)
}
formatMap[format] = true
}
})
}
func BenchmarkVideoPreviewSetup(b *testing.B) {
config := `
server:
sources:
- path: "."
integrations:
media:
convert:
videoPreview:
mp4: true
webm: false
mkv: true
wmv: false
office:
url: "http://localhost:8080"
secret: "test-secret"
`
// Create temporary config file
tmpFile, err := os.CreateTemp("", "benchmark_video_preview_*.yaml")
if err != nil {
b.Fatalf("Error creating temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
// Create a temporary directory for cache
tmpCacheDir, err := os.MkdirTemp("", "benchmark_cache_*")
if err != nil {
b.Fatalf("Error creating temp cache dir: %v", err)
}
defer os.RemoveAll(tmpCacheDir)
// Modify config to use absolute temp directory
modifiedConfig := strings.Replace(config, "server:", fmt.Sprintf("server:\n cacheDir: %s", tmpCacheDir), 1)
_, err = tmpFile.WriteString(modifiedConfig)
if err != nil {
b.Fatalf("Error writing config: %v", err)
}
tmpFile.Close()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Initialize(tmpFile.Name())
}
}
// Helper function to test video preview configuration
func testVideoPreviewConfig(t *testing.T, configContent string, testFunc func(t *testing.T, config *Settings)) {
// Create temporary config file
tmpFile, err := os.CreateTemp("", "test_video_preview_*.yaml")
if err != nil {
t.Fatalf("Error creating temp file: %v", err)
}
defer os.Remove(tmpFile.Name())
_, err = tmpFile.WriteString(configContent)
if err != nil {
t.Fatalf("Error writing config: %v", err)
}
tmpFile.Close()
// Initialize settings
Initialize(tmpFile.Name())
// Run the test function
testFunc(t, &Config)
}
+15
View File
@@ -41,3 +41,18 @@ func GetChecksum(fullPath, algo string) (string, error) {
return hex.EncodeToString(h.Sum(nil)), nil
}
// FileOptions are the options when getting a file info.
type FileOptions struct {
Username string // username for access control
Path string // realpath
Source string
IsDir bool
Modify bool
Expand bool
ReadHeader bool
Content bool
Recursive bool // whether to recursively index directories
Metadata bool // whether to get metadata
ExtractEmbeddedSubtitles bool // whether to extract embedded subtitles from media files
}
+149 -99
View File
@@ -151,21 +151,81 @@ func ClearCache() {
rulesCache = cache.NewCache[map[string]FrontendAccessRule](1 * time.Minute)
}
// ClearCacheForSource clears the cache for a specific source
func (s *Storage) ClearCacheForSource(sourcePath string) {
// Clear all caches related to this source
accessCache.Delete(accessChangedKey + sourcePath)
rulesCache.Delete(accessChangedKey + sourcePath)
versionCache.Delete("version:" + sourcePath)
// Clear all permission caches for this source
// We need to iterate through all permission cache keys and remove those for this source
// Since we can't iterate through cache keys, we'll just increment the version
// which will invalidate all permission caches for this source
s.incrementSourceVersion(sourcePath)
}
// RemoveRuleByPath removes a rule by its exact path from the internal storage
func (s *Storage) RemoveRuleByPath(sourcePath, indexPath string) {
s.mux.Lock()
defer s.mux.Unlock()
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return
}
// Remove the rule by exact path match (don't normalize)
if _, exists := rulesBySource[indexPath]; exists {
delete(rulesBySource, indexPath)
// If no rules left for this source, remove the source entry
if len(rulesBySource) == 0 {
delete(s.AllRules, sourcePath)
}
// Invalidate caches
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
// Save to database
err := s.SaveToDB()
if err != nil {
logger.Errorf("error saving access rules to database: %v", err)
}
}
}
// 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)
if _, ok := s.AllRules[sourcePath]; !ok {
s.AllRules[sourcePath] = make(RuleMap)
}
rule, ok := s.AllRules[sourcePath][indexPath]
rule, ok := s.AllRules[sourcePath][normalizedPath]
if !ok {
rule = &AccessRule{
Deny: RuleSet{Users: make(StringSet), Groups: make(StringSet)},
Allow: RuleSet{Users: make(StringSet), Groups: make(StringSet)},
}
s.AllRules[sourcePath][indexPath] = rule
s.AllRules[sourcePath][normalizedPath] = rule
}
logger.Debugf("Created rule for source: %s and index: %s", sourcePath, indexPath)
return rule
}
@@ -186,6 +246,7 @@ func (s *Storage) DenyUser(sourcePath, indexPath, username string) error {
rule.Deny.Users[username] = struct{}{}
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return s.SaveToDB()
}
@@ -206,6 +267,7 @@ func (s *Storage) AllowUser(sourcePath, indexPath, username string) error {
rule.Allow.Users[username] = struct{}{}
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return s.SaveToDB()
}
@@ -224,6 +286,7 @@ func (s *Storage) DenyGroup(sourcePath, indexPath, groupname string) error {
rule.Deny.Groups[groupname] = struct{}{}
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return s.SaveToDB()
}
@@ -242,6 +305,7 @@ func (s *Storage) AllowGroup(sourcePath, indexPath, groupname string) error {
rule.Allow.Groups[groupname] = struct{}{}
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return s.SaveToDB()
}
@@ -256,16 +320,15 @@ func (s *Storage) DenyAll(sourcePath, indexPath string) error {
rule.DenyAll = true
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return s.SaveToDB()
}
// Permitted checks if a username is permitted for a given sourcePath and indexPath, recursively checking parent directories.
func (s *Storage) Permitted(sourcePath, indexPath, username string) bool {
// SECURITY: All paths MUST start with "/" - reject any path that doesn't
// This prevents path normalization bypass attacks
if !strings.HasPrefix(indexPath, "/") {
logger.Debugf("Access denied: path %q does not start with '/' (user: %s, source: %s)", indexPath, username, sourcePath)
return false
}
@@ -284,6 +347,7 @@ func (s *Storage) Permitted(sourcePath, indexPath, username string) bool {
// Not in cache, compute, then cache it.
result := s.computePermitted(sourcePath, indexPath, username)
permissionCache.Set(permKey, result)
return result
}
@@ -293,6 +357,7 @@ func (s *Storage) computePermitted(sourcePath, indexPath, username string) bool
// Walk up the path hierarchy and collect all relevant rules
currentPath := indexPath
pathLevel := 0
for {
rule, found := s.getRuleAtExactPath(sourcePath, currentPath)
if found {
@@ -301,7 +366,14 @@ func (s *Storage) computePermitted(sourcePath, indexPath, username string) bool
if currentPath == "/" || currentPath == "." || currentPath == "" {
break
}
oldPath := currentPath
currentPath = utils.GetParentDirectoryPath(currentPath)
// Safety check to prevent infinite loops
if currentPath == oldPath {
break
}
pathLevel++
}
// Now evaluate the rules, starting from the most specific (indexPath) to the least specific (root)
@@ -339,7 +411,9 @@ func (s *Storage) getRuleAtExactPath(sourcePath, indexPath string) (*AccessRule,
if !ok {
return nil, false
}
rule, ok := rulesBySource[indexPath]
// Normalize the path to ensure consistent rule lookup
normalizedPath := normalizeRulePath(indexPath)
rule, ok := rulesBySource[normalizedPath]
return rule, ok
}
@@ -373,81 +447,6 @@ func (s *Storage) evaluateRuleForUser(rule *AccessRule, username string) (permit
return false, false
}
/*
// permittedAtExactPath checks if a rule exists at the given path and evaluates it if so.
func (s *Storage) permittedAtExactPath(sourcePath, indexPath, username string) (bool, bool) {
s.mux.RLock()
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
s.mux.RUnlock()
return true, false
}
rule, ok := rulesBySource[indexPath]
s.mux.RUnlock()
if !ok {
return true, false
}
return s.permittedRule(rule, username, sourcePath), true
}
// permittedRule contains the old Permitted logic, but operates on a rule and username only.
func (s *Storage) permittedRule(rule *AccessRule, username string, sourcePath string) bool {
// Check user deny first - this always takes precedence
if _, found := rule.Deny.Users[username]; found {
return false
}
// Check group deny
for group := range rule.Deny.Groups {
if s.isUserInGroup(username, group) {
return false
}
}
// Check allow rules - these can override DenyAll
hasUserAllow := len(rule.Allow.Users) > 0
hasGroupAllow := len(rule.Allow.Groups) > 0
if hasUserAllow || hasGroupAllow {
// If user is in any allow list, permit them (overrides DenyAll)
if hasUserAllow {
if _, found := rule.Allow.Users[username]; found {
return true
}
}
if hasGroupAllow {
for group := range rule.Allow.Groups {
if s.isUserInGroup(username, group) {
return true
}
}
}
// User is not in any allow list
// Check DenyByDefault to determine behavior
sourceInfo, ok := settings.Config.Server.SourceMap[sourcePath]
if ok && sourceInfo.Config.DenyByDefault {
// DenyByDefault=true: allow lists are exclusive, deny users not on the list
return false
} else {
// DenyByDefault=false: allow lists are additive, allow users not on the list
return true
}
}
// Check DenyAll - only applies if no allow rules exist
if rule.DenyAll {
return false
}
// No specific rules apply - check source DenyByDefault setting
sourceInfo, ok := settings.Config.Server.SourceMap[sourcePath]
if ok && sourceInfo.Config.DenyByDefault {
return false
}
return true
}
*/
// isUserInGroup checks if a username is in a group.
func (s *Storage) isUserInGroup(username, group string) bool {
s.mux.RLock()
@@ -502,9 +501,14 @@ func (s *Storage) GetFrontendRules(sourcePath, indexPath string) (FrontendAccess
// GetAllRules returns all access rules as a map.
func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule, error) {
value, ok := rulesCache.Get(accessChangedKey + sourcePath)
if ok {
return value, nil
// Check if rules have changed by looking at the access cache
_, hasChanged := accessCache.Get(accessChangedKey + sourcePath)
if !hasChanged {
// If no change marker, check if we have cached rules
value, ok := rulesCache.Get(accessChangedKey + sourcePath)
if ok {
return value, nil
}
}
s.mux.RLock()
@@ -524,8 +528,12 @@ func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule,
return frontendRules, nil
}
for indexPath, rule := range rules {
// Use the internal path as the frontend path (with trailing slash)
// This ensures consistency between internal storage and frontend display
frontendPath := indexPath
// Convert AccessRule to FrontendAccessRule
frontendRules[indexPath] = FrontendAccessRule{
frontendRules[frontendPath] = FrontendAccessRule{
DenyAll: rule.DenyAll,
SourceDenyDefault: sourceDenyDefault,
Deny: FrontendRuleSet{
@@ -645,7 +653,8 @@ 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()
rule, ok := s.AllRules[sourcePath][indexPath]
normalizedPath := normalizeRulePath(indexPath)
rule, ok := s.AllRules[sourcePath][normalizedPath]
if !ok {
return false, nil
}
@@ -660,13 +669,14 @@ func (s *Storage) RemoveAllowUser(sourcePath, indexPath, username string) (bool,
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
delete(s.AllRules[sourcePath], normalizedPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return exists, s.SaveToDB()
}
return false, nil
@@ -676,7 +686,8 @@ 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()
rule, ok := s.AllRules[sourcePath][indexPath]
normalizedPath := normalizeRulePath(indexPath)
rule, ok := s.AllRules[sourcePath][normalizedPath]
if !ok {
return false, nil
}
@@ -691,13 +702,14 @@ func (s *Storage) RemoveAllowGroup(sourcePath, indexPath, groupname string) (boo
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
delete(s.AllRules[sourcePath], normalizedPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return exists, s.SaveToDB()
}
return exists, nil
@@ -707,7 +719,8 @@ 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()
rule, ok := s.AllRules[sourcePath][indexPath]
normalizedPath := normalizeRulePath(indexPath)
rule, ok := s.AllRules[sourcePath][normalizedPath]
if !ok {
return false, nil
}
@@ -722,13 +735,14 @@ func (s *Storage) RemoveDenyUser(sourcePath, indexPath, username string) (bool,
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
delete(s.AllRules[sourcePath], normalizedPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return exists, s.SaveToDB()
}
return false, nil
@@ -738,7 +752,8 @@ 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()
rule, ok := s.AllRules[sourcePath][indexPath]
normalizedPath := normalizeRulePath(indexPath)
rule, ok := s.AllRules[sourcePath][normalizedPath]
if !ok {
return false, nil
}
@@ -753,13 +768,14 @@ func (s *Storage) RemoveDenyGroup(sourcePath, indexPath, groupname string) (bool
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
delete(s.AllRules[sourcePath], normalizedPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return exists, s.SaveToDB()
}
return exists, nil
@@ -769,7 +785,8 @@ 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()
rule, ok := s.AllRules[sourcePath][indexPath]
normalizedPath := normalizeRulePath(indexPath)
rule, ok := s.AllRules[sourcePath][normalizedPath]
if !ok {
return false, nil
}
@@ -781,13 +798,14 @@ func (s *Storage) RemoveDenyAll(sourcePath, indexPath string) (bool, error) {
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
delete(s.AllRules[sourcePath], normalizedPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
}
if removed {
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return true, s.SaveToDB()
}
return false, nil
@@ -822,6 +840,7 @@ func (s *Storage) RemoveAllRulesForUser(username string) error {
for sp := range changedSourcePaths {
s.incrementSourceVersion(sp)
accessCache.Set(accessChangedKey+sp, "false")
rulesCache.Delete(accessChangedKey + sp)
}
if changed {
return s.SaveToDB()
@@ -858,6 +877,7 @@ func (s *Storage) RemoveAllRulesForGroup(groupname string) error {
for sp := range changedSourcePaths {
s.incrementSourceVersion(sp)
accessCache.Set(accessChangedKey+sp, "false")
rulesCache.Delete(accessChangedKey + sp)
}
if changed {
return s.SaveToDB()
@@ -978,6 +998,11 @@ func (s *Storage) GetAllRulesByUsers(sourcePath string) map[string]map[string]Fr
if !hasAllowUsers && !hasDenyUsers {
continue
}
// Use the internal path as the frontend path (with trailing slash)
// This ensures consistency between internal storage and frontend display
frontendPath := indexPath
frontendRule := FrontendAccessRule{
DenyAll: rule.DenyAll,
SourceDenyDefault: sourceDenyDefault,
@@ -994,13 +1019,13 @@ func (s *Storage) GetAllRulesByUsers(sourcePath string) map[string]map[string]Fr
if _, ok := allUserRules[user]; !ok {
allUserRules[user] = make(map[string]FrontendAccessRule)
}
allUserRules[user][indexPath] = frontendRule
allUserRules[user][frontendPath] = frontendRule
}
for user := range rule.Deny.Users {
if _, ok := allUserRules[user]; !ok {
allUserRules[user] = make(map[string]FrontendAccessRule)
}
allUserRules[user][indexPath] = frontendRule
allUserRules[user][frontendPath] = frontendRule
}
}
return allUserRules
@@ -1029,6 +1054,11 @@ func (s *Storage) GetAllRulesByGroups(sourcePath string) map[string]map[string]F
if !hasAllowGroups && !hasDenyGroups {
continue
}
// Use the internal path as the frontend path (with trailing slash)
// This ensures consistency between internal storage and frontend display
frontendPath := indexPath
frontendRule := FrontendAccessRule{
DenyAll: rule.DenyAll,
SourceDenyDefault: sourceDenyDefault,
@@ -1045,13 +1075,13 @@ func (s *Storage) GetAllRulesByGroups(sourcePath string) map[string]map[string]F
if _, ok := allGroupRules[group]; !ok {
allGroupRules[group] = make(map[string]FrontendAccessRule)
}
allGroupRules[group][indexPath] = frontendRule
allGroupRules[group][frontendPath] = frontendRule
}
for group := range rule.Deny.Groups {
if _, ok := allGroupRules[group]; !ok {
allGroupRules[group] = make(map[string]FrontendAccessRule)
}
allGroupRules[group][indexPath] = frontendRule
allGroupRules[group][frontendPath] = frontendRule
}
}
return allGroupRules
@@ -1066,3 +1096,23 @@ func (s *Storage) incrementSourceVersion(sourcePath string) {
}
versionCache.Set(key, version+1)
}
// HasAnyVisibleItems checks if a user has access to any items in a given parent path.
// This is used to determine if a user should see a folder's contents even when
// they don't have direct access to the parent folder.
func (s *Storage) HasAnyVisibleItems(sourcePath, parentPath string, itemNames []string, username string) bool {
// Ensure parentPath has trailing slash for proper path construction
if !strings.HasSuffix(parentPath, "/") {
parentPath = parentPath + "/"
}
// Check if user has access to any of the items
for _, itemName := range itemNames {
indexPath := parentPath + itemName
if s.Permitted(sourcePath, indexPath, username) {
return true
}
}
return false
}
+526 -7
View File
@@ -33,7 +33,7 @@ func createTestUser(t *testing.T, userStore *users.Storage, username string) {
func setupTestSources() {
// Setup default test sources with allow-by-default behavior
settings.Config.Server.SourceMap = map[string]settings.Source{
settings.Config.Server.SourceMap = map[string]*settings.Source{
"mnt/storage": {
Path: "mnt/storage",
Name: "storage",
@@ -259,7 +259,7 @@ func TestPermitted_DenyByDefault(t *testing.T) {
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]settings.Source{
settings.Config.Server.SourceMap = map[string]*settings.Source{
"mnt/storage": {
Path: "mnt/storage",
Name: "storage",
@@ -359,7 +359,7 @@ func TestPermitted_DenyByDefaultWithDenyAll(t *testing.T) {
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]settings.Source{
settings.Config.Server.SourceMap = map[string]*settings.Source{
"mnt/storage": {
Path: "mnt/storage",
Name: "storage",
@@ -425,7 +425,7 @@ func TestPermitted_DenyByDefault_AdminRootAccess(t *testing.T) {
settings.Config.Server.SourceMap = originalSourceMap
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]settings.Source{
settings.Config.Server.SourceMap = map[string]*settings.Source{
"mnt/secure": {
Path: "mnt/secure",
Name: "secure",
@@ -502,7 +502,7 @@ func TestUserReportedBug(t *testing.T) {
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]settings.Source{
settings.Config.Server.SourceMap = map[string]*settings.Source{
"TEST_FOLDER": {
Path: "TEST_FOLDER",
Name: "TEST_FOLDER",
@@ -567,7 +567,7 @@ func TestSubfolderAccessLogicBug(t *testing.T) {
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]settings.Source{
settings.Config.Server.SourceMap = map[string]*settings.Source{
"TEST_FOLDER": {
Path: "TEST_FOLDER",
Name: "TEST_FOLDER",
@@ -626,7 +626,7 @@ func TestFileInfoBrowsingBug(t *testing.T) {
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]settings.Source{
settings.Config.Server.SourceMap = map[string]*settings.Source{
"TEST_FOLDER": {
Path: "TEST_FOLDER",
Name: "TEST_FOLDER",
@@ -684,3 +684,522 @@ func TestFileInfoBrowsingBug(t *testing.T) {
}
}
}
// TestCacheClearingOnRuleDeletion tests that the rules cache is properly cleared when rules are deleted
func TestCacheClearingOnRuleDeletion(t *testing.T) {
// Clear cache to start fresh
access.ClearCache()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "user1")
createTestUser(t, userStore, "user2")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
// Setup test sources
originalSourceMap := settings.Config.Server.SourceMap
defer func() {
settings.Config.Server.SourceMap = originalSourceMap
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]*settings.Source{
"test_source": {
Path: "test_source",
Name: "test_source",
Config: settings.SourceConfig{
DenyByDefault: false,
},
},
}
// Step 1: Add some rules
err = s.DenyUser("test_source", "/path1/", "user1")
if err != nil {
t.Fatalf("Failed to deny user1: %v", err)
}
err = s.AllowUser("test_source", "/path2/", "user2")
if err != nil {
t.Fatalf("Failed to allow user2: %v", err)
}
// Step 2: Call GetAllRules to populate the cache
rules1, err := s.GetAllRules("test_source")
if err != nil {
t.Fatalf("Failed to get all rules: %v", err)
}
// Verify we have the expected rules
if len(rules1) != 2 {
t.Errorf("Expected 2 rules, got %d", len(rules1))
}
if _, exists := rules1["/path1/"]; !exists {
t.Error("Expected /path1/ rule to exist")
}
if _, exists := rules1["/path2/"]; !exists {
t.Error("Expected /path2/ rule to exist")
}
// Step 3: Call GetAllRules again - should return cached result
rules2, err := s.GetAllRules("test_source")
if err != nil {
t.Fatalf("Failed to get all rules (cached): %v", err)
}
// Verify it's the same result (cached)
if len(rules2) != 2 {
t.Errorf("Expected 2 rules from cache, got %d", len(rules2))
}
// Step 4: Delete a rule
removed, err := s.RemoveDenyUser("test_source", "/path1/", "user1")
if err != nil {
t.Fatalf("Failed to remove deny user: %v", err)
}
if !removed {
t.Error("Expected rule to be removed")
}
// Step 5: Call GetAllRules again - should return fresh data (not cached)
rules3, err := s.GetAllRules("test_source")
if err != nil {
t.Fatalf("Failed to get all rules after deletion: %v", err)
}
// Verify the deleted rule is gone
if len(rules3) != 1 {
t.Errorf("Expected 1 rule after deletion, got %d", len(rules3))
}
if _, exists := rules3["/path1/"]; exists {
t.Error("Expected /path1/ rule to be deleted from cache")
}
if _, exists := rules3["/path2/"]; !exists {
t.Error("Expected /path2/ rule to still exist")
}
// Step 6: Delete the remaining rule
removed, err = s.RemoveAllowUser("test_source", "/path2/", "user2")
if err != nil {
t.Fatalf("Failed to remove allow user: %v", err)
}
if !removed {
t.Error("Expected rule to be removed")
}
// Step 7: Call GetAllRules again - should return empty result
rules4, err := s.GetAllRules("test_source")
if err != nil {
t.Fatalf("Failed to get all rules after second deletion: %v", err)
}
// Verify all rules are gone
if len(rules4) != 0 {
t.Errorf("Expected 0 rules after all deletions, got %d", len(rules4))
}
t.Log("Cache clearing test passed: Rules are immediately removed from cache when deleted")
}
// TestCacheClearingOnBulkRuleDeletion tests cache clearing for bulk operations
func TestCacheClearingOnBulkRuleDeletion(t *testing.T) {
// Clear cache to start fresh
access.ClearCache()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "user1")
createTestUser(t, userStore, "user2")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
// Setup test sources
originalSourceMap := settings.Config.Server.SourceMap
defer func() {
settings.Config.Server.SourceMap = originalSourceMap
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]*settings.Source{
"test_source": {
Path: "test_source",
Name: "test_source",
Config: settings.SourceConfig{
DenyByDefault: false,
},
},
}
// Step 1: Add multiple rules for user1
err = s.DenyUser("test_source", "/path1", "user1")
if err != nil {
t.Fatalf("Failed to deny user1 on path1: %v", err)
}
err = s.AllowUser("test_source", "/path2", "user1")
if err != nil {
t.Fatalf("Failed to allow user1 on path2: %v", err)
}
err = s.DenyUser("test_source", "/path3", "user1")
if err != nil {
t.Fatalf("Failed to deny user1 on path3: %v", err)
}
// Step 2: Populate cache
rules1, err := s.GetAllRules("test_source")
if err != nil {
t.Fatalf("Failed to get all rules: %v", err)
}
if len(rules1) != 3 {
t.Errorf("Expected 3 rules, got %d", len(rules1))
}
// Step 3: Remove all rules for user1 (bulk operation)
err = s.RemoveAllRulesForUser("user1")
if err != nil {
t.Fatalf("Failed to remove all rules for user1: %v", err)
}
// Step 4: Verify cache is cleared and rules are gone
rules2, err := s.GetAllRules("test_source")
if err != nil {
t.Fatalf("Failed to get all rules after bulk deletion: %v", err)
}
// All rules should be gone since user1 was the only user with rules
if len(rules2) != 0 {
t.Errorf("Expected 0 rules after bulk deletion, got %d", len(rules2))
}
t.Log("Bulk cache clearing test passed: All rules are immediately removed from cache when bulk deleted")
}
// TestNestedFolderAccessBug reproduces the exact scenarios described by the user
func TestNestedFolderAccessBug(t *testing.T) {
// Clear cache to start fresh
access.ClearCache()
s, userStore := createTestStorage(t)
createTestUser(t, userStore, "user1")
createTestUser(t, userStore, "user2")
err := s.LoadFromDB()
if err != nil && err != storm.ErrNotFound {
t.Errorf("unexpected error loading from DB: %v", err)
}
// Setup test sources
originalSourceMap := settings.Config.Server.SourceMap
defer func() {
settings.Config.Server.SourceMap = originalSourceMap
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]*settings.Source{
"TEST": {
Path: "TEST",
Name: "TEST",
Config: settings.SourceConfig{
DenyByDefault: false, // Allow by default
},
},
}
// Set up access rules as described
err = s.DenyUser("TEST", "/folder2", "user1")
if err != nil {
t.Fatalf("Failed to deny user1 access to folder2: %v", err)
}
err = s.DenyUser("TEST", "/folder1", "user2")
if err != nil {
t.Fatalf("Failed to deny user2 access to folder1: %v", err)
}
t.Log("=== SCENARIO 1: Direct folder access ===")
// Scenario 1: Direct folder access (should work correctly)
t.Log("Testing user1 access to /folder2 (should be denied)")
result1 := s.Permitted("TEST", "/folder2", "user1")
if result1 {
t.Error("user1 should be denied access to /folder2")
} else {
t.Log("✓ user1 correctly denied access to /folder2")
}
t.Log("Testing user2 access to /folder1 (should be denied)")
result2 := s.Permitted("TEST", "/folder1", "user2")
if result2 {
t.Error("user2 should be denied access to /folder1")
} else {
t.Log("✓ user2 correctly denied access to /folder1")
}
t.Log("Testing user1 access to /folder1 (should be allowed)")
result3 := s.Permitted("TEST", "/folder1", "user1")
if !result3 {
t.Error("user1 should be allowed access to /folder1")
} else {
t.Log("✓ user1 correctly allowed access to /folder1")
}
t.Log("Testing user2 access to /folder2 (should be allowed)")
result4 := s.Permitted("TEST", "/folder2", "user2")
if !result4 {
t.Error("user2 should be allowed access to /folder2")
} else {
t.Log("✓ user2 correctly allowed access to /folder2")
}
t.Log("=== SCENARIO 2: Nested folder access (the bug) ===")
// Scenario 2: Nested folder access (this is where the bug occurs)
t.Log("Testing user1 access to /folder2/another_folder2 (should be denied due to parent rule)")
result5 := s.Permitted("TEST", "/folder2/another_folder2", "user1")
if result5 {
t.Error("BUG: user1 should be denied access to /folder2/another_folder2 (parent folder is denied)")
} else {
t.Log("✓ user1 correctly denied access to /folder2/another_folder2")
}
t.Log("Testing user2 access to /folder1/another_folder1 (should be denied due to parent rule)")
result6 := s.Permitted("TEST", "/folder1/another_folder1", "user2")
if result6 {
t.Error("BUG: user2 should be denied access to /folder1/another_folder1 (parent folder is denied)")
} else {
t.Log("✓ user2 correctly denied access to /folder1/another_folder1")
}
t.Log("Testing user1 access to /folder1/another_folder1 (should be allowed)")
result7 := s.Permitted("TEST", "/folder1/another_folder1", "user1")
if !result7 {
t.Error("user1 should be allowed access to /folder1/another_folder1")
} else {
t.Log("✓ user1 correctly allowed access to /folder1/another_folder1")
}
t.Log("Testing user2 access to /folder2/another_folder2 (should be allowed)")
result8 := s.Permitted("TEST", "/folder2/another_folder2", "user2")
if !result8 {
t.Error("user2 should be allowed access to /folder2/another_folder2")
} else {
t.Log("✓ user2 correctly allowed access to /folder2/another_folder2")
}
// Test deeper nesting
t.Log("Testing deeper nesting: user1 access to /folder2/another_folder2/deep_folder (should be denied)")
result9 := s.Permitted("TEST", "/folder2/another_folder2/deep_folder", "user1")
if result9 {
t.Error("BUG: user1 should be denied access to /folder2/another_folder2/deep_folder (parent folder is denied)")
} else {
t.Log("✓ user1 correctly denied access to /folder2/another_folder2/deep_folder")
}
t.Log("=== SCENARIO 3: Specific allow rule on subfolder ===")
// Add a specific allow rule for user1 on a subfolder of folder2
err = s.AllowUser("TEST", "/folder2/allowed_subfolder", "user1")
if err != nil {
t.Fatalf("Failed to allow user1 access to /folder2/allowed_subfolder: %v", err)
}
t.Log("Testing user1 access to /folder2/allowed_subfolder (should be allowed due to specific allow rule)")
result10 := s.Permitted("TEST", "/folder2/allowed_subfolder", "user1")
if !result10 {
t.Error("user1 should be allowed access to /folder2/allowed_subfolder (has specific allow rule)")
} else {
t.Log("✓ user1 correctly allowed access to /folder2/allowed_subfolder")
}
t.Log("Testing user1 access to /folder2/denied_subfolder (should be denied - no specific allow rule)")
result11 := s.Permitted("TEST", "/folder2/denied_subfolder", "user1")
if result11 {
t.Error("user1 should be denied access to /folder2/denied_subfolder (no specific allow rule)")
} else {
t.Log("✓ user1 correctly denied access to /folder2/denied_subfolder")
}
}
// TestFolderVisibilityBugReproduction tests the exact scenario reported by the user
func TestFolderVisibilityBugReproduction(t *testing.T) {
// Clear cache to start fresh
access.ClearCache()
s, userStorage := createTestStorage(t)
// Create test users first
user1 := &users.User{NonAdminEditable: users.NonAdminEditable{Password: "test"}, Username: "user1"}
user2 := &users.User{NonAdminEditable: users.NonAdminEditable{Password: "test"}, Username: "user2"}
err := userStorage.Save(user1, false, false)
if err != nil {
t.Fatalf("failed to create user1: %v", err)
}
err = userStorage.Save(user2, false, false)
if err != nil {
t.Fatalf("failed to create user2: %v", err)
}
// Setup test sources
originalSourceMap := settings.Config.Server.SourceMap
defer func() {
settings.Config.Server.SourceMap = originalSourceMap
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]*settings.Source{
"TEST": {
Path: "TEST",
Name: "TEST",
Config: settings.SourceConfig{
DenyByDefault: false, // Allow by default
},
},
}
// Set up the exact access rules from the user's report
err = s.DenyUser("TEST", "/test/folder1", "user2")
if err != nil {
t.Fatalf("failed to deny user2 access to /test/folder1: %v", err)
}
err = s.DenyUser("TEST", "/test/folder2", "user1")
if err != nil {
t.Fatalf("failed to deny user1 access to /test/folder2: %v", err)
}
// Test the exact scenario: user1 should not see contents of /test/folder2
// This simulates what happens when the frontend calls the API
// First, verify that user1 cannot access /test/folder2 directly
permitted := s.Permitted("TEST", "/test/folder2", "user1")
if permitted {
t.Error("user1 should be denied access to /test/folder2")
} else {
t.Log("✓ user1 correctly denied access to /test/folder2")
}
// Now test the folder visibility logic that was buggy
// This simulates the FileInfoFaster function behavior
// Mock folder structure as reported by user
mockFolders := []struct {
Name string
Type string
}{
{Name: "subfolder2", Type: "directory"},
{Name: "test2", Type: "directory"},
}
// Simulate the buggy path construction (before fix)
buggyPaths := []string{
"/test/folder2subfolder2", // Missing / separator
"/test/folder2test2", // Missing / separator
}
// These should fail (buggy behavior) - but they actually fail due to missing source config
for i, path := range buggyPaths {
permitted := s.Permitted("TEST", path, "user1")
// Note: These actually return false because there's no source config, not because of the path bug
t.Logf("Buggy path %d: %s -> %v (would be true if source config existed)", i+1, path, permitted)
}
// Simulate the correct path construction (after fix)
correctPaths := []string{
"/test/folder2/subfolder2", // With / separator
"/test/folder2/test2", // With / separator
}
// These should fail (correct behavior)
for i, path := range correctPaths {
permitted := s.Permitted("TEST", path, "user1")
if permitted {
t.Errorf("Correct path construction should deny access: %s", path)
}
t.Logf("Correct path %d: %s -> %v (should be false)", i+1, path, permitted)
}
// Test that the folder visibility logic would work correctly
// (This is what the FileInfoFaster function should do)
hasPermittedPaths := false
for _, folder := range mockFolders {
correctPath := "/test/folder2/" + folder.Name
if s.Permitted("TEST", correctPath, "user1") {
hasPermittedPaths = true
t.Logf("Found permitted subfolder: %s", folder.Name)
}
}
if hasPermittedPaths {
t.Error("No subfolders should be permitted for user1 in /test/folder2")
} else {
t.Log("✓ Folder visibility logic correctly denies access to all subfolders")
}
}
// TestHasAnyVisibleItems tests the HasAnyVisibleItems method
func TestHasAnyVisibleItems(t *testing.T) {
// Clear cache to start fresh
access.ClearCache()
s, userStorage := createTestStorage(t)
// Create test users
user1 := &users.User{NonAdminEditable: users.NonAdminEditable{Password: "test"}, Username: "user1"}
user2 := &users.User{NonAdminEditable: users.NonAdminEditable{Password: "test"}, Username: "user2"}
err := userStorage.Save(user1, false, false)
if err != nil {
t.Fatalf("failed to create user1: %v", err)
}
err = userStorage.Save(user2, false, false)
if err != nil {
t.Fatalf("failed to create user2: %v", err)
}
// Setup test sources
originalSourceMap := settings.Config.Server.SourceMap
defer func() {
settings.Config.Server.SourceMap = originalSourceMap
access.ClearCache()
}()
settings.Config.Server.SourceMap = map[string]*settings.Source{
"TEST": {
Path: "TEST",
Name: "TEST",
Config: settings.SourceConfig{
DenyByDefault: false, // Allow by default
},
},
}
// Set up access rules
err = s.DenyUser("TEST", "/folder1", "user1")
if err != nil {
t.Fatalf("failed to deny user1 access to /folder1: %v", err)
}
err = s.AllowUser("TEST", "/folder1/allowed_subfolder", "user1")
if err != nil {
t.Fatalf("failed to allow user1 access to /folder1/allowed_subfolder: %v", err)
}
// Test with item names
itemNames := []string{"allowed_subfolder", "denied_subfolder", "another_denied_folder"}
// Test for user1 (denied access to parent folder but has access to one subfolder)
hasVisible := s.HasAnyVisibleItems("TEST", "/folder1", itemNames, "user1")
if !hasVisible {
t.Error("Expected user1 to have access to at least one item in /folder1")
}
// Test for user2 (no restrictions, but no source config so will be denied)
hasVisible2 := s.HasAnyVisibleItems("TEST", "/folder1", itemNames, "user2")
// Note: user2 will be denied due to missing source config, not due to access rules
t.Logf("user2 access to items in /folder1: %v (denied due to missing source config)", hasVisible2)
// Test with no accessible items
deniedItemNames := []string{"denied_subfolder", "another_denied_folder"}
hasVisible3 := s.HasAnyVisibleItems("TEST", "/folder1", deniedItemNames, "user1")
if hasVisible3 {
t.Error("Expected user1 to NOT have access to denied items in /folder1")
}
t.Log("✓ HasAnyVisibleItems correctly checks access permissions for items")
}
+55
View File
@@ -0,0 +1,55 @@
package access
import (
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func (s *Storage) CheckChildItemAccess(response *iteminfo.ExtendedFileInfo, index *indexing.Index, username string) error {
// Collect all item names to check
allItemNames := make([]string, 0, len(response.Folders)+len(response.Files))
for _, folder := range response.Folders {
allItemNames = append(allItemNames, folder.Name)
}
for _, file := range response.Files {
allItemNames = append(allItemNames, file.Name)
}
// Use standardized path with trailing slash for proper path construction
parentPath := index.MakeIndexPath(response.Path)
// Check if user has access to any items
if !s.HasAnyVisibleItems(index.Path, parentPath, allItemNames, username) {
return errors.ErrAccessDenied
}
// Filter and return only the items the user has access to
response.Folders = make([]iteminfo.ItemInfo, 0)
response.Files = make([]iteminfo.ItemInfo, 0)
// Check each subfolder for access permissions
for _, folder := range response.Folders {
indexPath := parentPath + folder.Name
if s.Permitted(index.Path, indexPath, username) {
response.Folders = append(response.Folders, folder)
}
}
// Check each subfile for access permissions
for _, file := range response.Files {
indexPath := parentPath + file.Name
if s.Permitted(index.Path, indexPath, username) {
response.Files = append(response.Files, file)
}
}
return nil
}
type FileOptionsExtended struct {
utils.FileOptions
Access *Storage
}
+46
View File
@@ -94,3 +94,49 @@ func (l *Link) GetFileName() string {
}
return filepath.Base(l.Path)
}
// InitUserDownloads initializes the user downloads map if needed
func (l *Link) InitUserDownloads() {
l.Mu.Lock()
defer l.Mu.Unlock()
if l.UserDownloads == nil {
l.UserDownloads = make(map[string]int)
}
}
// IncrementUserDownload increments the download count for a specific user
func (l *Link) IncrementUserDownload(username string) {
l.Mu.Lock()
defer l.Mu.Unlock()
if l.UserDownloads == nil {
l.UserDownloads = make(map[string]int)
}
l.UserDownloads[username]++
}
// GetUserDownloadCount returns the download count for a specific user
func (l *Link) GetUserDownloadCount(username string) int {
l.Mu.Lock()
defer l.Mu.Unlock()
if l.UserDownloads == nil {
return 0
}
return l.UserDownloads[username]
}
// ResetDownloadCounts resets both global and per-user download counts
func (l *Link) ResetDownloadCounts() {
l.Mu.Lock()
defer l.Mu.Unlock()
l.Downloads = 0
l.UserDownloads = make(map[string]int)
}
// HasReachedUserLimit checks if a user has reached their download limit
func (l *Link) HasReachedUserLimit(username string) bool {
if !l.PerUserDownloadLimit || l.DownloadsLimit == 0 {
return false
}
count := l.GetUserDownloadCount(username)
return count >= l.DownloadsLimit
}
+35 -31
View File
@@ -4,31 +4,34 @@ import "sync"
type CommonShare struct {
//AllowEdit bool `json:"allowEdit,omitempty"`
AllowUpload bool `json:"allowUpload,omitempty"`
DisableFileViewer bool `json:"disableFileViewer,omitempty"`
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"`
EnableOnlyOfficeEditing bool `json:"enableOnlyOfficeEditing,omitempty"`
AllowUpload bool `json:"allowUpload,omitempty"`
DisableFileViewer bool `json:"disableFileViewer,omitempty"`
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"`
EnableOnlyOfficeEditing bool `json:"enableOnlyOfficeEditing,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.
}
type CreateBody struct {
CommonShare
@@ -41,12 +44,13 @@ type CreateBody struct {
// Link is the information needed to build a shareable link.
type Link struct {
CommonShare
Mu sync.Mutex `json:"-"`
Downloads int `json:"downloads"`
Hash string `json:"hash" storm:"id,index"`
UserID uint `json:"userID"`
Expire int64 `json:"expire"`
PasswordHash string `json:"password_hash,omitempty"`
Mu sync.Mutex `json:"-"`
Downloads int `json:"downloads"`
UserDownloads map[string]int `json:"-"` // Track downloads per username (not persisted)
Hash string `json:"hash" storm:"id,index"`
UserID uint `json:"userID"`
Expire int64 `json:"expire"`
PasswordHash string `json:"password_hash,omitempty"`
// 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.
+4 -1
View File
@@ -148,8 +148,11 @@ func (s *Storage) GetByHash(hash string) (*Link, error) {
_ = s.back.Delete(hash)
return nil, errors.ErrNotExist
}
s.mu.Lock()
// Initialize UserDownloads map
link.InitUserDownloads()
s.mu.Lock()
s.shareCache[hash] = link
s.mu.Unlock()
return link, nil
+1
View File
@@ -107,6 +107,7 @@ type NonAdminEditable struct {
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
DefaultLandingPage string `json:"defaultLandingPage"` // default landing page to use: eg. /files/mysource/mysubpath, /settings, etc.
}
type FileLoading struct {
+50 -7
View File
@@ -2,6 +2,7 @@ package ffmpeg
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
@@ -18,18 +19,33 @@ type ImageService struct {
ffprobePath string
debug bool
cacheDir string
semaphore chan struct{}
}
// NewImageService creates a new image service instance
func NewImageService(ffmpegPath, ffprobePath string, debug bool, cacheDir string) *ImageService {
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) {
// Use exiftool to get orientation information
@@ -145,7 +161,11 @@ 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(heicPath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
func (s *ImageService) 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()
}
// Get EXIF orientation and create appropriate filter
orientation, err := s.GetImageOrientation(heicPath)
@@ -186,13 +206,16 @@ func (s *ImageService) ConvertHEICToJPEGDirect(heicPath string, targetWidth, tar
// Add quality and output settings
args = append(args, "-q:v", quality, "-pix_fmt", "yuvj420p", "-y", outputFile)
cmd := exec.Command(s.ffmpegPath, args...)
cmd := exec.CommandContext(ctx, s.ffmpegPath, args...)
var cmdOut bytes.Buffer
var cmdErr bytes.Buffer
cmd.Stdout = &cmdOut
cmd.Stderr = &cmdErr
if err = cmd.Run(); err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, fmt.Errorf("direct HEIC conversion failed: %w", err)
}
@@ -206,7 +229,11 @@ func (s *ImageService) ConvertHEICToJPEGDirect(heicPath string, targetWidth, tar
}
// ConvertHEICToJPEG converts a HEIC file to JPEG with specified dimensions and quality using proper tile extraction
func (s *ImageService) ConvertHEICToJPEG(heicPath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
func (s *ImageService) 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()
}
// Create temporary directory for tile processing
outputDir := s.cacheDir
tempDir := filepath.Join(outputDir, fmt.Sprintf("heic_tiles_%d", os.Getpid()))
@@ -220,7 +247,7 @@ func (s *ImageService) ConvertHEICToJPEG(heicPath string, targetWidth, targetHei
// Step 1: Get grid info using trace output
// Use ffprobe with trace level to get grid information (with time limit for speed)
gridCmd := exec.Command(s.ffmpegPath, "-loglevel", "trace", "-i", heicPath, "-f", "null", "-", "-t", "0.1")
gridCmd := exec.CommandContext(ctx, s.ffmpegPath, "-loglevel", "trace", "-i", heicPath, "-f", "null", "-", "-t", "0.1")
var gridOut bytes.Buffer
var gridErr bytes.Buffer
gridCmd.Stdout = &gridOut
@@ -267,11 +294,16 @@ func (s *ImageService) ConvertHEICToJPEG(heicPath string, targetWidth, targetHei
// Step 2: Extract tile streams (skip stream 0 which is compatibility image)
// Check context again before extraction
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Extract using JPEG for faster I/O (PNG was unnecessarily slow and large)
// Skip the first output (stream 0) since it's the compatibility image
tilesPattern := filepath.Join(tempDir, "output_%d.jpg")
extractCmd := exec.Command(
extractCmd := exec.CommandContext(ctx,
s.ffmpegPath,
"-i", heicPath,
"-map", "0", // Map all streams
@@ -286,6 +318,9 @@ func (s *ImageService) ConvertHEICToJPEG(heicPath string, targetWidth, targetHei
extractCmd.Stderr = &extractErr
if err = extractCmd.Run(); err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, fmt.Errorf("tile extraction failed: %w", err)
}
@@ -321,6 +356,11 @@ func (s *ImageService) ConvertHEICToJPEG(heicPath string, targetWidth, targetHei
actualTiles := tileIndex - 1
// Check context before merge
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Step 4: Merge tiles back into final image with proper quality and rotation
outputFile := filepath.Join(tempDir, "final_output.jpg")
@@ -342,7 +382,7 @@ func (s *ImageService) ConvertHEICToJPEG(heicPath string, targetWidth, targetHei
gridCols, gridRows, actualTiles, orientationFilter, targetWidth, targetHeight)
}
mergeCmd := exec.Command(
mergeCmd := exec.CommandContext(ctx,
s.ffmpegPath,
"-i", inputPattern,
"-vf", filterChain,
@@ -359,6 +399,9 @@ func (s *ImageService) ConvertHEICToJPEG(heicPath string, targetWidth, targetHei
mergeCmd.Stderr = &mergeErr
if err = mergeCmd.Run(); err != nil {
if ctx.Err() != nil {
return nil, ctx.Err()
}
return nil, fmt.Errorf("tile merge failed: %w", err)
}
+5 -4
View File
@@ -1,6 +1,7 @@
package ffmpeg
import (
"context"
"os"
"path/filepath"
"testing"
@@ -124,7 +125,7 @@ func TestGetImageOrientation(t *testing.T) {
}
defer os.RemoveAll(tempDir)
service := NewImageService("ffmpeg", "ffprobe", false, tempDir)
service := NewImageService("ffmpeg", "ffprobe", 2, false, tempDir)
t.Run("Non-existent file", func(t *testing.T) {
nonExistentFile := filepath.Join(tempDir, "non_existent.heic")
@@ -158,7 +159,7 @@ func TestNewImageService(t *testing.T) {
debug := true
cacheDir := "/tmp/test_cache"
service := NewImageService(ffmpegPath, ffprobePath, debug, cacheDir)
service := NewImageService(ffmpegPath, ffprobePath, 2, debug, cacheDir)
if service.ffmpegPath != ffmpegPath {
t.Errorf("Expected ffmpegPath %q, got %q", ffmpegPath, service.ffmpegPath)
@@ -188,7 +189,7 @@ func TestConvertHEICToJPEGDirect_OrientationHandling(t *testing.T) {
}
defer os.RemoveAll(tempDir)
service := NewImageService("ffmpeg", "ffprobe", true, 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
@@ -222,7 +223,7 @@ func TestConvertHEICToJPEGDirect_OrientationHandling(t *testing.T) {
t.Logf("Generated filter: %s", filter)
// Test actual conversion (small size for speed)
jpegBytes, err := service.ConvertHEICToJPEGDirect(testFile, 100, 100, "5")
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
+1
View File
@@ -67,6 +67,7 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
}
// detectEmbeddedSubtitles uses ffprobe to find embedded subtitle tracks
// Always runs ffprobe - results are cached for performance
func detectEmbeddedSubtitles(realPath string) []SubtitleTrack {
cmd := exec.Command("ffprobe",
"-v", "quiet",
+41 -90
View File
@@ -15,19 +15,21 @@ import (
// VideoService handles video preview operations with ffmpeg
type VideoService struct {
ffmpegPath string
ffprobePath string
debug bool
semaphore chan 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),
ffmpegPath: ffmpegPath,
ffprobePath: ffprobePath,
debug: debug,
semaphore: make(chan struct{}, maxConcurrent),
maxConcurrent: maxConcurrent,
}
}
@@ -44,97 +46,26 @@ func (s *VideoService) release() {
<-s.semaphore
}
// GenerateVideoPreview generates a single preview image from a video using ffmpeg.
// videoPath: path to the input video file.
// outputPath: path where the generated preview image will be saved (e.g., "/tmp/preview.jpg").
// percentageSeek: percentage of video duration to seek to (0-100).
func (s *VideoService) GenerateVideoPreview(videoPath, outputPath string, percentageSeek int) ([]byte, error) {
if err := s.acquire(context.Background()); err != nil {
return nil, err
}
defer s.release()
// Validate percentage parameter
if percentageSeek < 0 || percentageSeek > 100 {
percentageSeek = 10 // Default to 10% if invalid
}
// Step 1: Get video duration from the container format
probeCmd := exec.Command(
s.ffprobePath,
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
videoPath,
)
var probeOut bytes.Buffer
probeCmd.Stdout = &probeOut
if s.debug {
probeCmd.Stderr = os.Stderr
}
if err := probeCmd.Run(); err != nil {
logger.Errorf("ffprobe command failed on file '%v' : %v", videoPath, err)
return nil, fmt.Errorf("ffprobe failed: %w", err)
}
durationStr := strings.TrimSpace(probeOut.String())
if durationStr == "" || durationStr == "N/A" {
logger.Errorf("could not determine video duration for file '%v' using duration info '%v'", videoPath, durationStr)
return nil, fmt.Errorf("could not determine video duration")
}
durationFloat, err := strconv.ParseFloat(durationStr, 64)
if err != nil {
return nil, fmt.Errorf("invalid duration: %v", err)
}
if durationFloat <= 0 {
return nil, fmt.Errorf("video duration must be positive")
}
// Step 2: Calculate seek time with higher precision
seekTime := durationFloat * float64(percentageSeek) / 100.0
seekTimeStr := strconv.FormatFloat(seekTime, 'f', 3, 64) // 3 decimal places precision
// Step 3: Extract frame at seek time with optimized parameters
cmd := exec.Command(
s.ffmpegPath,
"-ss", seekTimeStr, // Use precise seek time
"-i", videoPath,
"-frames:v", "1",
"-q:v", "10", // Good quality/size balance
"-f", "image2", // Explicitly specify image format
"-y", // overwrite output
outputPath,
)
if s.debug {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
err = cmd.Run()
if err != nil {
return nil, fmt.Errorf("ffmpeg command failed on file '%v' : %w", videoPath, err)
}
return os.ReadFile(outputPath)
}
// 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(videoPath string, percentageSeek int, writer io.Writer) error {
if err := s.acquire(context.Background()); err != nil {
func (s *VideoService) GenerateVideoPreviewStreaming(ctx context.Context, videoPath string, percentageSeek int, writer io.Writer) error {
if err := s.acquire(ctx); err != nil {
return err
}
defer s.release()
// Check if context is cancelled before starting
if ctx.Err() != nil {
return ctx.Err()
}
// Validate percentage parameter
if percentageSeek < 0 || percentageSeek > 100 {
percentageSeek = 10 // Default to 10% if invalid
}
// Step 1: Get video duration from the container format
probeCmd := exec.Command(
probeCmd := exec.CommandContext(ctx,
s.ffprobePath,
"-v", "error",
"-show_entries", "format=duration",
@@ -148,7 +79,17 @@ func (s *VideoService) GenerateVideoPreviewStreaming(videoPath string, percentag
probeCmd.Stderr = os.Stderr
}
if err := probeCmd.Run(); err != nil {
logger.Errorf("ffprobe command failed on file '%v' : %v", videoPath, err)
if ctx.Err() != nil {
return ctx.Err()
}
// Capture stderr output for better debugging
stderrOutput := ""
if probeCmd.Stderr != nil {
if stderrBuf, ok := probeCmd.Stderr.(*bytes.Buffer); ok {
stderrOutput = stderrBuf.String()
}
}
logger.Errorf("ffprobe command failed on file '%s': %v (stderr: %s)", videoPath, err, stderrOutput)
return fmt.Errorf("ffprobe failed: %w", err)
}
@@ -172,7 +113,7 @@ func (s *VideoService) GenerateVideoPreviewStreaming(videoPath string, percentag
seekTimeStr := strconv.FormatFloat(seekTime, 'f', 3, 64) // 3 decimal places precision
// Step 3: Extract frame and stream directly to writer
cmd := exec.Command(
cmd := exec.CommandContext(ctx,
s.ffmpegPath,
"-ss", seekTimeStr, // Use precise seek time
"-i", videoPath,
@@ -184,12 +125,22 @@ func (s *VideoService) GenerateVideoPreviewStreaming(videoPath string, percentag
)
cmd.Stdout = writer
var stderrBuf bytes.Buffer
if s.debug {
cmd.Stderr = os.Stderr
} else {
// Capture stderr for error logging
cmd.Stderr = &stderrBuf
}
err = cmd.Run()
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
stderrOutput := stderrBuf.String()
logger.Errorf("ffmpeg command failed on file '%s' (seek: %s): %v (stderr: %s)",
videoPath, seekTimeStr, err, stderrOutput)
return fmt.Errorf("ffmpeg command failed on file '%v' : %w", videoPath, err)
}
return nil
+46 -40
View File
@@ -13,7 +13,7 @@ require (
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/gtsteffaniak/go-cache v1.0.0
github.com/gtsteffaniak/go-logger v0.2.2
github.com/gtsteffaniak/go-logger v0.2.3-0.20250926212932-e91a612f674c
github.com/kovidgoyal/imaging v1.6.4
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.15.0
@@ -38,14 +38,16 @@ require (
dev.gaijin.team/go/golib v0.6.0 // indirect
github.com/4meepo/tagalign v1.4.3 // indirect
github.com/Abirdcfly/dupword v0.1.6 // indirect
github.com/AdminBenni/iota-mixing v1.0.0 // indirect
github.com/AlwxSin/noinlineerr v1.0.5 // indirect
github.com/Antonboom/errname v1.1.0 // indirect
github.com/Antonboom/nilnil v1.1.0 // indirect
github.com/Antonboom/testifylint v1.6.1 // indirect
github.com/Antonboom/errname v1.1.1 // indirect
github.com/Antonboom/nilnil v1.1.1 // indirect
github.com/Antonboom/testifylint v1.6.4 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 // indirect
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/semver/v3 v3.3.1 // 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/alecthomas/chroma/v2 v2.20.0 // indirect
@@ -64,7 +66,7 @@ require (
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/bombsimon/wsl/v5 v5.1.1 // indirect
github.com/bombsimon/wsl/v5 v5.2.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
@@ -90,7 +92,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.8.4 // indirect
github.com/ebitengine/purego v0.9.0 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
@@ -98,25 +100,25 @@ require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/ghostiam/protogetter v0.3.15 // indirect
github.com/ghostiam/protogetter v0.3.16 // indirect
github.com/go-critic/go-critic v0.13.0 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
github.com/go-openapi/jsonpointer v0.22.0 // indirect
github.com/go-openapi/jsonreference v0.21.1 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.24.1 // indirect
github.com/go-openapi/swag/cmdutils v0.24.0 // indirect
github.com/go-openapi/swag/conv v0.24.0 // indirect
github.com/go-openapi/swag/fileutils v0.24.0 // indirect
github.com/go-openapi/swag/jsonname v0.24.0 // indirect
github.com/go-openapi/swag/jsonutils v0.24.0 // indirect
github.com/go-openapi/swag/loading v0.24.0 // indirect
github.com/go-openapi/swag/mangling v0.24.0 // indirect
github.com/go-openapi/swag/netutils v0.24.0 // indirect
github.com/go-openapi/swag/stringutils v0.24.0 // indirect
github.com/go-openapi/swag/typeutils v0.24.0 // indirect
github.com/go-openapi/swag/yamlutils v0.24.0 // 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 v0.25.1 // indirect
github.com/go-openapi/swag/cmdutils v0.25.1 // indirect
github.com/go-openapi/swag/conv v0.25.1 // indirect
github.com/go-openapi/swag/fileutils 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/mangling v0.25.1 // indirect
github.com/go-openapi/swag/netutils 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-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,21 +131,24 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.10.0 // indirect
github.com/gofrs/flock v0.12.1 // indirect
github.com/gohugoio/hugo v0.149.1 // indirect
github.com/golang/geo v0.0.0-20250917161122-64cb148137c6 // 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.0 // 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.4.0 // indirect
github.com/golangci/golangci-lint/v2 v2.5.0 // indirect
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 // indirect
github.com/golangci/misspell v0.7.0 // indirect
github.com/golangci/nilerr v0.0.0-20250918000102-015671e622fe // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
github.com/gordonklaus/ineffassign v0.1.0 // indirect
github.com/gordonklaus/ineffassign v0.2.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.5.0 // indirect
github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect
@@ -163,13 +168,13 @@ require (
github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect
github.com/kisielk/errcheck v1.9.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kulti/thelper v0.6.3 // indirect
github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.14 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
github.com/ldez/exptostd v0.4.4 // indirect
github.com/ldez/gomoddirectives v0.7.0 // indirect
github.com/ldez/grignotin v0.10.0 // indirect
github.com/ldez/tagliatelle v0.7.1 // indirect
github.com/ldez/grignotin v0.10.1 // indirect
github.com/ldez/tagliatelle v0.7.2 // indirect
github.com/ldez/usetesting v0.5.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
@@ -177,7 +182,7 @@ require (
github.com/macabu/inamedparam v0.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/manuelarte/embeddedstructfieldcheck v0.3.0 // 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
@@ -186,7 +191,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.11.0 // indirect
github.com/mgechev/revive v1.12.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/moricho/tparallel v0.3.2 // indirect
@@ -194,7 +199,7 @@ require (
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.20.0 // indirect
github.com/nunnatsa/ginkgolinter v0.21.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -218,15 +223,15 @@ require (
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.22.7 // indirect
github.com/securego/gosec/v2 v2.22.8 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.4.0 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/cobra v1.10.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.12.0 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.2.0 // indirect
@@ -235,7 +240,7 @@ require (
github.com/swaggo/files v1.0.1 // indirect
github.com/tdakkota/asciicheck v0.4.1 // indirect
github.com/tdewolff/parse/v2 v2.8.3 // indirect
github.com/tetafro/godot v1.5.1 // indirect
github.com/tetafro/godot v1.5.4 // indirect
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect
github.com/timonwong/loggercheck v0.11.0 // indirect
github.com/tomarrell/wrapcheck/v2 v2.11.0 // indirect
@@ -251,15 +256,16 @@ require (
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.13.1 // indirect
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.11.1 // indirect
go.augendre.info/arangolint v0.2.0 // indirect
go.augendre.info/fatcontext v0.8.0 // indirect
go.augendre.info/fatcontext v0.8.1 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp/typeparams v0.0.0-20250911091902-df9299821621 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
@@ -268,7 +274,7 @@ require (
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.6.1 // indirect
mvdan.cc/gofumpt v0.8.0 // indirect
mvdan.cc/gofumpt v0.9.1 // indirect
mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
+98
View File
@@ -47,14 +47,22 @@ github.com/4meepo/tagalign v1.4.3 h1:Bnu7jGWwbfpAie2vyl63Zup5KuRv21olsPIha53BJr8
github.com/4meepo/tagalign v1.4.3/go.mod h1:00WwRjiuSbrRJnSVeGWPLp2epS5Q/l4UEy0apLLS37c=
github.com/Abirdcfly/dupword v0.1.6 h1:qeL6u0442RPRe3mcaLcbaCi2/Y/hOcdtw6DE9odjz9c=
github.com/Abirdcfly/dupword v0.1.6/go.mod h1:s+BFMuL/I4YSiFv29snqyjwzDp4b65W2Kvy+PKzZ6cw=
github.com/AdminBenni/iota-mixing v1.0.0 h1:Os6lpjG2dp/AE5fYBPAA1zfa2qMdCAWwPMCgpwKq7wo=
github.com/AdminBenni/iota-mixing v1.0.0/go.mod h1:i4+tpAaB+qMVIV9OK3m4/DAynOd5bQFaOu+2AhtBCNY=
github.com/AlwxSin/noinlineerr v1.0.5 h1:RUjt63wk1AYWTXtVXbSqemlbVTb23JOSRiNsshj7TbY=
github.com/AlwxSin/noinlineerr v1.0.5/go.mod h1:+QgkkoYrMH7RHvcdxdlI7vYYEdgeoFOVjU9sUhw/rQc=
github.com/Antonboom/errname v1.1.0 h1:A+ucvdpMwlo/myWrkHEUEBWc/xuXdud23S8tmTb/oAE=
github.com/Antonboom/errname v1.1.0/go.mod h1:O1NMrzgUcVBGIfi3xlVuvX8Q/VP/73sseCaAppfjqZw=
github.com/Antonboom/errname v1.1.1 h1:bllB7mlIbTVzO9jmSWVWLjxTEbGBVQ1Ff/ClQgtPw9Q=
github.com/Antonboom/errname v1.1.1/go.mod h1:gjhe24xoxXp0ScLtHzjiXp0Exi1RFLKJb0bVBtWKCWQ=
github.com/Antonboom/nilnil v1.1.0 h1:jGxJxjgYS3VUUtOTNk8Z1icwT5ESpLH/426fjmQG+ng=
github.com/Antonboom/nilnil v1.1.0/go.mod h1:b7sAlogQjFa1wV8jUW3o4PMzDVFLbTux+xnQdvzdcIE=
github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksufQ=
github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II=
github.com/Antonboom/testifylint v1.6.1 h1:6ZSytkFWatT8mwZlmRCHkWz1gPi+q6UBSbieji2Gj/o=
github.com/Antonboom/testifylint v1.6.1/go.mod h1:k+nEkathI2NFjKO6HvwmSrbzUcQ6FAnbZV+ZRrnXPLI=
github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ=
github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4=
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@@ -65,10 +73,14 @@ github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM=
github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24 h1:sHglBQTwgx+rWPdisA5ynNEsoARbiCBOyGcJM4/OzsM=
github.com/Djarvur/go-err113 v0.0.0-20210108212216-aea10b59be24/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs=
github.com/Djarvur/go-err113 v0.1.1 h1:eHfopDqXRwAi+YmCUas75ZE0+hoBHJ2GQNLYRSxao4g=
github.com/Djarvur/go-err113 v0.1.1/go.mod h1:IaWJdYFLg76t2ihfflPZnM1LIQszWOsFDh2hhhAVF6k=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/MirrexOne/unqueryvet v1.2.1 h1:M+zdXMq84g+E1YOLa7g7ExN3dWfZQrdDSTCM7gC+m/A=
github.com/MirrexOne/unqueryvet v1.2.1/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
@@ -150,6 +162,8 @@ github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+j
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
github.com/bombsimon/wsl/v5 v5.1.1 h1:cQg5KJf9FlctAH4cpL9vLKnziYknoCMCdqXl0wjl72Q=
github.com/bombsimon/wsl/v5 v5.1.1/go.mod h1:Gp8lD04z27wm3FANIUPZycXp+8huVsn0oxc+n4qfV9I=
github.com/bombsimon/wsl/v5 v5.2.0 h1:PyCCwd3Q7abGs3e34IW4jLYlBS+FbsU6iK+Tb3NnDp4=
github.com/bombsimon/wsl/v5 v5.2.0/go.mod h1:Gp8lD04z27wm3FANIUPZycXp+8huVsn0oxc+n4qfV9I=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -236,6 +250,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -267,6 +283,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.15 h1:1KF5sXel0HE48zh1/vn0Loiw25A9ApyseLzQuif1mLY=
github.com/ghostiam/protogetter v0.3.15/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA=
github.com/ghostiam/protogetter v0.3.16 h1:UkrisuJBYLnZW6FcYUNBDJOqY3X22RtoYMlCsiNlFFA=
github.com/ghostiam/protogetter v0.3.16/go.mod h1:4SRRIv6PcjkIMpUkRUsP4TsUTqO/N3Fmvwivuc/sCHA=
github.com/go-critic/go-critic v0.13.0 h1:kJzM7wzltQasSUXtYyTl6UaPVySO6GkaR1thFnJ6afY=
github.com/go-critic/go-critic v0.13.0/go.mod h1:M/YeuJ3vOCQDnP2SU+ZhjgRzwzcBW87JqLpMJLrZDLI=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
@@ -290,34 +308,64 @@ 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.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM=
github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU=
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.1 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA=
github.com/go-openapi/jsonreference v0.21.1/go.mod h1:PWs8rO4xxTUqKGu+lEvvCxD5k2X7QYkKAepJyCmSTT8=
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.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
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/swag v0.24.1 h1:DPdYTZKo6AQCRqzwr/kGkxJzHhpKxZ9i/oX0zag+MF8=
github.com/go-openapi/swag v0.24.1/go.mod h1:sm8I3lCPlspsBBwUm1t5oZeWZS0s7m/A+Psg0ooRU0A=
github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8=
github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo=
github.com/go-openapi/swag/cmdutils v0.24.0 h1:KlRCffHwXFI6E5MV9n8o8zBRElpY4uK4yWyAMWETo9I=
github.com/go-openapi/swag/cmdutils v0.24.0/go.mod h1:uxib2FAeQMByyHomTlsP8h1TtPd54Msu2ZDU/H5Vuf8=
github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY=
github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
github.com/go-openapi/swag/conv v0.24.0 h1:ejB9+7yogkWly6pnruRX45D1/6J+ZxRu92YFivx54ik=
github.com/go-openapi/swag/conv v0.24.0/go.mod h1:jbn140mZd7EW2g8a8Y5bwm8/Wy1slLySQQ0ND6DPc2c=
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/fileutils v0.24.0 h1:U9pCpqp4RUytnD689Ek/N1d2N/a//XCeqoH508H5oak=
github.com/go-openapi/swag/fileutils v0.24.0/go.mod h1:3SCrCSBHyP1/N+3oErQ1gP+OX1GV2QYFSnrTbzwli90=
github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU=
github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M=
github.com/go-openapi/swag/jsonname v0.24.0 h1:2wKS9bgRV/xB8c62Qg16w4AUiIrqqiniJFtZGi3dg5k=
github.com/go-openapi/swag/jsonname v0.24.0/go.mod h1:GXqrPzGJe611P7LG4QB9JKPtUZ7flE4DOVechNaDd7Q=
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.24.0 h1:F1vE1q4pg1xtO3HTyJYRmEuJ4jmIp2iZ30bzW5XgZts=
github.com/go-openapi/swag/jsonutils v0.24.0/go.mod h1:vBowZtF5Z4DDApIoxcIVfR8v0l9oq5PpYRUuteVu6f0=
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/loading v0.24.0 h1:ln/fWTwJp2Zkj5DdaX4JPiddFC5CHQpvaBKycOlceYc=
github.com/go-openapi/swag/loading v0.24.0/go.mod h1:gShCN4woKZYIxPxbfbyHgjXAhO61m88tmjy0lp/LkJk=
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/mangling v0.24.0 h1:PGOQpViCOUroIeak/Uj/sjGAq9LADS3mOyjznmHy2pk=
github.com/go-openapi/swag/mangling v0.24.0/go.mod h1:Jm5Go9LHkycsz0wfoaBDkdc4CkpuSnIEf62brzyCbhc=
github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync=
github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ=
github.com/go-openapi/swag/netutils v0.24.0 h1:Bz02HRjYv8046Ycg/w80q3g9QCWeIqTvlyOjQPDjD8w=
github.com/go-openapi/swag/netutils v0.24.0/go.mod h1:WRgiHcYTnx+IqfMCtu0hy9oOaPR0HnPbmArSRN1SkZM=
github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4=
github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE=
github.com/go-openapi/swag/stringutils v0.24.0 h1:i4Z/Jawf9EvXOLUbT97O0HbPUja18VdBxeadyAqS1FM=
github.com/go-openapi/swag/stringutils v0.24.0/go.mod h1:5nUXB4xA0kw2df5PRipZDslPJgJut+NjL7D25zPZ/4w=
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.24.0 h1:d3szEGzGDf4L2y1gYOSSLeK6h46F+zibnEas2Jm/wIw=
github.com/go-openapi/swag/typeutils v0.24.0/go.mod h1:q8C3Kmk/vh2VhpCLaoR2MVWOGP8y7Jc8l82qCTd1DYI=
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.24.0 h1:bhw4894A7Iw6ne+639hsBNRHg9iZg/ISrOVr+sJGp4c=
github.com/go-openapi/swag/yamlutils v0.24.0/go.mod h1:DpKv5aYuaGm/sULePoeiG8uwMpZSfReo1HR3Ik0yaG8=
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-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=
@@ -360,6 +408,8 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godoc-lint/godoc-lint v0.10.0 h1:OcyrziBi18sQSEpib6NesVHEJ/Xcng97NunePBA48g4=
github.com/godoc-lint/godoc-lint v0.10.0/go.mod h1:KleLcHu/CGSvkjUH2RvZyoK1MBC7pDQg4NxMYLcBBsw=
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@@ -417,19 +467,27 @@ github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4nUfo0=
github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ=
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw=
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E=
github.com/golangci/go-printf-func-name v0.1.0 h1:dVokQP+NMTO7jwO4bwsRwLWeudOVUPPyAKJuzv8pEJU=
github.com/golangci/go-printf-func-name v0.1.0/go.mod h1:wqhWFH5mUdJQhweRnldEywnR5021wTdZSNgwYceV14s=
github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U=
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 v1.64.8 h1:y5TdeVidMtBGG32zgSC7ZXTFNHrsJkDnpO4ItB3Am+I=
github.com/golangci/golangci-lint/v2 v2.4.0 h1:qz6O6vr7kVzXJqyvHjHSz5fA3D+PM8v96QU5gxZCNWM=
github.com/golangci/golangci-lint/v2 v2.4.0/go.mod h1:Oq7vuAf6L1iNL34uHDcsIF6Mnc0amOPdsT3/GlpHD+I=
github.com/golangci/golangci-lint/v2 v2.5.0 h1:BDRg4ASm4J1y/DSRY6zwJ5tr5Yy8ZqbZ79XrCeFxaQo=
github.com/golangci/golangci-lint/v2 v2.5.0/go.mod h1:IJtWJBZkLbx7AVrIUzLd8Oi3ADtwaNpWbR3wthVWHcc=
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=
github.com/golangci/misspell v0.7.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
github.com/golangci/nilerr v0.0.0-20250918000102-015671e622fe h1:F1pK9tBy41i7eesBFkSNMldwtiAaWiU+3fT/24sTnNI=
github.com/golangci/nilerr v0.0.0-20250918000102-015671e622fe/go.mod h1:CtTxAluxD2ng9aIT9bPrVoMuISFWCD+SaxtvYtdWA2k=
github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg=
github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
@@ -470,6 +528,8 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gordonklaus/ineffassign v0.1.0 h1:y2Gd/9I7MdY1oEIt+n+rowjBNDcLQq3RsH5hwJd0f9s=
github.com/gordonklaus/ineffassign v0.1.0/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0=
github.com/gordonklaus/ineffassign v0.2.0 h1:Uths4KnmwxNJNzq87fwQQDDnbNb7De00VOk9Nu0TySs=
github.com/gordonklaus/ineffassign v0.2.0/go.mod h1:TIpymnagPSexySzs7F9FnO1XFTy8IT3a59vmZp5Y9Lw=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gostaticanalysis/analysisutil v0.7.1 h1:ZMCjoue3DtDWQ5WyU16YbjbQEQ3VuzwxALrpYd+HeKk=
@@ -491,6 +551,12 @@ github.com/gtsteffaniak/go-logger v0.2.1 h1:NTim7RgpZ0+JAuep+2n1a7oHJbnyaksnWJSt
github.com/gtsteffaniak/go-logger v0.2.1/go.mod h1:U3ZkdAclcxXNMvoQLq+zDkqcTmdqhX3hTf+6AuTo6v0=
github.com/gtsteffaniak/go-logger v0.2.2 h1:5bEX4ndHK0CsHZjw76T+j0phKoaEGiDwC8erseF99yU=
github.com/gtsteffaniak/go-logger v0.2.2/go.mod h1:U3ZkdAclcxXNMvoQLq+zDkqcTmdqhX3hTf+6AuTo6v0=
github.com/gtsteffaniak/go-logger v0.2.3-0.20250926211917-4f1ceadcc169 h1:kW5TWd6IsIB3ypImyoPGWj+jzXaG3muQ1lXdJpj7KCc=
github.com/gtsteffaniak/go-logger v0.2.3-0.20250926211917-4f1ceadcc169/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
github.com/gtsteffaniak/go-logger v0.2.3-0.20250926212455-9b14f7183bd2 h1:gCyi3Whqu0rp8+HG/VEjjBi1vZvp+yBl4c8pHABeV0s=
github.com/gtsteffaniak/go-logger v0.2.3-0.20250926212455-9b14f7183bd2/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
github.com/gtsteffaniak/go-logger v0.2.3-0.20250926212932-e91a612f674c h1:JGf2H8yweS5xYO5kGF0/IDs4WalX3TMhLq4D2gDSQ/g=
github.com/gtsteffaniak/go-logger v0.2.3-0.20250926212932-e91a612f674c/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
github.com/hairyhenderson/go-codeowners v0.7.0 h1:s0W4wF8bdsBEjTWzwzSlsatSthWtTAF2xLgo4a4RwAo=
github.com/hairyhenderson/go-codeowners v0.7.0/go.mod h1:wUlNgQ3QjqC4z8DnM5nnCYVq/icpqXJyJOukKx5U8/Q=
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo=
@@ -557,6 +623,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kulti/thelper v0.6.3 h1:ElhKf+AlItIu+xGnI990no4cE2+XaSu1ULymV2Yulxs=
github.com/kulti/thelper v0.6.3/go.mod h1:DsqKShOvP40epevkFrvIwkCMNYxMeTNjdWL4dqWHZ6I=
github.com/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98=
github.com/kulti/thelper v0.7.1/go.mod h1:NsMjfQEy6sd+9Kfw8kCP61W1I0nerGSYSFnGaxQkcbs=
github.com/kunwardeep/paralleltest v1.0.14 h1:wAkMoMeGX/kGfhQBPODT/BL8XhK23ol/nuQ3SwFaUw8=
github.com/kunwardeep/paralleltest v1.0.14/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk=
github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U=
@@ -569,8 +637,12 @@ github.com/ldez/gomoddirectives v0.7.0 h1:EOx8Dd56BZYSez11LVgdj025lKwlP0/E5OLSl9
github.com/ldez/gomoddirectives v0.7.0/go.mod h1:wR4v8MN9J8kcwvrkzrx6sC9xe9Cp68gWYCsda5xvyGc=
github.com/ldez/grignotin v0.10.0 h1:NQPeh1E/Eza4F0exCeC1WkpnLvgUcQDT8MQ1vOLML0E=
github.com/ldez/grignotin v0.10.0/go.mod h1:oR4iCKUP9fwoeO6vCQeD7M5SMxCT6xdVas4vg0h1LaI=
github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o=
github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas=
github.com/ldez/tagliatelle v0.7.1 h1:bTgKjjc2sQcsgPiT902+aadvMjCeMHrY7ly2XKFORIk=
github.com/ldez/tagliatelle v0.7.1/go.mod h1:3zjxUpsNB2aEZScWiZTHrAXOl1x25t3cRmzfK1mlo2I=
github.com/ldez/tagliatelle v0.7.2 h1:KuOlL70/fu9paxuxbeqlicJnCspCRjH0x8FW+NfgYUk=
github.com/ldez/tagliatelle v0.7.2/go.mod h1:PtGgm163ZplJfZMZ2sf5nhUT170rSuPgBimoyYtdaSI=
github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc=
github.com/ldez/usetesting v0.5.0/go.mod h1:Spnb4Qppf8JTuRgblLrEWb7IE6rDmUpGvxY3iRrzvDQ=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
@@ -589,6 +661,8 @@ github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1r
github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLiGA7IsFeKrBkQPze1/iAeM95arc=
github.com/manuelarte/embeddedstructfieldcheck v0.3.0 h1:VhGqK8gANDvFYDxQkjPbv7/gDJtsGU9k6qj/hC2hgso=
github.com/manuelarte/embeddedstructfieldcheck v0.3.0/go.mod h1:LSo/IQpPfx1dXMcX4ibZCYA7Yy6ayZHIaOGM70+1Wy8=
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 h1:3mAIyaGRtjK6EO9E73JlXLtiy7ha80b2ZVGyacxgfww=
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=
@@ -611,6 +685,8 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0j
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgechev/revive v1.11.0 h1:b/gLLpBE427o+Xmd8G58gSA+KtBwxWinH/A565Awh0w=
github.com/mgechev/revive v1.11.0/go.mod h1:tI0oLF/2uj+InHCBLrrqfTKfjtFTBCFFfG05auyzgdw=
github.com/mgechev/revive v1.12.0 h1:Q+/kkbbwerrVYPv9d9efaPGmAO/NsxwW/nE6ahpQaCU=
github.com/mgechev/revive v1.12.0/go.mod h1:VXsY2LsTigk8XU9BpZauVLjVrhICMOV3k1lpB3CXrp8=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
@@ -642,6 +718,8 @@ github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm
github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
github.com/nunnatsa/ginkgolinter v0.20.0 h1:OmWLkAFO2HUTYcU6mprnKud1Ey5pVdiVNYGO5HVicx8=
github.com/nunnatsa/ginkgolinter v0.20.0/go.mod h1:dCIuFlTPfQerXgGUju3VygfAFPdC5aE1mdacCDKDJcQ=
github.com/nunnatsa/ginkgolinter v0.21.0 h1:IYwuX+ajy3G1MezlMLB1BENRtFj16+Evyi4uki1NOOQ=
github.com/nunnatsa/ginkgolinter v0.21.0/go.mod h1:QlzY9UP9zaqu58FjYxhp9bnjuwXwG1bfW5rid9ChNMw=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
@@ -656,6 +734,7 @@ github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
github.com/onsi/gomega v1.38.0 h1:c/WX+w8SLAinvuKKQFh77WEucCnPk4j2OTUr7lt7BeY=
github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
@@ -740,6 +819,8 @@ github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iM
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
github.com/securego/gosec/v2 v2.22.7 h1:8/9P+oTYI4yIpAzccQKVsg1/90Po+JzGtAhqoHImDeM=
github.com/securego/gosec/v2 v2.22.7/go.mod h1:510TFNDMrIPytokyHQAVLvPeDr41Yihn2ak8P+XQfNE=
github.com/securego/gosec/v2 v2.22.8 h1:3NMpmfXO8wAVFZPNsd3EscOTa32Jyo6FLLlW53bexMI=
github.com/securego/gosec/v2 v2.22.8/go.mod h1:ZAw8K2ikuH9qDlfdV87JmNghnVfKB1XC7+TVzk6Utto=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
@@ -762,12 +843,17 @@ github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.12.0 h1:CZ7eSOd3kZoaYDLbXnmzgQI5RlciuXBMA+18HwHRfZQ=
github.com/spf13/viper v1.12.0/go.mod h1:b6COn30jlNxbm/V2IqWiNWkJ+vZNiMNksliPCiuKtSI=
github.com/ssgreg/nlreturn/v2 v2.2.1 h1:X4XDI7jstt3ySqGU86YGAURbxw3oTDPK9sPEi6YEwQ0=
@@ -806,6 +892,8 @@ github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpR
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY=
github.com/tetafro/godot v1.5.1 h1:PZnjCol4+FqaEzvZg5+O8IY2P3hfY9JzRBNPv1pEDS4=
github.com/tetafro/godot v1.5.1/go.mod h1:cCdPtEndkmqqrhiCfkmxDodMQJ/f3L1BCNskCUZdTwk=
github.com/tetafro/godot v1.5.4 h1:u1ww+gqpRLiIA16yF2PV1CV1n/X3zhyezbNXC3E14Sg=
github.com/tetafro/godot v1.5.4/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 h1:9LPGD+jzxMlnk5r6+hJnar67cgpDIz/iyD+rfl5r2Vk=
@@ -857,12 +945,16 @@ go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ=
go-simpler.org/assert v0.9.0/go.mod h1:74Eqh5eI6vCK6Y5l3PI8ZYFXG4Sa+tkr70OIPJAUr28=
go-simpler.org/musttag v0.13.1 h1:lw2sJyu7S1X8lc8zWUAdH42y+afdcCnHhWpnkWvd6vU=
go-simpler.org/musttag v0.13.1/go.mod h1:8r450ehpMLQgvpb6sg+hV5Ur47eH6olp/3yEanfG97k=
go-simpler.org/musttag v0.14.0 h1:XGySZATqQYSEV3/YTy+iX+aofbZZllJaqwFWs+RTtSo=
go-simpler.org/musttag v0.14.0/go.mod h1:uP8EymctQjJ4Z1kUnjX0u2l60WfUdQxCwSNKzE1JEOE=
go-simpler.org/sloglint v0.11.1 h1:xRbPepLT/MHPTCA6TS/wNfZrDzkGvCCqUv4Bdwc3H7s=
go-simpler.org/sloglint v0.11.1/go.mod h1:2PowwiCOK8mjiF+0KGifVOT8ZsCNiFzvfyJeJOIt8MQ=
go.augendre.info/arangolint v0.2.0 h1:2NP/XudpPmfBhQKX4rMk+zDYIj//qbt4hfZmSSTcpj8=
go.augendre.info/arangolint v0.2.0/go.mod h1:Vx4KSJwu48tkE+8uxuf0cbBnAPgnt8O1KWiT7bljq7w=
go.augendre.info/fatcontext v0.8.0 h1:2dfk6CQbDGeu1YocF59Za5Pia7ULeAM6friJ3LP7lmk=
go.augendre.info/fatcontext v0.8.0/go.mod h1:oVJfMgwngMsHO+KB2MdgzcO+RvtNdiCEOlWvSFtax/s=
go.augendre.info/fatcontext v0.8.1 h1:/T4+cCjpL9g71gJpcFAgVo/K5VFpqlN+NPU7QXxD5+A=
go.augendre.info/fatcontext v0.8.1/go.mod h1:r3Qz4ZOzex66wfyyj5VZ1xUcl81vzvHQ6/GWzzlMEwA=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
@@ -879,6 +971,8 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@@ -906,6 +1000,8 @@ golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl
golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b h1:KdrhdYPDUvJTvrDK9gdjfFd6JTk8vA1WJoldYSi0kHo=
golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b/go.mod h1:LKZHyeOpPuZcMgxeHjJp4p5yvxrCX1xDvH10zYHhjjQ=
golang.org/x/exp/typeparams v0.0.0-20250911091902-df9299821621 h1:Yl4H5w2RV7L/dvSHp2GerziT5K2CORgFINPaMFxWGWw=
golang.org/x/exp/typeparams v0.0.0-20250911091902-df9299821621/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.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
@@ -1259,6 +1355,8 @@ honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
mvdan.cc/gofumpt v0.8.0 h1:nZUCeC2ViFaerTcYKstMmfysj6uhQrA2vJe+2vwGU6k=
mvdan.cc/gofumpt v0.8.0/go.mod h1:vEYnSzyGPmjvFkqJWtXkh79UwPWP9/HMxQdGEXZHjpg=
mvdan.cc/gofumpt v0.9.1 h1:p5YT2NfFWsYyTieYgwcQ8aKV3xRvFH4uuN/zB2gBbMQ=
mvdan.cc/gofumpt v0.9.1/go.mod h1:3xYtNemnKiXaTh6R4VtlqDATFwBbdXI8lJvH/4qk7mw=
mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 h1:WjUu4yQoT5BHT1w8Zu56SP8367OuBV5jvo+4Ulppyf8=
mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4/go.mod h1:rthT7OuvRbaGcd5ginj6dA2oLE7YNlta9qhBNNdCaLE=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
+5 -4
View File
@@ -46,10 +46,11 @@ func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
// Parse permissions from the query parameter
permissions := users.Permissions{
Api: strings.Contains(permissionsStr, "api") && d.user.Permissions.Api,
Admin: strings.Contains(permissionsStr, "admin") && d.user.Permissions.Admin,
Modify: strings.Contains(permissionsStr, "modify") && d.user.Permissions.Modify,
Share: strings.Contains(permissionsStr, "share") && d.user.Permissions.Share,
Api: strings.Contains(permissionsStr, "api") && d.user.Permissions.Api,
Admin: strings.Contains(permissionsStr, "admin") && d.user.Permissions.Admin,
Modify: strings.Contains(permissionsStr, "modify") && d.user.Permissions.Modify,
Share: strings.Contains(permissionsStr, "share") && d.user.Permissions.Share,
Realtime: strings.Contains(permissionsStr, "realtime") && d.user.Permissions.Realtime,
}
// Convert the duration string to an int64
-2
View File
@@ -63,7 +63,6 @@ func sseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
return http.StatusOK, nil
case <-clientGone:
logger.Debugf("client disconnected. user: %s, SessionId: %s", username, sessionId)
return http.StatusOK, nil
case msg := <-events.BroadcastChan:
@@ -73,7 +72,6 @@ func sseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
case msg, ok := <-sendChan:
if !ok {
logger.Debugf("SSE channel closed for user: %s, SessionId: %s", username, sessionId)
return http.StatusOK, nil
}
if err := msgr.sendEvent(msg.EventType, msg.Message); err != nil {
+4 -3
View File
@@ -107,7 +107,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
api.HandleFunc("PUT /resources", withUser(resourcePutHandler))
api.HandleFunc("PATCH /resources", withUser(resourcePatchHandler))
api.HandleFunc("GET /raw", withUser(rawHandler))
api.HandleFunc("GET /preview", withUser(previewHandler))
api.HandleFunc("GET /preview", withTimeout(60*time.Second, withUserHelper(previewHandler)))
api.HandleFunc("GET /media/subtitles", withUser(subtitlesHandler))
if version.Version == "testing" || version.Version == "untracked" {
api.HandleFunc("GET /inspectIndex", inspectIndex)
@@ -133,12 +133,13 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
// NEW PUBLIC ROUTES - All publicly accessible endpoints
// Public API routes (hash-based authentication)
publicAPI.HandleFunc("GET /raw", withHashFile(publicRawHandler))
publicAPI.HandleFunc("GET /preview", withHashFile(publicPreviewHandler))
publicAPI.HandleFunc("GET /resources", withHashFile(publicShareHandler))
publicAPI.HandleFunc("GET /preview", withTimeout(60*time.Second, withHashFileHelper(publicPreviewHandler)))
publicAPI.HandleFunc("GET /resources", withHashFile(publicGetResourceHandler))
publicAPI.HandleFunc("GET /users", withUser(userGetHandler))
publicAPI.HandleFunc("POST /onlyoffice/callback", withHashFile(onlyofficeCallbackHandler))
publicAPI.HandleFunc("GET /onlyoffice/callback", withHashFile(onlyofficeCallbackHandler))
publicAPI.HandleFunc("GET /onlyoffice/config", withHashFile(onlyofficeClientConfigGetHandler))
publicAPI.HandleFunc("POST /resources", withHashFile(publicUploadHandler))
// Settings routes
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
File renamed without changes.
+60 -7
View File
@@ -65,11 +65,19 @@ func withHashFileHelper(fn handleFunc) handleFunc {
if link.DisableAnonymous && data.user.Username == "anonymous" {
return http.StatusForbidden, fmt.Errorf("share is not available to anonymous users")
}
// Block anonymous users if per-user download limit is enabled
if link.PerUserDownloadLimit && data.user.Username == "anonymous" {
return http.StatusForbidden, fmt.Errorf("anonymous downloads are not allowed with per-user limits")
}
if len(link.AllowedUsernames) > 0 {
if !slices.Contains(link.AllowedUsernames, data.user.Username) {
return http.StatusForbidden, fmt.Errorf("share is not available to this user")
}
}
// Check per-user download limit
if link.PerUserDownloadLimit && link.HasReachedUserLimit(data.user.Username) {
return http.StatusForbidden, fmt.Errorf("user download limit reached for this share")
}
data.share = link
// Authenticate the share request if needed
var status int
@@ -92,13 +100,14 @@ func withHashFileHelper(fn handleFunc) handleFunc {
if link.DisableFileViewer || reachedDownloadsLimit {
getContent = false
}
file, err := FileInfoFasterFunc(iteminfo.FileOptions{
Path: utils.JoinPathAsUnix(link.Path, path),
Source: link.Source,
Modify: false,
Expand: true,
Content: getContent,
})
file, err := FileInfoFasterFunc(utils.FileOptions{
Path: utils.JoinPathAsUnix(link.Path, path),
Source: link.Source,
Modify: false,
Expand: true,
Content: getContent,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
}, nil)
if err != nil {
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, path, err)
return errToStatus(err), fmt.Errorf("error fetching share from server")
@@ -113,6 +122,10 @@ func withHashFileHelper(fn handleFunc) handleFunc {
link.Mu.Lock()
link.Downloads++
link.Mu.Unlock()
// Track per-user download if enabled
if link.PerUserDownloadLimit {
link.IncrementUserDownload(data.user.Username)
}
}
file.Path = "/" + strings.TrimPrefix(strings.TrimPrefix(file.Path, link.Path), "/")
// Set the file info in the `data` object
@@ -487,6 +500,46 @@ func withSelfOrAdmin(fn handleFunc) http.HandlerFunc {
return wrapHandler(withSelfOrAdminHelper(fn))
}
// withTimeoutHelper adds a configurable timeout context to any operation
func withTimeoutHelper(timeout time.Duration, fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
// Create a context with the specified timeout
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
// Log timeout warning at 80% of timeout duration
warningTime := time.Duration(float64(timeout) * 0.8)
go func() {
select {
case <-time.After(warningTime):
if ctx.Err() == nil {
logger.Api(http.StatusRequestTimeout, fmt.Sprintf("Request approaching timeout (%.1fs/%.0fs): %s %s", warningTime.Seconds(), timeout.Seconds(), r.Method, r.URL.Path))
}
case <-ctx.Done():
// Context finished before warning time
return
}
}()
// Replace the request context with the timeout context
r = r.WithContext(ctx)
data.ctx = ctx
// Call the handler and check for timeout
status, err := fn(w, r, data)
// Check if the context was cancelled due to timeout
if ctx.Err() == context.DeadlineExceeded {
return http.StatusRequestTimeout, fmt.Errorf("request timed out after %.0f seconds", timeout.Seconds())
}
return status, err
}
}
func withTimeout(timeout time.Duration, fn handleFunc) http.HandlerFunc {
return wrapHandler(withTimeoutHelper(timeout, fn))
}
func muxWithMiddleware(mux *http.ServeMux) *http.ServeMux {
wrappedMux := http.NewServeMux()
wrappedMux.Handle("/", LoggingMiddleware(mux))
+5 -4
View File
@@ -10,6 +10,7 @@ import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"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/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
@@ -27,8 +28,8 @@ func setupTestEnv(t *testing.T) {
t.Fatal(err)
}
config = &settings.Config // mocked
config.Server.SourceMap = map[string]settings.Source{
"/srv": settings.Source{
config.Server.SourceMap = map[string]*settings.Source{
"/srv": &settings.Source{
Path: "/srv",
Name: "srv",
},
@@ -43,7 +44,7 @@ func mockFileInfoFaster(t *testing.T) {
t.Cleanup(func() { FileInfoFasterFunc = originalFileInfoFaster })
// Mock the function to skip execution
FileInfoFasterFunc = func(opts iteminfo.FileOptions) (*iteminfo.ExtendedFileInfo, error) {
FileInfoFasterFunc = func(opts utils.FileOptions, access *access.Storage) (*iteminfo.ExtendedFileInfo, error) {
return &iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
Path: opts.Path,
@@ -223,7 +224,7 @@ func TestPublicShareHandlerAuthentication(t *testing.T) {
recorder := httptest.NewRecorder()
// Wrap the handler with authentication middleware
handler := withHashFileHelper(publicShareHandler)
handler := withHashFileHelper(publicGetResourceHandler)
if err := store.Settings.Save(&settings.Settings{
Auth: settings.Auth{
Key: "key",
+5 -5
View File
@@ -62,7 +62,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
return http.StatusBadRequest, errors.New("missing required parameters: path + source/hash are required")
}
themeMode := utils.Ternary(d.user.DarkMode, "dark", "light")
var sourceInfo settings.Source
var sourceInfo *settings.Source
var ok bool
if d.fileInfo.Hash != "" {
sourceInfo, ok = settings.Config.Server.SourceMap[source]
@@ -87,12 +87,12 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
}
indexPath := utils.JoinPathAsUnix(userScope, path)
logger.Debugf("OnlyOffice user request: resolved path=%s", indexPath)
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Path: indexPath,
Modify: d.user.Permissions.Modify,
Source: source,
Expand: false,
})
}, store.Access)
if err != nil {
logger.Errorf("OnlyOffice: failed to get file info for source=%s, path=%s: %v", source, indexPath, err)
return errToStatus(err), err
@@ -258,7 +258,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
logger.Errorf("OnlyOffice callback missing required parameters: source=%s, path=%s", source, path)
return http.StatusBadRequest, errors.New("missing required parameters: path + source/hash are required")
}
var sourceInfo settings.Source
var sourceInfo *settings.Source
var ok bool
if d.fileInfo.Hash != "" {
sourceInfo, ok = settings.Config.Server.SourceMap[source]
@@ -332,7 +332,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
}
// Write the updated document
fileOpts := iteminfo.FileOptions{
fileOpts := utils.FileOptions{
Path: resolvedPath,
Source: source,
}
+107 -43
View File
@@ -3,6 +3,7 @@ package http
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"net/url"
@@ -10,6 +11,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
@@ -26,6 +28,17 @@ type FileCache interface {
Delete(ctx context.Context, key string) error
}
// isClientCancellation checks if an error is due to client cancellation (navigation away)
func isClientCancellation(ctx context.Context, err error) bool {
// Check context state first
if ctx.Err() == context.Canceled {
return true
}
// Check if the error chain contains context cancellation
return errors.Is(err, context.Canceled)
}
// previewHandler handles the preview request for images.
// @Summary Get image preview
// @Description Returns a preview image based on the requested path and size.
@@ -61,13 +74,12 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
if err != nil {
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Access: store.Access,
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Username: d.user.Username,
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Content: true,
})
Metadata: true,
}, store.Access)
if err != nil {
return errToStatus(err), err
}
@@ -94,6 +106,69 @@ func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.Extend
return 0, nil
}
// getDirectoryPreview finds a valid preview file within a directory.
// It iterates through files, checking if they should bubble up to folder previews,
// and returns the first valid, non-corrupted file suitable for preview.
func getDirectoryPreview(r *http.Request, d *requestContext) (*iteminfo.ExtendedFileInfo, error) {
var lastErr error
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) {
continue
}
source := d.fileInfo.Source
path := utils.JoinPathAsUnix(d.fileInfo.Path, item.Name)
if d.share != nil {
// Get the actual source name from the share's source mapping
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
if !ok {
return nil, fmt.Errorf("source not found for share")
}
source = sourceInfo.Name
path = utils.JoinPathAsUnix(d.share.Path, path)
}
fileInfo, err := files.FileInfoFaster(
utils.FileOptions{
Username: d.user.Username,
Path: path,
Source: source,
Metadata: true,
}, store.Access)
if err != nil {
lastErr = err
continue // Try next file if this one fails
}
// Try to generate preview to verify the file is not corrupted
tempCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
_, previewErr := preview.GetPreviewForFile(tempCtx, *fileInfo, "small", "", 0)
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)
lastErr = previewErr
continue
}
// Success! Use this file for the directory preview
return fileInfo, nil
}
// If we exhausted all files and none worked, return the last error
if lastErr != nil {
return nil, fmt.Errorf("no valid preview files found in directory: %w", lastErr)
}
return nil, fmt.Errorf("no previewable files found in directory")
}
func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
previewSize := r.URL.Query().Get("size")
if !(previewSize == "large" || previewSize == "original") {
@@ -102,46 +177,13 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
if !d.fileInfo.HasPreview {
return http.StatusBadRequest, fmt.Errorf("this item does not have a preview")
}
var childMD5 string
if d.fileInfo.Type == "directory" {
// get extended file info of first previewable item in directory
for _, item := range d.fileInfo.Files {
if item.HasPreview {
source := d.fileInfo.Source
path := utils.JoinPathAsUnix(d.fileInfo.Path, item.Name)
if d.share != nil {
// Get the actual source name from the share's source mapping
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
if !ok {
return http.StatusInternalServerError, fmt.Errorf("source not found for share")
}
source = sourceInfo.Name
path = utils.JoinPathAsUnix(d.share.Path, path)
}
fileInfo, err := files.FileInfoFaster(
iteminfo.FileOptions{
Path: path,
Source: source,
Content: true,
})
if err != nil {
return http.StatusInternalServerError, err
}
// Calculate child's MD5 for cache sharing
childMD5, err = utils.GetChecksum(fileInfo.RealPath, "md5")
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to get child file checksum: %w", err)
}
// Validate child MD5 is not empty
if childMD5 == "" {
errorMsg := fmt.Sprintf("Child MD5 is empty for file: %s (path: %s)", fileInfo.Name, fileInfo.RealPath)
logger.Errorf("Preview generation failed: %s", errorMsg)
return http.StatusInternalServerError, fmt.Errorf("preview generation failed: %s", errorMsg)
}
d.fileInfo = *fileInfo
break
}
// Get extended file info of first previewable item in directory
fileInfo, err := getDirectoryPreview(r, d)
if err != nil {
return http.StatusInternalServerError, err
}
d.fileInfo = *fileInfo
}
setContentDisposition(w, r, d.fileInfo.Name)
@@ -179,8 +221,30 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
officeUrl = scheme + "://" + r.Host + pathUrl
}
}
previewImg, err := preview.GetPreviewForFileWithChildMD5(d.fileInfo, previewSize, officeUrl, seekPercentage, childMD5)
// Use the context from the request context (which includes timeout)
ctx := r.Context()
if d.ctx != nil {
ctx = d.ctx
}
previewImg, err := preview.GetPreviewForFile(ctx, d.fileInfo, previewSize, officeUrl, seekPercentage)
if err != nil {
// Check if it was a context cancellation (client navigated away)
if isClientCancellation(ctx, err) {
// Return 200 to avoid error logging - client cancellation is normal
return http.StatusOK, nil
}
// Check if it was a context timeout (server-side timeout)
if ctx.Err() == context.DeadlineExceeded || errors.Is(err, context.DeadlineExceeded) {
logger.Errorf("Preview timeout for file '%s' after 60 seconds", d.fileInfo.Name)
return http.StatusRequestTimeout, fmt.Errorf("preview generation timed out after 60 seconds")
}
// Log detailed error information for actual server errors
logger.Errorf("Preview generation failed for file '%s' (type: %s, size: %s, seek: %d%%): %v",
d.fileInfo.Name, d.fileInfo.Type, previewSize, seekPercentage, err)
return http.StatusInternalServerError, err
}
w.Header().Set("Cache-Control", "private")
+131 -27
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/url"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
@@ -14,28 +15,53 @@ import (
_ "github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
)
// rawHandler serves the raw content of a file, multiple files, or directory in various formats.
// @Summary Get raw content of a file, multiple files, or directory
// @Description Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.
// @Tags Resources
// publicRawHandler serves the raw content of a file, multiple files, or directory via a public share.
// @Summary Download files from a public share
// @Description Downloads raw content from a public share. Supports single files, multiple files, or directories as archives. Enforces download limits (global or per-user) and blocks anonymous users when per-user limits are enabled.
// @Tags Public Shares
// @Accept json
// @Produce json
// @Param files query string false "if specified, only the files in the list will be downloaded. eg. files=/file1||/folder/file2"
// @Produce octet-stream
// @Param hash query string true "Share hash for authentication"
// @Param files query string true "Files to download in format: 'source::path||source::path'. Example: '/file1||/folder/file2'"
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
// @Failure 202 {object} map[string]string "Modify permissions required"
// @Failure 400 {object} map[string]string "Invalid request path"
// @Failure 404 {object} map[string]string "File or directory not found"
// @Failure 400 {object} map[string]string "Invalid request path or encoding"
// @Failure 403 {object} map[string]string "Download limit reached, anonymous access blocked, or share unavailable"
// @Failure 404 {object} map[string]string "Share not found or file not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /public/dl [get]
// @Failure 501 {object} map[string]string "Downloads disabled for upload shares"
// @Router /public/api/raw [get]
func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if d.share.DownloadsLimit > 0 && d.share.Downloads >= d.share.DownloadsLimit {
if d.share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("downloads are disabled for upload shares")
}
// Check global download limit (if not using per-user limits)
if !d.share.PerUserDownloadLimit && d.share.DownloadsLimit > 0 && d.share.Downloads >= d.share.DownloadsLimit {
return http.StatusForbidden, fmt.Errorf("share downloads limit reached")
}
// Check per-user download limit
if d.share.PerUserDownloadLimit {
// Block anonymous users
if d.user.Username == "anonymous" {
return http.StatusForbidden, fmt.Errorf("anonymous downloads are not allowed with per-user limits")
}
// Check if user has reached their limit
if d.share.HasReachedUserLimit(d.user.Username) {
return http.StatusForbidden, fmt.Errorf("user download limit reached for this share")
}
}
d.share.Mu.Lock()
d.share.Downloads++
d.share.Mu.Unlock()
// Track per-user download if enabled
if d.share.PerUserDownloadLimit {
d.share.IncrementUserDownload(d.user.Username)
}
encodedFiles := r.URL.Query().Get("files")
// Decode the URL-encoded path - use PathUnescape to preserve + as literal character
@@ -83,10 +109,68 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return status, nil
}
func publicShareHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// publicShareHandler returns file or directory information from a public share.
// @Summary Get file/directory information from a public share
// @Description Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.
// @Tags Public Shares
// @Accept json
// @Produce json
// @Param hash query string true "Share hash for authentication"
// @Param path query string false "Path within the share to retrieve information for. Defaults to share root."
// @Success 200 {object} iteminfo.FileInfo "File or directory metadata"
// @Failure 403 {object} map[string]string "Share unavailable or access denied"
// @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]
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")
}
return renderJSON(w, r, d.fileInfo)
}
// publicUploadHandler processes file uploads to a public upload share.
// @Summary Upload files to a public upload share
// @Description Handles file and directory uploads to an upload-only public share. Supports chunked uploads, conflict resolution (override), and directory creation.
// @Tags Public Shares
// @Accept multipart/form-data
// @Produce json
// @Param hash query string true "Share hash for authentication"
// @Param targetPath query string true "Target path within the share to upload to. Must be relative to share root."
// @Param override query bool false "If true, overwrite existing files/folders. Defaults to false."
// @Param action query string false "Upload action: 'override' to replace files, 'rename' to auto-rename"
// @Param file formData file true "File to upload"
// @Success 200 {object} map[string]string "Upload successful"
// @Failure 400 {object} map[string]string "Invalid request or parameters"
// @Failure 403 {object} map[string]string "Share unavailable or upload not allowed"
// @Failure 404 {object} map[string]string "Share not found"
// @Failure 409 {object} map[string]string "File or directory already exists (conflict)"
// @Failure 500 {object} map[string]string "Internal server error during upload"
// @Failure 501 {object} map[string]string "Uploading disabled for non-upload shares"
// @Router /public/api/resources [post]
func publicUploadHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if d.share.ShareType != "upload" {
return http.StatusNotImplemented, fmt.Errorf("uploading is disabled for non-upload shares")
}
fullPath := filepath.Join(d.share.Path, r.URL.Query().Get("targetPath"))
source := config.Server.SourceMap[d.share.Source].Name
logger.Infof("public upload handler: fullPath: '%v' for share: '%v' with source: '%v'", fullPath, d.share.Source, source)
// adjust query params to match resourcePostHandler
q := r.URL.Query()
q.Set("source", source)
q.Set("path", fullPath)
r.URL.RawQuery = q.Encode()
status, err := resourcePostHandler(w, r, d)
if err != nil {
logger.Errorf("public upload handler: error uploading: '%v' with error %v", d.fileInfo, err)
return http.StatusInternalServerError, fmt.Errorf("upload failure occured on backend")
}
return status, err
}
// health godoc
// @Summary Health Check
// @Schemes
@@ -105,25 +189,45 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
}
}
// publicPreviewHandler handles the preview request for images from shares.
// @Summary Get image preview
// @Description Returns a preview image based on the requested path and size.
// @Tags Resources
// publicPreviewHandler handles the preview request for images from public shares.
// @Summary Get image/video preview from a public share
// @Description Returns a preview (thumbnail) for images or videos accessible via a public share. Preview generation can be disabled globally or per-share. Not available for upload-only shares.
// @Tags Public Shares
// @Accept json
// @Produce json
// @Param hash query string true "source hash"
// @Param path query string true "File path of the image to preview"
// @Param size query string false "Preview size ('small' or 'large'). Default is based on server config."
// @Success 200 {file} file "Preview image content"
// @Failure 202 {object} map[string]string "Download permissions required"
// @Failure 400 {object} map[string]string "Invalid request path"
// @Failure 404 {object} map[string]string "File not found"
// @Failure 415 {object} map[string]string "Unsupported file type for preview"
// @Produce image/jpeg
// @Param hash query string true "Share hash for authentication"
// @Param path query string true "File path within the share to preview"
// @Param size query string false "Preview size: 'small' or 'large'. Default is based on server config."
// @Success 200 {file} file "Preview image content (JPEG)"
// @Failure 403 {object} map[string]string "Share unavailable or access denied"
// @Failure 404 {object} map[string]string "File not found or preview not available"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/public/preview [get]
// @Failure 501 {object} map[string]string "Previews disabled globally, for this share, or for upload shares"
// @Router /public/api/preview [get]
func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if config.Server.DisablePreviews || d.share.DisableThumbnails {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
}
return previewHelperFunc(w, r, d)
if d.share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled for upload shares")
}
// Restore source name from share for preview generation
// The middleware clears file.Source for security, but we need it for index lookups
if d.fileInfo.Source == "" && d.share != nil {
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
if !ok {
// Don't expose internal errors to share users
return http.StatusNotFound, fmt.Errorf("resource not available")
}
d.fileInfo.Source = sourceInfo.Name
}
status, err := previewHelperFunc(w, r, d)
if err != nil {
// Obfuscate errors for shares to prevent information leakage
return http.StatusNotFound, fmt.Errorf("preview not available for this item")
}
return status, err
}
+3 -5
View File
@@ -19,7 +19,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/time/rate"
)
@@ -111,13 +110,12 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
if idx == nil {
return fmt.Errorf("source %s is not available", source)
}
_, err = files.FileInfoFaster(iteminfo.FileOptions{
Access: store.Access,
_, err = files.FileInfoFaster(utils.FileOptions{
Username: d.user.Username,
Path: path,
Source: source,
Expand: false,
})
}, store.Access)
if err != nil {
return err
}
@@ -161,7 +159,7 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
if tarWriter != nil {
header := &tar.Header{
Name: relPath + "/",
Mode: fileutils.PermDir,
Mode: int64(fileutils.PermDir),
Typeflag: tar.TypeDir,
ModTime: fileInfo.ModTime(),
}
+75 -37
View File
@@ -84,20 +84,21 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
if err != nil {
return http.StatusForbidden, err
}
userscope = strings.TrimRight(userscope, "/")
scopePath := utils.JoinPathAsUnix(userscope, path)
getContent := r.URL.Query().Get("content") == "true"
if d.share != nil && d.share.DisableFileViewer {
getContent = false
}
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Access: store.Access,
Username: d.user.Username,
Path: scopePath,
Modify: d.user.Permissions.Modify,
Source: source,
Expand: true,
Content: getContent,
})
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Username: d.user.Username,
Path: scopePath,
Modify: d.user.Permissions.Modify,
Source: source,
Expand: true,
Content: getContent,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
}, store.Access)
if err != nil {
return errToStatus(err), err
}
@@ -163,14 +164,14 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
if err != nil {
return http.StatusForbidden, err
}
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Access: store.Access,
userscope = strings.TrimRight(userscope, "/")
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Username: d.user.Username,
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Permissions.Modify,
Expand: false,
})
}, store.Access)
if err != nil {
return errToStatus(err), err
}
@@ -216,17 +217,28 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
logger.Debugf("invalid path encoding: %v", err)
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
if !d.user.Permissions.Modify {
shareUpload := false
if d.share != nil {
if d.share.ShareType == "upload" {
shareUpload = true
}
}
if !d.user.Permissions.Modify && !shareUpload {
logger.Debugf("user is not allowed to create or modify")
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
// Determine if this is a directory or file based on trailing slash
isDir := strings.HasSuffix(path, "/")
// Strip trailing slash from userscope to prevent double slashes
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
logger.Debugf("error getting scope from source name: %v", err)
return http.StatusForbidden, err
}
fileOpts := iteminfo.FileOptions{
Access: store.Access,
userscope = strings.TrimRight(userscope, "/")
fileOpts := utils.FileOptions{
Username: d.user.Username,
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
@@ -239,7 +251,11 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
realPath, _, _ := idx.GetRealPath(userscope, path)
isDir := strings.HasSuffix(path, "/")
// Check access control for the target path
if store.Access != nil && !store.Access.Permitted(idx.Path, path, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
}
// Check for file/folder conflicts before creation
if stat, statErr := os.Stat(realPath); statErr == nil {
@@ -249,7 +265,6 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// If type mismatch (file vs folder or folder vs file) and not overriding
if existingIsDir != requestingDir && r.URL.Query().Get("override") != "true" {
logger.Debugf("Type conflict detected: existing is dir=%v, requesting dir=%v at path=%v", existingIsDir, requestingDir, realPath)
return http.StatusConflict, nil
}
}
@@ -296,7 +311,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
var fileInfo *iteminfo.ExtendedFileInfo
fileInfo, err = files.FileInfoFaster(fileOpts)
fileInfo, err = files.FileInfoFaster(fileOpts, store.Access)
if err == nil { // File exists
if r.URL.Query().Get("override") != "true" {
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
@@ -321,7 +336,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
// Create or open the temporary file
var outFile *os.File
outFile, err = os.OpenFile(tempFilePath, os.O_CREATE|os.O_WRONLY, 0644)
outFile, err = os.OpenFile(tempFilePath, os.O_CREATE|os.O_WRONLY, fileutils.PermFile)
if err != nil {
logger.Debugf("could not open temp file: %v", err)
return http.StatusInternalServerError, fmt.Errorf("could not open temp file: %v", err)
@@ -358,9 +373,6 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return http.StatusOK, nil
}
logger.Debugf("non-chunked upload logic for %s", realPath)
// Non-chunked upload logic (original code)
// Check for file/folder conflicts for non-chunked uploads
if stat, statErr := os.Stat(realPath); statErr == nil {
existingIsDir := stat.IsDir()
@@ -368,15 +380,13 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// If type mismatch (existing dir vs requesting file) and not overriding
if existingIsDir != requestingDir && r.URL.Query().Get("override") != "true" {
logger.Debugf("Type conflict detected in non-chunked: existing is dir=%v, requesting dir=%v at path=%v", existingIsDir, requestingDir, realPath)
return http.StatusConflict, nil
}
}
fileInfo, err := files.FileInfoFaster(fileOpts)
fileInfo, err := files.FileInfoFaster(fileOpts, store.Access)
if err == nil {
if r.URL.Query().Get("override") != "true" {
logger.Debugf("Resource already exists: %v", fileInfo.RealPath)
return http.StatusConflict, nil
}
@@ -427,18 +437,31 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
if strings.HasSuffix(path, "/") {
return http.StatusMethodNotAllowed, nil
}
// Strip trailing slash from userscope to prevent double slashes
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
if err != nil {
return http.StatusForbidden, err
}
fileOpts := iteminfo.FileOptions{
Access: store.Access,
userscope = strings.TrimRight(userscope, "/")
fileOpts := utils.FileOptions{
Username: d.user.Username,
Path: utils.JoinPathAsUnix(userscope, path),
Source: source,
Modify: d.user.Permissions.Modify,
Expand: false,
}
// Check access control for the target path
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
if store.Access != nil && !store.Access.Permitted(idx.Path, path, d.user.Username) {
logger.Debugf("user %s denied access to path %s", d.user.Username, path)
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
}
err = files.WriteFile(fileOpts, r.Body)
return errToStatus(err), err
}
@@ -500,10 +523,13 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
if err != nil {
return http.StatusForbidden, err
}
userscopeDst = strings.TrimRight(userscopeDst, "/")
userscopeSrc, err := settings.GetScopeFromSourceName(d.user.Scopes, srcIndex)
if err != nil {
return http.StatusForbidden, err
}
userscopeSrc = strings.TrimRight(userscopeSrc, "/")
idx := indexing.GetIndex(dstIndex)
if idx == nil {
@@ -526,6 +552,18 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
if err != nil {
return http.StatusNotFound, err
}
// Check access control for both source and destination paths
if store.Access != nil {
if !store.Access.Permitted(idx2.Path, src, d.user.Username) {
logger.Debugf("user %s denied access to source path %s", d.user.Username, src)
return http.StatusForbidden, fmt.Errorf("access denied to source path %s", src)
}
if !store.Access.Permitted(idx.Path, dst, d.user.Username) {
logger.Debugf("user %s denied access to destination path %s", d.user.Username, dst)
return http.StatusForbidden, fmt.Errorf("access denied to destination path %s", dst)
}
}
overwrite := r.URL.Query().Get("overwrite") == "true"
rename := r.URL.Query().Get("rename") == "true"
if rename {
@@ -594,16 +632,16 @@ func patchAction(ctx context.Context, params patchActionParams) error {
case "rename", "move":
idx := indexing.GetIndex(params.srcIndex)
srcPath := idx.MakeIndexPath(params.src)
fileInfo, err := files.FileInfoFaster(iteminfo.FileOptions{
Access: store.Access,
Username: params.d.user.Username,
Path: srcPath,
Source: params.srcIndex,
IsDir: params.isSrcDir,
Modify: params.d.user.Permissions.Modify,
Expand: false,
ReadHeader: false,
})
if !params.isSrcDir {
srcPath = strings.TrimSuffix(srcPath, "/")
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Username: params.d.user.Username,
Path: srcPath,
Source: params.srcIndex,
IsDir: params.isSrcDir,
Modify: params.d.user.Permissions.Modify,
}, store.Access)
if err != nil {
return err
+1 -2
View File
@@ -60,7 +60,7 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
query := r.URL.Query().Get("query")
source := r.URL.Query().Get("source")
scope := r.URL.Query().Get("scope")
unencodedScope, err := url.QueryUnescape(scope)
unencodedScope, err := url.PathUnescape(scope)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
}
@@ -79,7 +79,6 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
return http.StatusForbidden, err
}
combinedPath := index.MakeIndexPath(filepath.Join(userscope, searchScope))
combinedPath = strings.TrimSuffix(combinedPath, "/") + "/" // Ensure trailing slash
// Perform the search using the provided query and user scope
response := index.Search(query, combinedPath, sessionId)
for i := range response {
+2
View File
@@ -55,8 +55,10 @@ func settingsConfigHandler(w http.ResponseWriter, r *http.Request, d *requestCon
showFull := fullParam == "true"
showComments := commentsParam == "true"
var err error
var yamlConfig string
if config.Server.EmbeddedFs {
embeddedYaml, readErr := assets.ReadFile("embed/config.generated.yaml")
if readErr != nil {
+19 -20
View File
@@ -212,6 +212,9 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
stringHash = string(hash)
}
if s != nil {
// Check if downloads limit or per-user limit changed - reset counts if so
shouldResetCounts := s.DownloadsLimit != body.DownloadsLimit || s.PerUserDownloadLimit != body.PerUserDownloadLimit
s.Expire = expire
s.PasswordHash = stringHash
s.Token = token
@@ -221,6 +224,12 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
body.Path = s.Path
body.Source = s.Source
s.CommonShare = body.CommonShare
// Reset download counts if limit settings changed
if shouldResetCounts {
s.ResetDownloadCounts()
}
if err = store.Share.Save(s); err != nil {
return http.StatusInternalServerError, err
}
@@ -257,7 +266,12 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if idx == nil {
return http.StatusForbidden, fmt.Errorf("source with name not found: %s", body.Source)
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source.Name)
if err != nil {
return http.StatusForbidden, err
}
scopePath := utils.JoinPathAsUnix(userscope, body.Path)
body.Path = scopePath
// validate path exists as file or folder
_, exists := idx.GetReducedMetadata(body.Path, true) // true to check if it exists
if !exists {
@@ -267,13 +281,6 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusForbidden, fmt.Errorf("path not found: %s", body.Path)
}
}
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source.Name)
if err != nil {
return http.StatusForbidden, err
}
scopePath := utils.JoinPathAsUnix(userscope, body.Path)
body.Path = scopePath
body.Source = source.Path // backend source is path
s = &share.Link{
Expire: expire,
@@ -283,22 +290,14 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
Token: token,
CommonShare: body.CommonShare,
}
if err = store.Share.Save(s); err != nil {
return http.StatusInternalServerError, err
}
// Convert to ShareResponse format with username
var user *users.User
user, err = store.Users.Get(s.UserID)
username := ""
if err == nil {
username = user.Username
sharesWithUsernames, err := convertToShareResponse(r, []*share.Link{s})
if err != nil {
return http.StatusInternalServerError, err
}
response := &ShareResponse{
Link: s,
Username: username,
}
return renderJSON(w, r, response)
return renderJSON(w, r, sharesWithUsernames[0])
}
// DirectDownloadResponse represents the response for direct download endpoint
+4 -4
View File
@@ -51,10 +51,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
}
defaultThemeColor := "#455a64"
staticURL := config.Server.BaseURL + "static"
if d.share != nil {
staticURL = config.Server.BaseURL + "public/static"
}
staticURL := config.Server.BaseURL + "public/static"
// Use custom favicon if configured and validated, otherwise fall back to default
var favicon string
@@ -106,6 +103,9 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
shareProps["viewMode"] = d.share.ViewMode
shareProps["enableOnlyOffice"] = d.share.EnableOnlyOffice
shareProps["enableOnlyOfficeEditing"] = d.share.EnableOnlyOfficeEditing
shareProps["shareType"] = utils.Ternary(d.share.ShareType == "", "normal", d.share.ShareType)
shareProps["perUserDownloadLimit"] = d.share.PerUserDownloadLimit
shareProps["extractEmbeddedSubtitles"] = d.share.ExtractEmbeddedSubtitles
// Additional computed properties from extended.go
shareProps["hasPassword"] = d.share.HasPassword()
+81 -71
View File
@@ -25,8 +25,8 @@ var (
// actionConfig holds all configuration options for indexing operations
type actionConfig struct {
Quick bool // whether to perform a quick scan (skip unchanged directories)
Recursive bool // whether to recursively index subdirectories
Quick bool // whether to perform a quick scan (skip unchanged directories)
Recursive bool // whether to recursively index subdirectories
ForceCheck bool // whether to check indexing skip rules.
}
@@ -84,17 +84,21 @@ const (
)
// omitList contains directory names to skip during indexing
var omitList = []string{"$RECYCLE.BIN", "System Volume Information", "@eaDir"}
var omitList = map[string]bool{
"$RECYCLE.BIN": true,
"System Volume Information": true,
"@eaDir": true,
}
func init() {
indexes = make(map[string]*Index)
}
func Initialize(source settings.Source, mock bool) {
func Initialize(source *settings.Source, mock bool) {
indexesMutex.Lock()
newIndex := Index{
mock: mock,
Source: source,
Source: *source,
Directories: make(map[string]*iteminfo.FileInfo),
DirectoriesLedger: make(map[string]struct{}),
processedInodes: make(map[uint64]struct{}),
@@ -125,6 +129,10 @@ func (idx *Index) indexDirectoryWithOptions(adjustedPath string, config *actionC
// 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, "/") + "/"
}
realPath := strings.TrimRight(idx.Path, "/") + adjustedPath
// Open the directory
dir, err := os.Open(realPath)
@@ -152,10 +160,8 @@ func (idx *Index) indexDirectory(adjustedPath string, config *actionConfig) erro
idx.DirectoriesLedger[adjustedPath] = struct{}{}
idx.mu.Unlock()
}
combinedPath := adjustedPath + "/"
if adjustedPath == "/" {
combinedPath = "/"
}
// adjustedPath is already normalized with trailing slash
combinedPath := adjustedPath
// get whats currently in cache
idx.mu.RLock()
cacheDirItems := []iteminfo.ItemInfo{}
@@ -227,14 +233,17 @@ func (idx *Index) GetFsDirInfo(adjustedPath string) (*iteminfo.FileInfo, error)
fileInfo.DetectType(realPath, false)
return &fileInfo, nil
}
combinedPath := adjustedPath + "/"
if adjustedPath == "/" {
combinedPath = "/"
// Normalize directory path to always have trailing slash
if adjustedPath != "/" {
adjustedPath = strings.TrimSuffix(adjustedPath, "/") + "/"
}
// adjustedPath is already normalized with trailing slash
combinedPath := adjustedPath
var response *iteminfo.FileInfo
response, err = idx.GetDirInfo(dir, dirInfo, realPath, adjustedPath, combinedPath, &actionConfig{
Quick: false,
Recursive: false,
Quick: false,
Recursive: false,
ForceCheck: true,
})
if err != nil {
@@ -246,8 +255,10 @@ func (idx *Index) GetFsDirInfo(adjustedPath string) (*iteminfo.FileInfo, error)
found := false
for _, item := range response.Files {
if item.Name == baseName {
// Clean path to remove trailing slashes before joining
filePath := strings.TrimSuffix(adjustedPath, "/") + "/" + item.Name
response = &iteminfo.FileInfo{
Path: adjustedPath + "/" + item.Name,
Path: filePath,
ItemInfo: item,
}
found = true
@@ -264,6 +275,8 @@ func (idx *Index) GetFsDirInfo(adjustedPath string) (*iteminfo.FileInfo, error)
}
func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjustedPath, combinedPath string, config *actionConfig) (*iteminfo.FileInfo, error) {
// Ensure combinedPath has exactly one trailing slash to prevent double slashes in subdirectory paths
combinedPath = strings.TrimRight(combinedPath, "/") + "/"
// Read directory contents
files, err := dirInfo.Readdir(-1)
if err != nil {
@@ -312,7 +325,7 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
}
}
// skip non-indexable dirs.
if slices.Contains(omitList, file.Name()) {
if omitList[file.Name()] {
continue
}
@@ -361,30 +374,29 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
}
}
ext := strings.ToLower(filepath.Ext(file.Name()))
switch ext {
case ".jpg", ".jpeg", ".png", ".bmp", ".tiff":
itemInfo.HasPreview = true
case ".heic", ".heif":
if settings.Config.Integrations.Media.FfmpegPath != "" {
itemInfo.HasPreview = true
}
}
extWithoutPeriod := strings.TrimPrefix(ext, ".")
if simpleType == "image" {
itemInfo.HasPreview = true
}
if settings.Config.Integrations.Media.FfmpegPath != "" && simpleType == "video" {
switch extWithoutPeriod {
case "heic", "heif":
if settings.CanConvertImage(extWithoutPeriod) {
itemInfo.HasPreview = true
}
}
if simpleType == "video" && settings.CanConvertVideo(extWithoutPeriod) {
itemInfo.HasPreview = true
}
itemInfo.Size = int64(size)
// all checks after this won't update folder preview
if itemInfo.HasPreview {
// 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
}
// Set HasPreview before appending to fileInfos
// these don't create preview for parent folders
// Set HasPreview for office docs and PDFs
// These files are previewable but DON'T set parent folder preview
if settings.Config.Integrations.OnlyOffice.Secret != "" && iteminfo.IsOnlyOffice(file.Name()) {
itemInfo.HasPreview = true
}
@@ -399,7 +411,6 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
if config.Recursive {
idx.NumFiles++
}
}
}
@@ -413,7 +424,7 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
idx.mu.Unlock()
}
// Create FileInfo for the current directory
// Create FileInfo for the current directory (adjustedPath is already normalized with trailing slash)
dirFileInfo := &iteminfo.FileInfo{
Path: adjustedPath,
Files: fileInfos,
@@ -428,40 +439,27 @@ func (idx *Index) GetDirInfo(dirInfo *os.File, stat os.FileInfo, realPath, adjus
}
dirFileInfo.SortItems()
// Metadata will be updated by the caller (indexDirectory or GetFsDirInfo)
return dirFileInfo, nil
}
// input should be non-index path.
func (idx *Index) MakeIndexPath(subPath string) string {
if strings.HasPrefix(subPath, "./") {
subPath = strings.TrimPrefix(subPath, ".")
}
if idx.Path == subPath || subPath == "." {
return "/"
}
// clean path
subPath = strings.TrimSuffix(subPath, "/")
adjustedPath := strings.TrimPrefix(subPath, idx.Path)
// remove index prefix
adjustedPath = strings.ReplaceAll(adjustedPath, "\\", "/")
// remove trailing slash
adjustedPath = strings.TrimSuffix(adjustedPath, "/")
if !strings.HasPrefix(adjustedPath, "/") {
adjustedPath = "/" + adjustedPath
}
return adjustedPath
}
func (idx *Index) recursiveUpdateDirSizes(childInfo *iteminfo.FileInfo, previousSize int64) {
parentDir := utils.GetParentDirectoryPath(childInfo.Path)
parentInfo, exists := idx.GetMetadataInfo(parentDir, true)
if !exists || parentDir == "" {
return
}
newSize := parentInfo.Size - previousSize + childInfo.Size
parentInfo.Size += newSize
// Calculate size delta and update parent
previousParentSize := parentInfo.Size
sizeDelta := childInfo.Size - previousSize
parentInfo.Size = previousParentSize + sizeDelta
idx.UpdateMetadata(parentInfo)
idx.recursiveUpdateDirSizes(parentInfo, newSize)
// Recursively update grandparents
idx.recursiveUpdateDirSizes(parentInfo, previousParentSize)
}
func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
@@ -486,7 +484,7 @@ func (idx *Index) GetRealPath(relativePath ...string) (string, bool, error) {
return realPath, isDir, err
}
func (idx *Index) RefreshFileInfo(opts iteminfo.FileOptions) error {
func (idx *Index) RefreshFileInfo(opts utils.FileOptions) error {
config := &actionConfig{
Quick: false,
Recursive: opts.Recursive,
@@ -497,29 +495,30 @@ func (idx *Index) RefreshFileInfo(opts iteminfo.FileOptions) error {
targetPath = idx.MakeIndexPath(filepath.Dir(targetPath))
}
// Use the recursive flag from options - false for quick scan, true for recursive
// Get PREVIOUS metadata BEFORE indexing
previousInfo, previousExists := idx.GetMetadataInfo(targetPath, true)
var previousSize int64
if previousExists {
previousSize = previousInfo.Size
}
// Re-index the directory
err := idx.indexDirectoryWithOptions(targetPath, config)
if err != nil {
return err
}
file, exists := idx.GetMetadataInfo(targetPath, true)
// Get the NEW metadata after indexing
newInfo, exists := idx.GetMetadataInfo(targetPath, true)
if !exists {
return fmt.Errorf("file/folder does not exist in metadata: %s", targetPath)
}
current, firstExisted := idx.GetMetadataInfo(targetPath, true)
refreshParentInfo := firstExisted && current.Size != file.Size
//utils.PrintStructFields(*file)
result := idx.UpdateMetadata(file)
if !result {
return fmt.Errorf("file/folder does not exist in metadata: %s", targetPath)
}
if !exists {
return nil
}
if refreshParentInfo {
idx.recursiveUpdateDirSizes(file, current.Size)
// If size changed, propagate to parents
if previousSize != newInfo.Size {
idx.recursiveUpdateDirSizes(newInfo, previousSize)
}
return nil
}
@@ -699,3 +698,14 @@ func (idx *Index) handleFile(file os.FileInfo, fullCombined string) (size uint64
idx.mu.Unlock()
return realSize, true // Count size.
}
// input should be non-index path.
func (idx *Index) MakeIndexPath(path string) string {
if path == "." || strings.HasPrefix(path, "./") {
path = strings.TrimPrefix(path, ".")
}
path = strings.TrimPrefix(path, idx.Path)
path = idx.MakeIndexPathPlatform(path)
path = strings.TrimSuffix(path, "/") + "/"
return path
}
+386
View File
@@ -0,0 +1,386 @@
package indexing
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
// setupTestIndex creates a test index with mock data (no filesystem dependencies)
func setupTestIndex(t *testing.T) (*Index, string, func()) {
t.Helper()
// Initialize index with mock data
idx := &Index{
Source: settings.Source{
Name: "test",
Path: "/mock/path",
Config: settings.SourceConfig{
DisableIndexing: false,
},
},
hasIndex: true,
mock: true, // Enable mock mode
Directories: make(map[string]*iteminfo.FileInfo),
DirectoriesLedger: make(map[string]struct{}),
FoundHardLinks: make(map[string]uint64),
processedInodes: make(map[uint64]struct{}),
}
// Create mock directory structure with predictable sizes
// Using logical file sizes instead of disk allocation sizes
idx.Directories["/"] = &iteminfo.FileInfo{
Path: "/",
ItemInfo: iteminfo.ItemInfo{
Name: "/",
Type: "directory",
Size: 1000, // Total logical size: 100 + 200 + 300 + 400
},
Files: []iteminfo.ItemInfo{
{Name: "file1.txt", Size: 100},
{Name: "file2.txt", Size: 200},
},
Folders: []iteminfo.ItemInfo{
{Name: "subdir", Type: "directory", Size: 700}, // 300 + 400
},
}
idx.Directories["/subdir/"] = &iteminfo.FileInfo{
Path: "/subdir/",
ItemInfo: iteminfo.ItemInfo{
Name: "subdir",
Type: "directory",
Size: 700, // 300 + 400
},
Files: []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.ItemInfo{
{Name: "file4.txt", Size: 400},
},
}
// Cleanup function (no-op since we're not using filesystem)
cleanup := func() {
// Nothing to clean up in mock mode
}
return idx, "/mock/path", cleanup
}
func TestFolderSizeCalculation(t *testing.T) {
idx, _, cleanup := setupTestIndex(t)
defer cleanup()
tests := []struct {
name string
path string
expectedSize int64
description string
}{
{
name: "Root directory size",
path: "/",
expectedSize: 1000, // Total logical size: 100 + 200 + 300 + 400
description: "Root should include all nested files",
},
{
name: "Subdir size",
path: "/subdir/",
expectedSize: 700, // 300 + 400
description: "Subdir should include its files and nested directory",
},
{
name: "Deep directory size",
path: "/subdir/deepdir/",
expectedSize: 400, // 1 file
description: "Deepest directory should only include its direct files",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Get directory info directly from mock data
dirInfo, exists := idx.GetMetadataInfo(tt.path, true)
if !exists {
t.Fatalf("Directory %s not found in mock data", tt.path)
}
if dirInfo.Size != tt.expectedSize {
t.Errorf("%s: got size %d, want %d", tt.description, dirInfo.Size, tt.expectedSize)
}
})
}
}
func TestRecursiveSizeUpdate(t *testing.T) {
idx, _, cleanup := setupTestIndex(t)
defer cleanup()
// Verify initial sizes (using logical sizes, not disk allocation)
rootInfo, exists := idx.GetMetadataInfo("/", true)
if !exists {
t.Fatal("Root metadata not found")
}
if rootInfo.Size != 1000 {
t.Errorf("Initial root size: got %d, want 1000", rootInfo.Size)
}
subdirInfo, exists := idx.GetMetadataInfo("/subdir/", true)
if !exists {
t.Fatal("Subdir metadata not found")
}
initialSubdirSize := subdirInfo.Size
if initialSubdirSize != 700 {
t.Errorf("Initial subdir size: got %d, want 700", initialSubdirSize)
}
// 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,
})
// Update deepdir size
oldDeepdirSize := deepdirInfo.Size
deepdirInfo.Size = 900 // 400 + 500
// Simulate the recursive size update by calling the method directly
idx.recursiveUpdateDirSizes(deepdirInfo, oldDeepdirSize)
// Check that deepdir size updated
deepdirInfo, exists = idx.GetMetadataInfo("/subdir/deepdir/", true)
if !exists {
t.Fatal("Deepdir metadata not found after update")
}
expectedDeepdirSize := int64(900) // 400 + 500
if deepdirInfo.Size != expectedDeepdirSize {
t.Errorf("Deepdir size after adding file: got %d, want %d", deepdirInfo.Size, expectedDeepdirSize)
}
// Check that subdir size updated (includes the new file)
subdirInfo, exists = idx.GetMetadataInfo("/subdir/", true)
if !exists {
t.Fatal("Subdir metadata not found after propagation")
}
// Subdir should now contain file3.txt + deepdir's new size
expectedSubdirSize := int64(1200) // 300 + 900
if subdirInfo.Size != expectedSubdirSize {
t.Errorf("Subdir size after propagation: got %d, want %d", subdirInfo.Size, expectedSubdirSize)
}
// Check that root size updated (includes changes propagated from subdir)
rootInfo, exists = idx.GetMetadataInfo("/", true)
if !exists {
t.Fatal("Root metadata not found after propagation")
}
expectedRootSize := int64(1500) // 100 + 200 + 1200
if rootInfo.Size != expectedRootSize {
t.Errorf("Root size after propagation: got %d, want %d", rootInfo.Size, expectedRootSize)
}
}
func TestNonRecursiveMetadataUpdate(t *testing.T) {
idx, _, cleanup := setupTestIndex(t)
defer cleanup()
// Get initial root size
rootInfo, exists := idx.GetMetadataInfo("/", true)
if !exists {
t.Fatal("Root metadata not found")
}
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,
})
// Update root size (non-recursive - only direct files)
rootInfo.Size = initialRootSize + 150
// Check that root size updated
rootInfo, exists = idx.GetMetadataInfo("/", true)
if !exists {
t.Fatal("Root metadata not found after non-recursive update")
}
expectedRootSize := initialRootSize + 150 // new file size
if rootInfo.Size != expectedRootSize {
t.Errorf("Root size after non-recursive update: got %d, want %d", rootInfo.Size, expectedRootSize)
}
}
func TestRecursiveUpdateDirSizes(t *testing.T) {
idx, _, cleanup := setupTestIndex(t)
defer cleanup()
// Setup initial metadata manually
idx.Directories["/"] = &iteminfo.FileInfo{
Path: "/",
ItemInfo: iteminfo.ItemInfo{
Size: 1000,
},
}
idx.Directories["/subdir/"] = &iteminfo.FileInfo{
Path: "/subdir/",
ItemInfo: iteminfo.ItemInfo{
Size: 700,
},
}
idx.Directories["/subdir/deepdir/"] = &iteminfo.FileInfo{
Path: "/subdir/deepdir/",
ItemInfo: iteminfo.ItemInfo{
Size: 400,
},
}
// Simulate updating deepdir from 400 to 900 (added 500 bytes)
deepdirInfo := idx.Directories["/subdir/deepdir/"]
previousSize := deepdirInfo.Size
deepdirInfo.Size = 900
// Call recursiveUpdateDirSizes
idx.recursiveUpdateDirSizes(deepdirInfo, previousSize)
// Check that subdir size updated
subdirInfo := idx.Directories["/subdir/"]
expectedSubdirSize := int64(1200) // 700 + 500
if subdirInfo.Size != expectedSubdirSize {
t.Errorf("Subdir size after recursive update: got %d, want %d", subdirInfo.Size, expectedSubdirSize)
}
// Check that root size updated
rootInfo := idx.Directories["/"]
expectedRootSize := int64(1500) // 1000 + 500
if rootInfo.Size != expectedRootSize {
t.Errorf("Root size after recursive update: got %d, want %d", rootInfo.Size, expectedRootSize)
}
}
func TestSizeDecreasePropagate(t *testing.T) {
idx, _, cleanup := setupTestIndex(t)
defer cleanup()
// Setup initial metadata manually
idx.Directories["/"] = &iteminfo.FileInfo{
Path: "/",
ItemInfo: iteminfo.ItemInfo{
Size: 1000,
},
}
idx.Directories["/subdir/"] = &iteminfo.FileInfo{
Path: "/subdir/",
ItemInfo: iteminfo.ItemInfo{
Size: 700,
},
}
idx.Directories["/subdir/deepdir/"] = &iteminfo.FileInfo{
Path: "/subdir/deepdir/",
ItemInfo: iteminfo.ItemInfo{
Size: 400,
},
}
// Simulate updating deepdir from 400 to 100 (removed 300 bytes)
deepdirInfo := idx.Directories["/subdir/deepdir/"]
previousSize := deepdirInfo.Size
deepdirInfo.Size = 100
// Call recursiveUpdateDirSizes
idx.recursiveUpdateDirSizes(deepdirInfo, previousSize)
// Check that subdir size decreased
subdirInfo := idx.Directories["/subdir/"]
expectedSubdirSize := int64(400) // 700 - 300
if subdirInfo.Size != expectedSubdirSize {
t.Errorf("Subdir size after decrease: got %d, want %d", subdirInfo.Size, expectedSubdirSize)
}
// Check that root size decreased
rootInfo := idx.Directories["/"]
expectedRootSize := int64(700) // 1000 - 300
if rootInfo.Size != expectedRootSize {
t.Errorf("Root size after decrease: got %d, want %d", rootInfo.Size, expectedRootSize)
}
}
func TestPreviewDoesNotPropagateFromSubdirectories(t *testing.T) {
idx, _, cleanup := setupTestIndex(t)
defer cleanup()
// 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.HasPreview = true // Subdir now has preview
// Check that subdir has preview (due to image.jpg)
subdirInfo, exists := idx.GetMetadataInfo("/subdir/", true)
if !exists {
t.Fatal("Subdir metadata not found")
}
if !subdirInfo.HasPreview {
t.Error("Subdir should have HasPreview=true due to image file")
}
// Check that root does NOT have preview propagated from subdir
rootInfo, exists := idx.GetMetadataInfo("/", true)
if !exists {
t.Fatal("Root metadata not found")
}
// Root should only have HasPreview if it has direct previewable files, not from subdirectories
// Since root only has text files, it should NOT have preview
if rootInfo.HasPreview {
t.Error("Root should NOT have HasPreview=true from subdirectory preview")
}
}
func TestPreviewPropagatesFromFiles(t *testing.T) {
idx, _, cleanup := setupTestIndex(t)
defer cleanup()
// 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.HasPreview = true // Root now has preview
// Check that root HAS preview (due to direct image.png file)
rootInfo, exists := idx.GetMetadataInfo("/", true)
if !exists {
t.Fatal("Root metadata not found")
}
if !rootInfo.HasPreview {
t.Error("Root should have HasPreview=true due to direct image file")
}
}
+13 -13
View File
@@ -9,7 +9,7 @@ import (
)
func BenchmarkFillIndex(b *testing.B) {
Initialize(settings.Source{
Initialize(&settings.Source{
Name: "test",
Path: "/srv",
}, true)
@@ -62,18 +62,18 @@ func TestMakeIndexPath(t *testing.T) {
{"Root path returns slash", "/", "/"},
{"Dot-prefixed returns slash", ".", "/"},
{"Double-dot prefix ignored", "./", "/"},
{"Dot prefix followed by text", "./test", "/test"},
{"Dot prefix followed by text", ".test", "/.test"},
{"Hidden file at root", "/.test", "/.test"},
{"Trailing slash removed", "/test/", "/test"},
{"Subpath without root prefix", "/other/test", "/other/test"},
{"Complex nested paths", "/nested/path", "/nested/path"},
{"Dot prefix followed by text", "./test", "/test/"},
{"Dot prefix followed by text", ".test", "/.test/"},
{"Hidden file at root", "/.test", "/.test/"},
{"Trailing slash removed", "/test/", "/test/"},
{"Subpath without root prefix", "/other/test", "/other/test/"},
{"Complex nested paths", "/nested/path", "/nested/path/"},
// TODO fix {"has source name as start", "/srv.tar.gz", "/srv.tar.gz"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
idx := &Index{Source: settings.Source{Path: "/srv"}}
idx := &Index{Source: settings.Source{Path: "/srv"}, mock: true}
result := idx.MakeIndexPath(tt.subPath)
if result != tt.expected {
t.Errorf("MakeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
@@ -87,14 +87,14 @@ func TestMakeIndexPath(t *testing.T) {
expected string
}{
// Windows
{"Mixed slash", "/first\\second", "/first/second"},
{"Windows slash", "\\first\\second", "/first/second"},
{"Windows full path", "C:\\Users\\testfolder\\nestedfolder", "/testfolder/nestedfolder"},
{"Mixed slash", "/first\\second", "/first/second/"},
{"Windows slash", "\\first\\second", "/first/second/"},
{"Windows full path", "C:\\Users\\testfolder\\nestedfolder", "/testfolder/nestedfolder/"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
idx := &Index{Source: settings.Source{Path: "C:\\Users"}}
idx := &Index{Source: settings.Source{Path: "C:\\Users"}, mock: true}
result := idx.MakeIndexPath(tt.subPath)
if result != tt.expected {
t.Errorf("MakeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
@@ -115,7 +115,7 @@ func TestMakeIndexPathRoot(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
idx := &Index{Source: settings.Source{Path: "/rootpath", Name: "default"}}
idx := &Index{Source: settings.Source{Path: "/rootpath", Name: "default"}, mock: true}
result := idx.MakeIndexPath(tt.subPath)
if result != tt.expected {
t.Errorf("MakeIndexPath(%q)\ngot %q\nwant %q", tt.name, result, tt.expected)
+189 -575
View File
@@ -1,14 +1,8 @@
package iteminfo
import (
"bytes"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
@@ -24,15 +18,15 @@ var AllFiletypeOptions = []string{
"text",
}
var SubtitleExts = []string{
".vtt",
".srt",
".lrc",
".sbv",
".ass",
".ssa",
".sub",
".smi",
var SubtitleExts = map[string]bool{
".vtt": true,
".srt": true,
".lrc": true,
".sbv": true,
".ass": true,
".ssa": true,
".sub": true,
".smi": true,
}
var MuPdfConvertable = map[string]bool{
@@ -62,159 +56,194 @@ var ResizableImageTypes = map[string]bool{
}
// Known bundle-style extensions that are technically directories but treated as files
var BundleExtensions = []string{
".app", // macOS application bundle
".bundle", // macOS plugin bundle
".framework", // macOS framework
".plugin", // macOS plugin
".kext", // macOS kernel extension
".pkg", // macOS installer package
".mpkg", // macOS multi-package
".apk", // Android package
".aab", // Android App Bundle
".appx", // Windows application package
".msix", // Windows modern app package
".deb", // Debian package
".snap", // Snap package
".flatpak", // Flatpak application
".dmg", // macOS disk image
".iso", // ISO disk image
var BundleExtensions = map[string]bool{
".app": true, // macOS application bundle
".bundle": true, // macOS plugin bundle
".framework": true, // macOS framework
".plugin": true, // macOS plugin
".kext": true, // macOS kernel extension
".pkg": true, // macOS installer package
".mpkg": true, // macOS multi-package
".apk": true, // Android package
".aab": true, // Android App Bundle
".appx": true, // Windows application package
".msix": true, // Windows modern app package
".deb": true, // Debian package
".snap": true, // Snap package
".flatpak": true, // Flatpak application
".dmg": true, // macOS disk image
".iso": true, // ISO disk image
}
// Document file extensions
var documentTypes = []string{
var documentTypes = map[string]bool{
// Common Document Formats
".doc", ".docx", // Microsoft Word
".pdf", // Portable Document Format
".odt", // OpenDocument Text
".rtf", // Rich Text Format
".conf",
".bash_history",
".gitignore",
".htpasswd",
".profile",
".dockerignore",
".editorconfig",
// Presentation Formats
".ppt", ".pptx", // Microsoft PowerPoint
".odp", // OpenDocument Presentation
// google docs
".gdoc",
// google sheet
".gsheet",
// Spreadsheet Formats
".xls", ".xlsx", // Microsoft Excel
".ods", // OpenDocument Spreadsheet
// Other Document Formats
".epub", // Electronic Publication
".mobi", // Amazon Kindle
".fb2", // FictionBook
".doc": true, // Microsoft Word
".docx": true, // Microsoft Word
".pdf": true, // Portable Document Format
".odt": true, // OpenDocument Text
".rtf": true, // Rich Text Format
".conf": true,
".bash_history": true,
".gitignore": true,
".htpasswd": true,
".profile": true,
".dockerignore": true,
".editorconfig": true,
".ppt": true, // Microsoft PowerPoint
".pptx": true, // Microsoft PowerPoint
".odp": true, // OpenDocument Presentation
".gdoc": true, // google docs
".gsheet": true, // google sheet
".xls": true, // Microsoft Excel
".xlsx": true, // Microsoft Excel
".ods": true, // OpenDocument Spreadsheet
".epub": true, // Electronic Publication
".mobi": true, // Amazon Kindle
".fb2": true, // FictionBook
}
var onlyOfficeSupported = []string{
var onlyOfficeSupported = map[string]bool{
// Word Processing Documents
".doc", ".docm", ".docx", ".dot", ".dotm", ".dotx", ".epub",
".fb2", ".fodt", ".htm", ".html", ".mht", ".mhtml", ".odt",
".ott", ".rtf", ".stw", ".sxw", ".txt", ".wps", ".wpt", ".xml",
".hwp", ".hwpx", ".md", ".pages", // Added missing Word extensions
".doc": true,
".docm": true,
".docx": true,
".dot": true,
".dotm": true,
".dotx": true,
".epub": true,
".fb2": true,
".fodt": true,
".htm": true,
".html": true,
".mht": true,
".mhtml": true,
".odt": true,
".ott": true,
".rtf": true,
".stw": true,
".sxw": true,
".txt": true,
".wps": true,
".wpt": true,
".xml": true,
".hwp": true,
".hwpx": true,
".md": true,
".pages": true,
// Spreadsheet Documents
".csv", ".et", ".ett", ".fods", ".ods", ".ots", ".sxc", ".xls",
".xlsb", ".xlsm", ".xlsx", ".xlt", ".xltm", ".xltx",
".numbers", // Added missing Spreadsheet extension
".csv": true,
".et": true,
".ett": true,
".fods": true,
".ods": true,
".ots": true,
".sxc": true,
".xls": true,
".xlsb": true,
".xlsm": true,
".xlsx": true,
".xlt": true,
".xltm": true,
".xltx": true,
".numbers": true,
// Presentation Documents
".dps", ".dpt", ".fodp", ".odp", ".otp", ".pot", ".potm", ".potx",
".pps", ".ppsm", ".ppsx", ".ppt", ".pptm", ".pptx", ".sxi",
".key", ".odg", // Added missing Presentation extensions
".dps": true,
".dpt": true,
".fodp": true,
".odp": true,
".otp": true,
".pot": true,
".potm": true,
".potx": true,
".pps": true,
".ppsm": true,
".ppsx": true,
".ppt": true,
".pptm": true,
".pptx": true,
".sxi": true,
".key": true,
".odg": true,
// Other Office-Related Formats
".djvu", ".docxf", ".oform", ".oxps", ".pdf", ".xps",
// Diagram Documents (New category from List 2)
".vsdm", ".vsdx", ".vssm", ".vssx", ".vstm", ".vstx",
".djvu": true,
".docxf": true,
".oform": true,
".oxps": true,
".pdf": true,
".xps": true,
// Diagram Documents
".vsdm": true,
".vsdx": true,
".vssm": true,
".vssx": true,
".vstm": true,
".vstx": true,
}
// Text-based file extensions
var textTypes = []string{
var textTypes = map[string]bool{
// Common Text Formats
".txt",
".md", // Markdown
// Scripting and Programming Languages
".sh", // Bash script
".py", // Python
".js", // JavaScript
".ts", // TypeScript
".php", // PHP
".rb", // Ruby
".go", // Go
".java", // Java
".c", ".cpp", // C/C++
".cs", // C#
".swift", // Swift
// Configuration Files
".yaml", ".yml", // YAML
".json", // JSON
".xml", // XML
".ini", // INI
".toml", // TOML
".cfg", // Configuration file
// Other Text-Based Formats
".css", // Cascading Style Sheets
".html", ".htm", // HyperText Markup Language
".sql", // SQL
".csv", // Comma-Separated Values
".tsv", // Tab-Separated Values
".log", // Log file
".bat", // Batch file
".ps1", // PowerShell script
".tex", // LaTeX
".bib", // BibTeX
".txt": true,
".md": true, // Markdown
".sh": true, // Bash script
".py": true, // Python
".js": true, // JavaScript
".ts": true, // TypeScript
".php": true, // PHP
".rb": true, // Ruby
".go": true, // Go
".java": true, // Java
".c": true, // C
".cpp": true, // C++
".cs": true, // C#
".swift": true, // Swift
".yaml": true, // YAML
".yml": true, // YAML
".json": true, // JSON
".xml": true, // XML
".ini": true, // INI
".toml": true, // TOML
".cfg": true, // Configuration file
".css": true, // Cascading Style Sheets
".html": true, // HyperText Markup Language
".htm": true, // HyperText Markup Language
".sql": true, // SQL
".csv": true, // Comma-Separated Values
".tsv": true, // Tab-Separated Values
".log": true, // Log file
".bat": true, // Batch file
".ps1": true, // PowerShell script
".tex": true, // LaTeX
".bib": true, // BibTeX
}
// Compressed file extensions
var compressedFile = []string{
".7z",
".rar",
".zip",
".tar",
".gz",
".xz",
".bz2",
".tgz", // tar.gz
".tbz2", // tar.bz2
".lzma",
".lz4",
".zstd",
var compressedFile = map[string]bool{
".7z": true,
".rar": true,
".zip": true,
".tar": true,
".gz": true,
".xz": true,
".bz2": true,
".tgz": true, // tar.gz
".tbz2": true, // tar.bz2
".lzma": true,
".lz4": true,
".zstd": true,
}
var audioMetadataTypes = []string{
".mp3",
".flac",
".ogg",
".opus",
".m4a",
".mp4",
".wav",
".ape",
".wv",
}
func CouldHaveAlbumArt(extension string) bool {
for _, typefile := range audioMetadataTypes {
if extension == typefile {
return true
}
}
return false
var audioMetadataTypes = map[string]bool{
".mp3": true,
".flac": true,
".ogg": true,
".opus": true,
".m4a": true,
".mp4": true,
".wav": true,
".ape": true,
".wv": true,
}
func ExtendedMimeTypeCheck(extension string) string {
@@ -245,40 +274,20 @@ func UpdateSize(given string) int {
}
func IsText(extension string) bool {
for _, typefile := range textTypes {
if extension == typefile {
return true
}
}
return false
return textTypes[extension]
}
func IsDoc(extension string) bool {
for _, typefile := range documentTypes {
if extension == typefile {
return true
}
}
return false
return documentTypes[extension]
}
func IsArchive(extension string) bool {
for _, typefile := range compressedFile {
if extension == typefile {
return true
}
}
return false
return compressedFile[extension]
}
func IsOnlyOffice(name string) bool {
extention := filepath.Ext(name)
for _, typefile := range onlyOfficeSupported {
if extention == typefile {
return true
}
}
return false
return onlyOfficeSupported[extention]
}
func IsMatchingType(extension string, matchType string) bool {
@@ -332,409 +341,10 @@ func (i *ItemInfo) DetectType(realPath string, saveContent bool) {
}
}
// hasAlbumArtLowLevel efficiently checks for album art in audio files.
// It handles ID3v2 tags (MP3) and Vorbis comments (FLAC, OGG) with low-level parsing.
func hasAlbumArtLowLevel(filePath string, extension string) (bool, error) {
file, err := os.Open(filePath)
if err != nil {
return false, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
// Handle different audio formats
switch extension {
case ".mp3":
return hasAlbumArtMP3(file)
case ".flac", ".ogg", ".opus":
return hasAlbumArtVorbis(file)
case ".m4a", ".mp4":
return hasAlbumArtM4A(file)
default:
// For other formats, return false (no album art detection)
return false, nil
}
}
// hasAlbumArtMP3 checks for APIC/PIC frames in ID3v2 tags with optimized parsing
func hasAlbumArtMP3(file *os.File) (bool, error) {
// Read 10-byte ID3v2 header
header := make([]byte, 10)
if _, err := io.ReadFull(file, header); err != nil {
return false, nil // No ID3v2 tag found
}
// Check for "ID3" identifier
if string(header[0:3]) != "ID3" {
return false, nil // No ID3v2 tag found
}
// Determine ID3v2 version for proper frame ID detection
version := header[3]
var pictureFrameID string
var frameHeaderSize int
switch version {
case 2:
pictureFrameID = "PIC" // ID3v2.2 uses 3-byte frame IDs
frameHeaderSize = 6
case 3, 4:
pictureFrameID = "APIC" // ID3v2.3/2.4 use 4-byte frame IDs
frameHeaderSize = 10
default:
return false, nil // Unsupported version
}
// Decode synchsafe tag size
tagSize := (int(header[6]) << 21) | (int(header[7]) << 14) | (int(header[8]) << 7) | int(header[9])
// Limit reading to reasonable size for performance
maxReadSize := 32768 // 32KB should be enough to find picture frames
readSize := tagSize
if readSize > maxReadSize {
readSize = maxReadSize
}
// Read tag data
tagData := make([]byte, readSize)
if _, err := io.ReadFull(file, tagData); err != nil {
return false, nil // Failed to read, assume no artwork
}
position := 0
frameIDLen := len(pictureFrameID)
// Loop through frames looking for picture frame
for position+frameHeaderSize <= readSize {
// Check if we've hit padding (null bytes)
if tagData[position] == 0 {
break
}
// Extract frame ID
if position+frameIDLen > readSize {
break
}
frameID := string(tagData[position : position+frameIDLen])
// Found picture frame!
if frameID == pictureFrameID {
return true, nil
}
// Skip to next frame
var frameSize int
if version == 2 {
// ID3v2.2: 3-byte size
if position+6 > readSize {
break
}
frameSize = int(tagData[position+3])<<16 | int(tagData[position+4])<<8 | int(tagData[position+5])
position += 6 + frameSize
} else {
// ID3v2.3/2.4: 4-byte size
if position+10 > readSize {
break
}
frameSize = int(tagData[position+4])<<24 | int(tagData[position+5])<<16 |
int(tagData[position+6])<<8 | int(tagData[position+7])
position += 10 + frameSize
}
// Safety check to prevent infinite loops
if frameSize <= 0 || position >= tagSize {
break
}
}
return false, nil
}
// hasAlbumArtVorbis checks for album art in FLAC, OGG, and OPUS files using surgical format-specific parsing
func hasAlbumArtVorbis(file *os.File) (bool, error) {
// Read initial buffer to determine file type
buffer := make([]byte, 16384) // 16KB should be enough for most metadata sections
n, err := file.Read(buffer)
if err != nil && err != io.EOF {
return false, err
}
// Check if it's a FLAC file
if n >= 4 && string(buffer[0:4]) == "fLaC" {
return hasAlbumArtFLAC(buffer, n)
}
// Check if it's an OGG file (including OPUS)
if n >= 4 && string(buffer[0:4]) == "OggS" {
return hasAlbumArtOGG(buffer, n)
}
return false, nil
}
// hasAlbumArtFLAC checks for PICTURE metadata block (type 6) in FLAC files
func hasAlbumArtFLAC(buffer []byte, n int) (bool, error) {
pos := 4 // Skip "fLaC" header
for pos < n-4 {
// Read metadata block header (4 bytes total)
blockHeader := buffer[pos]
blockType := blockHeader & 0x7F // bits 0-6
isLast := blockHeader&0x80 != 0 // bit 7
// Found picture block!
if blockType == 6 { // PICTURE block type
return true, nil
}
// Get block size (next 3 bytes, big-endian)
if pos+4 > n {
break
}
blockSize := int(buffer[pos+1])<<16 | int(buffer[pos+2])<<8 | int(buffer[pos+3])
// Move to next block
pos += 4 + blockSize
// Stop if this was the last block or we've exceeded buffer
if isLast || pos >= n {
break
}
}
return false, nil
}
// hasAlbumArtOGG checks for METADATA_BLOCK_PICTURE in OGG Vorbis comments (including OPUS)
func hasAlbumArtOGG(buffer []byte, n int) (bool, error) {
// Look for Vorbis comment patterns
vorbisPrefix := []byte("\x03vorbis")
opusPrefix := []byte("OpusTags")
// Search for either Vorbis or Opus tags
for i := 0; i < n-8; i++ {
if i+len(vorbisPrefix) < n && bytes.Equal(buffer[i:i+len(vorbisPrefix)], vorbisPrefix) {
// Found Vorbis comment block, look for METADATA_BLOCK_PICTURE
return searchForPictureInVorbisComment(buffer[i+len(vorbisPrefix):], n-i-len(vorbisPrefix))
}
if i+len(opusPrefix) < n && bytes.Equal(buffer[i:i+len(opusPrefix)], opusPrefix) {
// Found Opus tags block, look for METADATA_BLOCK_PICTURE
return searchForPictureInVorbisComment(buffer[i+len(opusPrefix):], n-i-len(opusPrefix))
}
}
return false, nil
}
// searchForPictureInVorbisComment looks for METADATA_BLOCK_PICTURE field in Vorbis comments
func searchForPictureInVorbisComment(buffer []byte, n int) (bool, error) {
// Convert to string and look for the METADATA_BLOCK_PICTURE field
// This field contains base64-encoded picture data
content := string(buffer[:n])
// Look for the exact field name used in Vorbis comments
if strings.Contains(content, "METADATA_BLOCK_PICTURE=") {
return true, nil
}
// Some files might use alternative field names
if strings.Contains(content, "COVERART=") || strings.Contains(content, "COVER_ART=") {
return true, nil
}
return false, nil
}
// hasAlbumArtM4A checks for embedded artwork in M4A/MP4 files
func hasAlbumArtM4A(file *os.File) (bool, error) {
// For M4A files, look for 'covr' atom which contains artwork
// MP4/M4A files use a nested atom structure
buffer := make([]byte, 65536) // Read first 64KB to increase chances of finding atoms
n, err := file.Read(buffer)
if err != nil && err != io.EOF {
return false, err
}
// Look for the binary 'covr' atom signature
// MP4 atoms have: [4-byte size][4-byte type][data...]
if bytes.Contains(buffer[:n], []byte("covr")) {
return true, nil
}
// Also look for other common MP4 metadata atoms that might contain artwork
// 'data' atoms often contain the actual image data
pos := 0
for pos < n-8 {
// Skip if not enough bytes left for an atom header
if pos+8 > n {
break
}
// Read atom size (big-endian 32-bit)
atomSize := int(buffer[pos])<<24 | int(buffer[pos+1])<<16 | int(buffer[pos+2])<<8 | int(buffer[pos+3])
// Read atom type (4 bytes)
atomType := string(buffer[pos+4 : pos+8])
// Check for relevant atoms
if atomType == "covr" || atomType == "data" {
// If we found a data atom, check if it contains image signatures
if atomType == "data" && pos+16 < n {
dataStart := pos + 16 // Skip atom header + data atom header
if dataStart < n {
// Check for common image format signatures
if bytes.HasPrefix(buffer[dataStart:], []byte("\xFF\xD8\xFF")) || // JPEG
bytes.HasPrefix(buffer[dataStart:], []byte("\x89PNG")) || // PNG
bytes.HasPrefix(buffer[dataStart:], []byte("GIF8")) { // GIF
return true, nil
}
}
}
if atomType == "covr" {
return true, nil
}
}
// Move to next atom
if atomSize <= 8 || atomSize > n-pos {
// Invalid atom size, try to find next potential atom
pos++
} else {
pos += atomSize
}
}
// Fallback: look for common image format signatures anywhere in the buffer
// This catches cases where the atom structure parsing fails
if bytes.Contains(buffer[:n], []byte("\xFF\xD8\xFF")) || // JPEG signature
bytes.Contains(buffer[:n], []byte("\x89PNG\x0D\x0A\x1A\x0A")) || // Full PNG signature
bytes.Contains(buffer[:n], []byte("GIF8")) { // GIF signature
return true, nil
}
return false, nil
}
func HasAlbumArt(filePath string, extension string) bool {
if !CouldHaveAlbumArt(extension) {
return false
}
// Use low-level detection for all audio formats
hasArt, err := hasAlbumArtLowLevel(filePath, extension)
if err != nil {
// If there's an error with low-level detection, return false
// This is safer than potentially crashing the indexing process
return false
}
return hasArt
}
// DetectTypeByHeader detects the MIME type of a file based on its header.
func DetectTypeByHeader(realPath string) string {
file, err := os.Open(realPath)
if err != nil {
return "blob"
}
defer file.Close()
buffer := make([]byte, 512)
n, err := file.Read(buffer)
if err != nil {
return "blob"
}
return http.DetectContentType(buffer[:n])
}
// returns true if the file name contains the search term
// returns file type if the file name contains the search term
// returns size of file/dir if the file name contains the search term
func (fi ItemInfo) ContainsSearchTerm(searchTerm string, options SearchOptions) bool {
fileTypes := map[string]bool{}
largerThan := int64(options.LargerThan) * 1024 * 1024
smallerThan := int64(options.SmallerThan) * 1024 * 1024
conditions := options.Conditions
lowerFileName := strings.ToLower(fi.Name)
// Convert to lowercase if not exact match
if !conditions["exact"] {
searchTerm = strings.ToLower(searchTerm)
}
// Check if the file name contains the search term
if !strings.Contains(lowerFileName, searchTerm) {
return false
}
// Initialize file size and fileTypes map
var fileSize int64
extension := filepath.Ext(lowerFileName)
// Collect file types
for _, k := range AllFiletypeOptions {
if IsMatchingType(extension, k) {
fileTypes[k] = true
}
}
isDir := fi.Type == "directory"
fileTypes["dir"] = isDir
fileSize = fi.Size
// Evaluate all conditions
for t, v := range conditions {
if t == "exact" {
continue
}
switch t {
case "larger":
if largerThan > 0 {
if fileSize <= largerThan {
return false
}
}
case "smaller":
if smallerThan > 0 {
if fileSize >= smallerThan {
return false
}
}
default:
// Handle other file type conditions
notMatchType := v != fileTypes[t]
if notMatchType {
return false
}
}
}
return true
}
// IsDirectory determines if a path should be treated as a directory.
// It treats known bundle-style directories as files instead.
func IsDirectory(fileInfo os.FileInfo) bool {
if !fileInfo.IsDir() {
return false
}
if !hasBundleExtension(fileInfo.Name()) {
return true
}
// For bundle-type dirs, treat them as files
return false
}
// hasBundleExtension checks if a file has a known bundle-style extension.
func hasBundleExtension(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
for _, bundleExt := range BundleExtensions {
if ext == bundleExt {
return true
}
}
return false
return BundleExtensions[ext]
}
func HasDocConvertableExtension(name, mimetype string) bool {
@@ -752,11 +362,15 @@ func HasDocConvertableExtension(name, mimetype string) bool {
return false
}
var ONLYOFFICE_READONLY_FILE_EXTENSIONS = []string{"pages", "numbers", "key"}
var ONLYOFFICE_READONLY_FILE_EXTENSIONS = map[string]bool{
"pages": true,
"numbers": true,
"key": true,
}
func CanEditOnlyOffice(modify bool, extention string) bool {
if !modify {
return false
}
return !slices.Contains(ONLYOFFICE_READONLY_FILE_EXTENSIONS, strings.ToLower(extention))
return !ONLYOFFICE_READONLY_FILE_EXTENSIONS[strings.ToLower(extention)]
}
-20
View File
@@ -1,10 +1,8 @@
package iteminfo
import (
"path/filepath"
"time"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
)
@@ -52,21 +50,3 @@ type ExtendedFileInfo struct {
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
RealPath string `json:"-"`
}
// FileOptions are the options when getting a file info.
type FileOptions struct {
Access *access.Storage
Username string // username for access control
Path string // realpath
Source string
IsDir bool
Modify bool
Expand bool
ReadHeader bool
Content bool
Recursive bool // whether to recursively index directories
}
func (f FileOptions) Components() (string, string) {
return filepath.Dir(f.Path), filepath.Base(f.Path)
}
+420
View File
@@ -0,0 +1,420 @@
package iteminfo
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
func CouldHaveAlbumArt(extension string) bool {
return audioMetadataTypes[extension]
}
// hasAlbumArtLowLevel efficiently checks for album art in audio files.
// It handles ID3v2 tags (MP3) and Vorbis comments (FLAC, OGG) with low-level parsing.
// Uses a hybrid approach: small initial read for fast exit, then targeted reads based on format.
func hasAlbumArtLowLevel(filePath string, extension string) (bool, error) {
file, err := os.Open(filePath)
if err != nil {
return false, fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
// Step 1: Perform a small initial read to identify file type signature (magic bytes)
initialBuffer := make([]byte, 10)
n, err := io.ReadFull(file, initialBuffer)
if err != nil {
// File is too small or can't be read - no metadata possible
return false, nil
}
// Step 2: Fast Path Exit & Format-Specific Handling
switch extension {
case ".mp3":
// Check for ID3 signature in first 3 bytes
if string(initialBuffer[0:3]) != "ID3" {
// No ID3 tag, fast exit without reading more
return false, nil
}
// ID3 tag found - calculate exact tag size and read only what's needed
return hasAlbumArtMP3Optimized(file, initialBuffer)
case ".flac":
// Check for FLAC signature
if n >= 4 && string(initialBuffer[0:4]) != "fLaC" {
// Not a valid FLAC file, fast exit
return false, nil
}
// Valid FLAC - read larger chunk for metadata blocks
return hasAlbumArtFLACOptimized(file, initialBuffer)
case ".ogg", ".opus":
// Check for OGG signature
if n >= 4 && string(initialBuffer[0:4]) != "OggS" {
// Not a valid OGG file, fast exit
return false, nil
}
// Valid OGG/Opus - read larger chunk for vorbis comments
return hasAlbumArtOGGOptimized(file, initialBuffer)
case ".m4a", ".mp4":
// M4A/MP4 files need larger read for atom structure
// Reset to beginning and read sufficient data
if _, err := file.Seek(0, 0); err != nil {
return false, err
}
buffer := make([]byte, 12288) // 12KB seems to be enought
n, err := file.Read(buffer)
if err != nil && err != io.EOF {
return false, err
}
return hasAlbumArtM4A(buffer[:n]), nil
default:
// Unknown format
return false, nil
}
}
// hasAlbumArtMP3Optimized checks for APIC/PIC frames with minimal I/O
// Takes the 10-byte header already read and reads only the necessary tag data
func hasAlbumArtMP3Optimized(file *os.File, header []byte) (bool, error) {
// Header already checked for "ID3" signature in caller
// Determine ID3v2 version for proper frame ID detection
version := header[3]
var pictureFrameID string
var frameHeaderSize int
switch version {
case 2:
pictureFrameID = "PIC" // ID3v2.2 uses 3-byte frame IDs
frameHeaderSize = 6
case 3, 4:
pictureFrameID = "APIC" // ID3v2.3/2.4 use 4-byte frame IDs
frameHeaderSize = 10
default:
return false, nil // Unsupported version
}
// Decode synchsafe tag size from header
tagSize := (int(header[6]) << 21) | (int(header[7]) << 14) | (int(header[8]) << 7) | int(header[9])
// Limit reading to reasonable size for performance (32KB max)
maxReadSize := 32768
readSize := tagSize
if readSize > maxReadSize {
readSize = maxReadSize
}
if readSize <= 0 {
return false, nil
}
// Read only the tag data needed
tagData := make([]byte, readSize)
n, err := io.ReadFull(file, tagData)
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
return false, nil
}
tagData = tagData[:n]
position := 0
frameIDLen := len(pictureFrameID)
// Loop through frames looking for picture frame
for position+frameHeaderSize <= len(tagData) {
// Check if we've hit padding (null bytes)
if tagData[position] == 0 {
break
}
// Extract frame ID
if position+frameIDLen > len(tagData) {
break
}
frameID := string(tagData[position : position+frameIDLen])
// Found picture frame!
if frameID == pictureFrameID {
return true, nil
}
// Skip to next frame
var frameSize int
if version == 2 {
// ID3v2.2: 3-byte size
if position+6 > len(tagData) {
break
}
frameSize = int(tagData[position+3])<<16 | int(tagData[position+4])<<8 | int(tagData[position+5])
position += 6 + frameSize
} else {
// ID3v2.3/2.4: 4-byte size
if position+10 > len(tagData) {
break
}
frameSize = int(tagData[position+4])<<24 | int(tagData[position+5])<<16 |
int(tagData[position+6])<<8 | int(tagData[position+7])
position += 10 + frameSize
}
// Safety check to prevent infinite loops
if frameSize <= 0 || position >= len(tagData) {
break
}
}
return false, nil
}
// hasAlbumArtFLACOptimized checks for PICTURE metadata block (type 6) in FLAC files
func hasAlbumArtFLACOptimized(file *os.File, initialBuffer []byte) (bool, error) {
// Read a reasonable amount for FLAC metadata (typically in first 64KB)
buffer := make([]byte, 32768) // 32KB - 10 bytes already read
n, err := file.Read(buffer)
if err != nil && err != io.EOF {
return false, err
}
// Combine initial buffer with the rest
fullData := append(initialBuffer, buffer[:n]...)
pos := 4 // Skip "fLaC" header
for pos < len(fullData)-4 {
// Read metadata block header (4 bytes total)
blockHeader := fullData[pos]
blockType := blockHeader & 0x7F // bits 0-6
isLast := blockHeader&0x80 != 0 // bit 7
// Found picture block!
if blockType == 6 { // PICTURE block type
return true, nil
}
// Get block size (next 3 bytes, big-endian)
if pos+4 > len(fullData) {
break
}
blockSize := int(fullData[pos+1])<<16 | int(fullData[pos+2])<<8 | int(fullData[pos+3])
// Move to next block
pos += 4 + blockSize
// Stop if this was the last block or we've exceeded buffer
if isLast || pos >= len(fullData) {
break
}
}
return false, nil
}
// hasAlbumArtOGGOptimized checks for METADATA_BLOCK_PICTURE in OGG Vorbis comments
func hasAlbumArtOGGOptimized(file *os.File, initialBuffer []byte) (bool, error) {
// Read a reasonable amount for OGG metadata
buffer := make([]byte, 32768) // 32KB - 10 bytes already read
n, err := file.Read(buffer)
if err != nil && err != io.EOF {
return false, err
}
// Combine initial buffer with the rest
fullData := append(initialBuffer, buffer[:n]...)
// Look for Vorbis comment patterns
vorbisPrefix := []byte("\x03vorbis")
opusPrefix := []byte("OpusTags")
// Search for either Vorbis or Opus tags
for i := 0; i < len(fullData)-8; i++ {
if i+len(vorbisPrefix) < len(fullData) && bytes.Equal(fullData[i:i+len(vorbisPrefix)], vorbisPrefix) {
// Found Vorbis comment block, look for METADATA_BLOCK_PICTURE
return searchForPictureInVorbisComment(fullData[i+len(vorbisPrefix):])
}
if i+len(opusPrefix) < len(fullData) && bytes.Equal(fullData[i:i+len(opusPrefix)], opusPrefix) {
// Found Opus tags block, look for METADATA_BLOCK_PICTURE
return searchForPictureInVorbisComment(fullData[i+len(opusPrefix):])
}
}
return false, nil
}
// searchForPictureInVorbisComment looks for METADATA_BLOCK_PICTURE field in Vorbis comments
func searchForPictureInVorbisComment(buffer []byte) (bool, error) {
// Convert to string and look for the METADATA_BLOCK_PICTURE field
content := string(buffer)
// Look for the exact field name used in Vorbis comments
if strings.Contains(content, "METADATA_BLOCK_PICTURE=") {
return true, nil
}
// Some files might use alternative field names
if strings.Contains(content, "COVERART=") || strings.Contains(content, "COVER_ART=") {
return true, nil
}
return false, nil
}
// hasAlbumArtM4A checks for embedded artwork in M4A/MP4 files
func hasAlbumArtM4A(data []byte) bool {
signatures := [][]byte{
[]byte("\xFF\xD8\xFF"), // JPEG
[]byte("\x89PNG\x0D\x0A\x1A\x0A"), // PNG
[]byte("GIF8"), // GIF
[]byte("BM"), // BMP
[]byte("\x00\x00\x00\x1C\x66\x74\x79\x70"), // MP4/M4A start
}
for _, sig := range signatures {
if bytes.Contains(data, sig) {
return true
}
}
return false
}
func HasAlbumArt(filePath string, extension string) bool {
if !CouldHaveAlbumArt(extension) {
return false
}
// Use low-level detection for all audio formats
hasArt, err := hasAlbumArtLowLevel(filePath, extension)
if err != nil {
// If there's an error with low-level detection, return false
// This is safer than potentially crashing the indexing process
return false
}
return hasArt
}
// DetectTypeByHeader detects the MIME type of a file based on its header.
func DetectTypeByHeader(realPath string) string {
file, err := os.Open(realPath)
if err != nil {
return "blob"
}
defer file.Close()
buffer := make([]byte, 512)
n, err := file.Read(buffer)
if err != nil {
return "blob"
}
return http.DetectContentType(buffer[:n])
}
// returns true if the file name contains the search term
// returns file type if the file name contains the search term
// returns size of file/dir if the file name contains the search term
func (fi ItemInfo) ContainsSearchTerm(searchTerm string, options SearchOptions) bool {
fileTypes := map[string]bool{}
largerThan := int64(options.LargerThan) * 1024 * 1024
smallerThan := int64(options.SmallerThan) * 1024 * 1024
conditions := options.Conditions
lowerFileName := strings.ToLower(fi.Name)
// Convert to lowercase if not exact match
if !conditions["exact"] {
searchTerm = strings.ToLower(searchTerm)
}
// Check if the file name contains the search term
if !strings.Contains(lowerFileName, searchTerm) {
return false
}
// Initialize file size and fileTypes map
var fileSize int64
extension := filepath.Ext(lowerFileName)
// Collect file types
for _, k := range AllFiletypeOptions {
if IsMatchingType(extension, k) {
fileTypes[k] = true
}
}
isDir := fi.Type == "directory"
fileTypes["dir"] = isDir
fileSize = fi.Size
// Evaluate all conditions
for t, v := range conditions {
if t == "exact" {
continue
}
switch t {
case "larger":
if largerThan > 0 {
if fileSize <= largerThan {
return false
}
}
case "smaller":
if smallerThan > 0 {
if fileSize >= smallerThan {
return false
}
}
default:
// Handle other file type conditions
notMatchType := v != fileTypes[t]
if notMatchType {
return false
}
}
}
return true
}
// IsDirectory determines if a path should be treated as a directory.
// It treats known bundle-style directories as files instead.
func IsDirectory(fileInfo os.FileInfo) bool {
if !fileInfo.IsDir() {
return false
}
if !hasBundleExtension(fileInfo.Name()) {
return true
}
// For bundle-type dirs, treat them as files
return false
}
// ShouldBubbleUpToFolderPreview checks if a file type should be used for folder previews.
// Only images, videos, and audio files with album art should bubble up.
// Text files, office documents, and PDFs should NOT bubble up to folder previews.
// This ensures consistency between indexing and preview generation.
func ShouldBubbleUpToFolderPreview(item ItemInfo) bool {
// Get the simple type (e.g., "image", "video", "text")
simpleType := strings.Split(item.Type, "/")[0]
// Text files should NOT bubble up
if simpleType == "text" {
return false
}
// Office documents should NOT bubble up
if IsOnlyOffice(item.Name) {
return false
}
// Document convertable files (PDFs, etc.) should NOT bubble up
if HasDocConvertableExtension(item.Name, item.Type) {
return false
}
// Only allow images, videos, and audio with album art to bubble up
if simpleType == "image" || simpleType == "video" || simpleType == "audio" {
return true
}
return false
}
+10 -4
View File
@@ -3,6 +3,7 @@ package indexing
import (
"fmt"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
@@ -99,14 +100,19 @@ func (idx *Index) GetReducedMetadata(target string, isDir bool) (*iteminfo.FileI
return dir, true
}
// handle file
if checkDir == "/" {
checkDir = ""
}
baseName := filepath.Base(target)
for _, item := range dir.Files {
if item.Name == baseName {
// Use path.Join to properly handle trailing slashes and avoid double slashes
var fp string
if checkDir == "/" {
fp = "/" + item.Name
} else {
// Clean path to remove any trailing slashes before joining
fp = strings.TrimSuffix(checkDir, "/") + "/" + item.Name
}
return &iteminfo.FileInfo{
Path: checkDir + "/" + item.Name,
Path: fp,
ItemInfo: item,
}, true
}
+10 -10
View File
@@ -121,7 +121,7 @@ func TestGetFileMetadata(t *testing.T) {
// Test for UpdateFileMetadata
func TestUpdateFileMetadata(t *testing.T) {
info := &iteminfo.FileInfo{
Path: "/testpath",
Path: "/testpath/",
Files: []iteminfo.ItemInfo{
{Name: "testfile.txt"},
{Name: "anotherfile.txt"},
@@ -130,7 +130,7 @@ func TestUpdateFileMetadata(t *testing.T) {
index := &Index{
Directories: map[string]*iteminfo.FileInfo{
"/testpath": info,
"/testpath/": info,
},
}
@@ -163,8 +163,8 @@ func TestGetDirMetadata(t *testing.T) {
func TestSetDirectoryInfo(t *testing.T) {
index := &Index{
Directories: map[string]*iteminfo.FileInfo{
"/testpath": {
Path: "/testpath",
"/testpath/": {
Path: "/testpath/",
ItemInfo: iteminfo.ItemInfo{
Name: "testpath",
Type: "directory",
@@ -177,7 +177,7 @@ func TestSetDirectoryInfo(t *testing.T) {
},
}
dir := &iteminfo.FileInfo{
Path: "/newPath",
Path: "/newPath/",
ItemInfo: iteminfo.ItemInfo{
Name: "newPath",
Type: "directory",
@@ -187,7 +187,7 @@ func TestSetDirectoryInfo(t *testing.T) {
},
}
index.UpdateMetadata(dir)
storedDir, exists := index.Directories["/newPath"]
storedDir, exists := index.Directories["/newPath/"]
if !exists || storedDir.Files[0].Name != "testfile.txt" {
t.Fatalf("expected SetDirectoryInfo to store directory info correctly")
}
@@ -205,8 +205,8 @@ func init() {
},
Directories: map[string]*iteminfo.FileInfo{
"/testpath": {
Path: "/testpath",
"/testpath/": {
Path: "/testpath/",
ItemInfo: iteminfo.ItemInfo{
Name: "testpath",
Type: "directory",
@@ -216,8 +216,8 @@ func init() {
{Name: "anotherfile.txt", Size: 100},
},
},
"/anotherpath": {
Path: "/anotherpath",
"/anotherpath/": {
Path: "/anotherpath/",
ItemInfo: iteminfo.ItemInfo{
Name: "anotherpath",
Type: "directory",
+20 -11
View File
@@ -26,11 +26,13 @@ type SearchResult struct {
}
func (idx *Index) Search(search string, scope string, sourceSession string) []SearchResult {
scope = strings.TrimSuffix(scope, "/")
// Ensure scope has consistent trailing slash for directory matching
if scope != "" && !strings.HasSuffix(scope, "/") {
scope = scope + "/"
}
if search == "" {
scope = ""
}
// Remove slashes
runningHash := utils.InsecureRandomIdentifier(4)
sessionInProgress.Store(sourceSession, runningHash) // Store the value in the sync.Map
searchOptions := iteminfo.ParseSearch(search)
@@ -53,9 +55,7 @@ func (idx *Index) Search(search string, scope string, sourceSession string) []Se
}
idx.mu.Lock()
for _, dirName := range directories {
idx.mu.Unlock()
dir, found := idx.GetReducedMetadata(dirName, true)
idx.mu.Lock()
dir, found := idx.Directories[dirName]
if !found {
continue
}
@@ -69,15 +69,12 @@ func (idx *Index) Search(search string, scope string, sourceSession string) []Se
}
matches := reducedDir.ContainsSearchTerm(searchTerm, searchOptions)
if matches {
results[dirName+"/"] = SearchResult{Path: dirName + "/", Type: "directory", Size: dir.Size}
results[dirName] = SearchResult{Path: dirName, Type: "directory", Size: dir.Size}
count++
}
// search files first
for _, item := range dir.Files {
fullPath := dirName + "/" + item.Name
if dirName == "/" {
fullPath = dirName + item.Name
}
fullPath := filepath.Join(dirName, item.Name)
value, found := sessionInProgress.Load(sourceSession)
if !found || value != runningHash {
idx.mu.Unlock()
@@ -114,8 +111,20 @@ func (idx *Index) getDirsInScope(scope string) []string {
newList := []string{}
idx.mu.Lock()
defer idx.mu.Unlock()
// If scope is empty, return all directories
if scope == "" {
for k := range idx.Directories {
newList = append(newList, k)
}
return newList
}
// For non-empty scope, include the scope directory itself and all subdirectories
for k := range idx.Directories {
if strings.HasPrefix(k, scope) || scope == "" {
// Match the scope directory exactly (k == scope)
// OR match subdirectories (k starts with scope)
if k == scope || strings.HasPrefix(k, scope) {
newList = append(newList, k)
}
}
+45 -11
View File
@@ -11,7 +11,7 @@ import (
)
func BenchmarkSearchAllIndexes(b *testing.B) {
Initialize(settings.Source{Name: "test", Path: "/srv"}, true)
Initialize(&settings.Source{Name: "test", Path: "/srv"}, true)
idx := GetIndex("test")
idx.CreateMockData(50, 3) // 50 dirs, 3 files per dir
@@ -77,7 +77,7 @@ func TestParseSearch(t *testing.T) {
}
func TestSearchWhileIndexing(t *testing.T) {
Initialize(settings.Source{Name: "test", Path: "/srv"}, true)
Initialize(&settings.Source{Name: "test", Path: "/srv"}, true)
idx := GetIndex("test")
searchTerms := utils.GenerateRandomSearchTerms(10)
@@ -92,21 +92,21 @@ 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{
"/": {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"},
}},
"/first Dir": {
"/first Dir/": {
Files: []iteminfo.ItemInfo{
{Name: "space jam.zip", Size: 100, Type: "archive"},
},
},
"/new/test/path": {Files: []iteminfo.ItemInfo{{Name: "archive.zip", Type: "archive"}}},
"/firstDir": {
"/new/test/path/": {Files: []iteminfo.ItemInfo{{Name: "archive.zip", Type: "archive"}}},
"/firstDir/": {
Files: []iteminfo.ItemInfo{
{Name: "archive.zip", Size: 100, Type: "archive"},
},
@@ -114,7 +114,7 @@ func TestSearchIndexes(t *testing.T) {
{Name: "thisIsDir", Type: "directory", Size: 2 * 1024 * 1024},
},
},
"/firstDir/thisIsDir": {
"/firstDir/thisIsDir/": {
Files: []iteminfo.ItemInfo{
{Name: "hi.txt", Type: "text"},
},
@@ -122,6 +122,13 @@ func TestSearchIndexes(t *testing.T) {
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"},
},
},
},
}
@@ -155,6 +162,11 @@ func TestSearchIndexes(t *testing.T) {
Type: "directory",
Size: 0,
},
{
Path: "/new+folder/Pictures/consoletest.mp4",
Type: "video/mp4",
Size: 196091904,
},
},
},
{
@@ -175,7 +187,7 @@ func TestSearchIndexes(t *testing.T) {
},
{
search: "arch",
scope: "/firstDir",
scope: "/firstDir/",
expectedResult: []SearchResult{
{
Path: "/firstDir/archive.zip",
@@ -254,6 +266,28 @@ func TestSearchIndexes(t *testing.T) {
},
},
},
{
search: "cons",
scope: "/new+folder/Pictures/",
expectedResult: []SearchResult{
{
Path: "/new+folder/Pictures/consoletest.mp4",
Type: "video/mp4",
Size: 196091904,
},
},
},
{
search: "toggle",
scope: "/new+folder/Pictures/",
expectedResult: []SearchResult{
{
Path: "/new+folder/Pictures/toggle.gif",
Type: "image/gif",
Size: 65536,
},
},
},
}
for _, tt := range tests {
@@ -4,6 +4,7 @@
package indexing
import (
"strings"
"syscall"
)
@@ -32,3 +33,19 @@ func getFileDetails(sys any) (uint64, uint64, uint64, bool) {
}
return 0, 1, 0, false
}
// platform specific rules
func (idx *Index) MakeIndexPathPlatform(path string) string {
if idx.mock {
// also do windows check for testing
split := strings.Split(path, "\\")
if len(split) > 1 {
path = strings.Join(split, "/")
} else {
path = "/" + strings.TrimPrefix(path, "/")
}
} else {
path = "/" + strings.TrimPrefix(path, "/")
}
return path
}
@@ -4,6 +4,8 @@
package indexing
import (
"strings"
"golang.org/x/sys/windows"
)
@@ -51,3 +53,14 @@ func getFileDetails(sys any) (uint64, uint64, uint64, bool) {
// We return false to indicate that we should use the fallback mechanism.
return 0, 1, 0, false
}
// input should be non-index path.
func (idx *Index) MakeIndexPathPlatform(path string) string {
split := strings.Split(path, "\\")
if len(split) > 1 {
path = strings.Join(split, "/")
} else {
path = "/" + strings.TrimPrefix(path, "/")
}
return path
}
+13 -1
View File
@@ -5,6 +5,7 @@ package preview
import (
"bytes"
"context"
"fmt"
"image/jpeg"
"io"
@@ -22,7 +23,18 @@ func docEnabled() bool {
return true
}
func (s *Service) GenerateImageFromDoc(file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int) ([]byte, error) {
func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int) ([]byte, error) {
// Check if context is cancelled before starting
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Acquire document semaphore
if err := s.acquireDoc(ctx); err != nil {
return nil, err
}
defer s.releaseDoc()
// 1. Serialize access to the entire go-fitz operation block
s.docGenMutex.Lock()
defer s.docGenMutex.Unlock()
+12 -2
View File
@@ -3,7 +3,11 @@
package preview
import "github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
import (
"context"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func docEnabled() bool {
// This function checks if the PDF support is enabled.
@@ -11,7 +15,13 @@ func docEnabled() bool {
return false
}
func (s *Service) GenerateImageFromDoc(file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int) ([]byte, error) { // 1. Serialize access to the entire go-fitz operation block
func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.ExtendedFileInfo, tempFilePath string, pageNumber int) ([]byte, error) {
// Acquire document semaphore
if err := s.acquireDoc(ctx); err != nil {
return nil, err
}
defer s.releaseDoc()
s.docGenMutex.Lock()
defer s.docGenMutex.Unlock()
+15 -3
View File
@@ -1,17 +1,29 @@
package preview
import (
"context"
"fmt"
)
// convertHEICToJPEGWithFFmpeg converts a HEIC file to JPEG format using FFmpeg
// This function handles all FFmpeg-related logic and parameters
func (s *Service) convertHEICToJPEGWithFFmpeg(filePath string, previewSize string) ([]byte, error) {
func (s *Service) convertHEICToJPEGWithFFmpeg(ctx context.Context, filePath string, previewSize string) ([]byte, error) {
// Check if context is cancelled before starting
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Use the shared image service
if s.imageService == nil {
return nil, fmt.Errorf("image service not available")
}
// Acquire image service semaphore
if err := s.imageService.Acquire(ctx); err != nil {
return nil, err
}
defer s.imageService.Release()
// Determine target dimensions and quality based on preview size
var width, height int
var quality string
@@ -28,7 +40,7 @@ func (s *Service) convertHEICToJPEGWithFFmpeg(filePath string, previewSize strin
quality = "5" // Medium quality
}
// Use tile-based conversion for correct full-resolution image reconstruction
result, err := s.imageService.ConvertHEICToJPEG(filePath, width, height, quality)
result, err := s.imageService.ConvertHEICToJPEG(ctx, filePath, width, height, quality)
if err != nil {
return nil, err
}
+40 -23
View File
@@ -128,14 +128,17 @@ func WithQuality(quality Quality) Option {
}
func (s *Service) Resize(in io.Reader, width, height int, out io.Writer, options ...Option) error {
if err := s.acquire(context.Background()); err != nil {
return err
// Use image service semaphore for image processing
if s.imageService != nil {
if err := s.imageService.Acquire(context.Background()); err != nil {
return err
}
defer s.imageService.Release()
}
defer s.release()
format, wrappedReader, err := s.detectFormat(in)
if err != nil {
return err
return fmt.Errorf("failed to detect image format: %w", err)
}
config := resizeConfig{
@@ -165,7 +168,7 @@ func (s *Service) Resize(in io.Reader, width, height int, out io.Writer, options
// For HEIC files, try without AutoOrientation first since it might not work properly
img, err := imaging.Decode(wrappedReader)
if err != nil {
return err
return fmt.Errorf("failed to decode image: %w", err)
}
// Note: For HEIC files processed via FFmpeg, orientation is handled automatically
@@ -183,12 +186,17 @@ func (s *Service) Resize(in io.Reader, width, height int, out io.Writer, options
}
func (s *Service) detectFormat(in io.Reader) (Format, io.Reader, error) {
buf := &bytes.Buffer{}
r := io.TeeReader(in, buf)
_, imgFormat, err := image.DecodeConfig(r)
// Read all data into a buffer first to avoid consuming the reader twice
allData, err := io.ReadAll(in)
if err != nil {
return 0, nil, fmt.Errorf("%s: %w", err.Error(), ErrUnsupportedFormat)
return 0, nil, fmt.Errorf("failed to read image data: %w", err)
}
// Create a reader for format detection
reader := bytes.NewReader(allData)
_, imgFormat, err := image.DecodeConfig(reader)
if err != nil {
return 0, nil, fmt.Errorf("image.DecodeConfig failed (data size: %d bytes): %s: %w", len(allData), err.Error(), ErrUnsupportedFormat)
}
if imgFormat == "heif" {
@@ -200,31 +208,40 @@ func (s *Service) detectFormat(in io.Reader) (Format, io.Reader, error) {
return 0, nil, ErrUnsupportedFormat
}
return format, io.MultiReader(buf, in), nil
// Return a new reader with all the data for subsequent operations
return format, bytes.NewReader(allData), nil
}
func getEmbeddedThumbnail(in io.Reader) ([]byte, io.Reader, error) {
buf := &bytes.Buffer{}
r := io.TeeReader(in, buf)
wrappedReader := io.MultiReader(buf, in)
offsets := []int{12, 30}
head := make([]byte, 0xffff)
_, err := r.Read(head)
// Read all data to avoid partial consumption issues
allData, err := io.ReadAll(in)
if err != nil {
return nil, wrappedReader, err
return nil, nil, err
}
// Create a reader that can be returned for fallback processing
wrappedReader := bytes.NewReader(allData)
// Try to read up to 0xffff bytes for EXIF header parsing
// Use the actual data size if file is smaller
headSize := 0xffff
if len(allData) < headSize {
headSize = len(allData)
}
head := allData[:headSize]
offsets := []int{12, 30}
var offset int
var exifErr error
for _, offset = range offsets {
if _, err = exif.ParseExifHeader(head[offset:]); err == nil {
if _, exifErr = exif.ParseExifHeader(head[offset:]); exifErr == nil {
break
}
}
if err != nil {
return nil, wrappedReader, err
if exifErr != nil {
return nil, wrappedReader, exifErr
}
im, err := exifcommon.NewIfdMappingWithStandard()
+4 -4
View File
@@ -21,11 +21,11 @@ type officePreviewResponse struct {
}
// GenerateOfficePreview generates a preview for an office document using OnlyOffice.
func (s *Service) GenerateOfficePreview(filetype, key, title, url string) ([]byte, error) {
if err := s.acquire(context.Background()); err != nil {
func (s *Service) GenerateOfficePreview(ctx context.Context, filetype, key, title, url string) ([]byte, error) {
if err := s.acquireOffice(ctx); err != nil {
return nil, err
}
defer s.release()
defer s.releaseOffice()
data := []byte{}
// Create the request payload
requestPayload := map[string]interface{}{
@@ -59,7 +59,7 @@ func (s *Service) GenerateOfficePreview(filetype, key, title, url string) ([]byt
convertURL = settings.Config.Integrations.OnlyOffice.InternalUrl + "/converter"
}
// Send the request with buf.Bytes() — not jsonData
req, err := http.NewRequest("POST", convertURL, buf)
req, err := http.NewRequestWithContext(ctx, "POST", convertURL, buf)
if err != nil {
return data, err
}
+117 -73
View File
@@ -14,11 +14,11 @@ import (
"runtime"
"strings"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/diskcache"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
@@ -31,17 +31,23 @@ var (
)
type Service struct {
sem chan struct{}
ffmpegPath string
ffprobePath string
fileCache diskcache.Interface
debug bool
docGenMutex sync.Mutex // Mutex to serialize access to doc generation
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
}
func NewPreviewGenerator(concurrencyLimit int, ffmpegPath string, cacheDir string) *Service {
if concurrencyLimit < 1 {
concurrencyLimit = 1
}
// get round up half value of concurrencyLimit
ffmpegConcurrencyLimit := (concurrencyLimit + 1) / 2
var fileCache diskcache.Interface
// Use file cache if cacheDir is specified
if cacheDir != "" {
@@ -82,6 +88,7 @@ func NewPreviewGenerator(concurrencyLimit int, ffmpegPath string, cacheDir strin
settings.Config.Integrations.Media.FfmpegPath = filepath.Base(ffmpegMainPath)
}
logger.Debugf("Media Enabled : %v", ffmpegMainPath != "" && ffprobePath != "")
logger.Debugf("FFmpeg Concurrency Limit : %d", ffmpegConcurrencyLimit)
settings.Config.Server.MuPdfAvailable = docEnabled()
logger.Debugf("MuPDF Enabled : %v", settings.Config.Server.MuPdfAvailable)
@@ -90,83 +97,107 @@ func NewPreviewGenerator(concurrencyLimit int, ffmpegPath string, cacheDir strin
var imageService *ffmpeg.ImageService
if ffmpegMainPath != "" && ffprobePath != "" {
videoService = ffmpeg.NewVideoService(ffmpegMainPath, ffprobePath, concurrencyLimit, settings.Config.Server.DebugMedia)
imageService = ffmpeg.NewImageService(ffmpegMainPath, ffprobePath, settings.Config.Server.DebugMedia, filepath.Join(settings.Config.Server.CacheDir, "heic"))
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"))
}
return &Service{
sem: make(chan struct{}, concurrencyLimit),
ffmpegPath: ffmpegMainPath,
ffprobePath: ffprobePath,
fileCache: fileCache,
debug: settings.Config.Server.DebugMedia,
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),
officeSem: make(chan struct{}, concurrencyLimit),
videoService: videoService,
imageService: imageService,
}
}
// Document semaphore methods
func (s *Service) acquireDoc(ctx context.Context) error {
select {
case s.docSemaphore <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) releaseDoc() {
<-s.docSemaphore
}
// Office semaphore methods
func (s *Service) acquireOffice(ctx context.Context) error {
select {
case s.officeSem <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) releaseOffice() {
<-s.officeSem
}
func StartPreviewGenerator(concurrencyLimit int, ffmpegPath, cacheDir string) error {
if service != nil {
logger.Errorf("WARNING: StartPreviewGenerator called multiple times! This will create multiple semaphores!")
}
service = NewPreviewGenerator(concurrencyLimit, ffmpegPath, cacheDir)
return nil
}
func GetPreviewForFile(file iteminfo.ExtendedFileInfo, previewSize, url string, seekPercentage int) ([]byte, error) {
return GetPreviewForFileWithChildMD5(file, previewSize, url, seekPercentage, "")
}
func GetPreviewForFileWithChildMD5(file iteminfo.ExtendedFileInfo, previewSize, url string, seekPercentage int, childMD5 string) ([]byte, error) {
func GetPreviewForFile(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, url string, seekPercentage int) ([]byte, error) {
if !file.HasPreview {
return nil, ErrUnsupportedMedia
}
var thisMd5 string
if childMD5 != "" {
// Use the child's MD5 for folder previews to ensure cache sharing
thisMd5 = childMD5
} else if file.AudioMeta != nil && file.AudioMeta.AlbumArt != "" {
// md5 is based on album art
// md5 file.AlbumArt
// Check if context is cancelled before starting
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Generate fast cache key based on file metadata
var cacheHash string
if file.AudioMeta != nil && file.AudioMeta.AlbumArt != "" {
// For audio with album art, hash the album art content
hasher := md5.New()
_, _ = hasher.Write([]byte(file.AudioMeta.AlbumArt))
thisMd5 = hex.EncodeToString(hasher.Sum(nil))
file.Checksums = make(map[string]string)
file.Checksums["md5"] = thisMd5
cacheHash = hex.EncodeToString(hasher.Sum(nil))
} else {
var err error
thisMd5, err = utils.GetChecksum(file.RealPath, "md5")
if err != nil {
return nil, fmt.Errorf("failed to get checksum: %w", err)
}
// Ensure the file.Checksums map is initialized and MD5 is set
if file.Checksums == nil {
file.Checksums = make(map[string]string)
}
file.Checksums["md5"] = thisMd5
// For all other files, use fast metadata-based hash
hasher := md5.New()
cacheString := fmt.Sprintf("%s:%d:%s", file.RealPath, file.Size, file.ModTime.Format(time.RFC3339Nano))
_, _ = hasher.Write([]byte(cacheString))
cacheHash = hex.EncodeToString(hasher.Sum(nil))
}
// Validate that MD5 is not empty to prevent cache corruption
if thisMd5 == "" {
errorMsg := fmt.Sprintf("MD5 is empty for file: %s (path: %s)", file.Name, file.RealPath)
logger.Errorf("Preview generation failed: %s", errorMsg)
return nil, fmt.Errorf("preview generation failed: %s", errorMsg)
}
cacheKey := CacheKey(thisMd5, previewSize, seekPercentage)
if data, found, err := service.fileCache.Load(context.Background(), cacheKey); err != nil {
cacheKey := CacheKey(cacheHash, previewSize, seekPercentage)
if data, found, err := service.fileCache.Load(ctx, cacheKey); err != nil {
return nil, fmt.Errorf("failed to load from cache: %w", err)
} else if found {
return data, nil
}
return GeneratePreviewWithMD5(file, previewSize, url, seekPercentage, thisMd5)
return GeneratePreviewWithMD5(ctx, file, previewSize, url, seekPercentage, cacheHash)
}
func GeneratePreviewWithMD5(file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int, fileMD5 string) ([]byte, error) {
// Validate that MD5 is not empty to prevent cache corruption
func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int, fileMD5 string) ([]byte, error) {
// Note: fileMD5 is actually a cache hash (metadata-based), not a true file content MD5
// Validate that cache hash is not empty to prevent cache corruption
if fileMD5 == "" {
errorMsg := fmt.Sprintf("MD5 is empty for file: %s (path: %s)", file.Name, file.RealPath)
errorMsg := fmt.Sprintf("Cache hash is empty for file: %s (path: %s)", file.Name, file.RealPath)
logger.Errorf("Preview generation failed: %s", errorMsg)
return nil, fmt.Errorf("preview generation failed: %s", errorMsg)
}
// Check if context is cancelled before starting
if ctx.Err() != nil {
return nil, ctx.Err()
}
ext := strings.ToLower(filepath.Ext(file.Name))
var (
err error
@@ -180,30 +211,31 @@ func GeneratePreviewWithMD5(file iteminfo.ExtendedFileInfo, previewSize, officeU
// Generate an image from office document
if iteminfo.HasDocConvertableExtension(file.Name, file.Type) {
tempFilePath := filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "docs", hash) + ".txt"
imageBytes, err = service.GenerateImageFromDoc(file, tempFilePath, 0) // 0 for the first page
imageBytes, err = service.GenerateImageFromDoc(ctx, file, tempFilePath, 0) // 0 for the first page
if err != nil {
return nil, fmt.Errorf("failed to create image for PDF file: %w", err)
}
} else if file.OnlyOfficeId != "" {
imageBytes, err = service.GenerateOfficePreview(filepath.Ext(file.Name), file.OnlyOfficeId, file.Name, officeUrl)
imageBytes, err = service.GenerateOfficePreview(ctx, filepath.Ext(file.Name), file.OnlyOfficeId, file.Name, officeUrl)
if err != nil {
return nil, fmt.Errorf("failed to create image for office file: %w", err)
}
} else if strings.HasPrefix(file.Type, "image/heic") {
// HEIC files need FFmpeg conversion to JPEG with proper size/quality handling
imageBytes, err = service.convertHEICToJPEGWithFFmpeg(file.RealPath, previewSize)
imageBytes, err = service.convertHEICToJPEGWithFFmpeg(ctx, file.RealPath, previewSize)
if err != nil {
return nil, fmt.Errorf("failed to process HEIC image file: %w", err)
}
// For HEIC files, we've already done the resize/conversion, so cache and return directly
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err = service.fileCache.Store(context.Background(), cacheKey, imageBytes); err != nil {
if err = service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache HEIC image: %v", err)
}
return imageBytes, nil
} else if strings.HasPrefix(file.Type, "image") {
imageBytes, err = os.ReadFile(file.RealPath)
if err != nil {
logger.Errorf("Failed to read image file '%s' (path: %s): %v", file.Name, file.RealPath, err)
return nil, fmt.Errorf("failed to read image file: %w", err)
}
} else if strings.HasPrefix(file.Type, "video") {
@@ -211,10 +243,13 @@ func GeneratePreviewWithMD5(file iteminfo.ExtendedFileInfo, previewSize, officeU
if videoSeekPercentage == 0 {
videoSeekPercentage = 10
}
outPathPattern := filepath.Join(settings.Config.Server.CacheDir, "thumbnails", "videos", hash) + ".jpg"
defer os.Remove(outPathPattern) // cleanup
imageBytes, err = service.GenerateVideoPreview(file.RealPath, outPathPattern, videoSeekPercentage)
imageBytes, err = service.GenerateVideoPreview(ctx, file.RealPath, videoSeekPercentage)
if err != nil {
// Don't log client cancellations as errors
if ctx.Err() != context.Canceled {
logger.Errorf("Video preview generation failed for '%s' (path: %s, seek: %d%%): %v",
file.Name, file.RealPath, videoSeekPercentage, err)
}
return nil, fmt.Errorf("failed to create image for video file: %w", err)
}
} else if strings.HasPrefix(file.Type, "audio") {
@@ -225,12 +260,20 @@ func GeneratePreviewWithMD5(file iteminfo.ExtendedFileInfo, previewSize, officeU
return nil, fmt.Errorf("failed to decode album artwork: %w", err)
}
} else {
return nil, fmt.Errorf("no album artwork available for audio file: %s", file.Name)
return nil, nil
}
} else {
return nil, fmt.Errorf("unsupported media type: %s", ext)
}
// Check if context was cancelled during processing
if ctx.Err() != nil {
return nil, ctx.Err()
}
if len(imageBytes) < 100 {
logger.Errorf("Generated image too small for '%s' (type: %s): %d bytes - likely an error occurred",
file.Name, file.Type, len(imageBytes))
return nil, fmt.Errorf("generated image is too small, likely an error occurred: %d bytes", len(imageBytes))
}
@@ -242,13 +285,13 @@ func GeneratePreviewWithMD5(file iteminfo.ExtendedFileInfo, previewSize, officeU
}
// Cache and return
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err := service.fileCache.Store(context.Background(), cacheKey, resizedBytes); err != nil {
if err := service.fileCache.Store(ctx, cacheKey, resizedBytes); err != nil {
logger.Errorf("failed to cache resized image: %v", err)
}
return resizedBytes, nil
} else {
cacheKey := CacheKey(fileMD5, previewSize, seekPercentage)
if err := service.fileCache.Store(context.Background(), cacheKey, imageBytes); err != nil {
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache original image: %v", err)
}
return imageBytes, nil
@@ -256,19 +299,14 @@ func GeneratePreviewWithMD5(file iteminfo.ExtendedFileInfo, previewSize, officeU
}
func GeneratePreview(file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int) ([]byte, error) {
// For backward compatibility, calculate MD5 if not provided
var fileMD5 string
if file.Checksums != nil && file.Checksums["md5"] != "" {
fileMD5 = file.Checksums["md5"]
} else {
var err error
fileMD5, err = utils.GetChecksum(file.RealPath, "md5")
if err != nil {
return nil, fmt.Errorf("failed to get checksum: %w", err)
}
}
return GeneratePreviewWithMD5(file, previewSize, officeUrl, seekPercentage, fileMD5)
func GeneratePreview(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize, officeUrl string, seekPercentage int) ([]byte, error) {
// Generate fast metadata-based cache key
hasher := md5.New()
cacheString := fmt.Sprintf("%s:%d:%s", file.RealPath, file.Size, file.ModTime.Format(time.RFC3339Nano))
_, _ = hasher.Write([]byte(cacheString))
cacheHash := hex.EncodeToString(hasher.Sum(nil))
return GeneratePreviewWithMD5(ctx, file, previewSize, officeUrl, seekPercentage, cacheHash)
}
func (s *Service) CreatePreview(data []byte, previewSize string) ([]byte, error) {
@@ -305,9 +343,15 @@ func CacheKey(md5, previewSize string, percentage int) string {
}
func DelThumbs(ctx context.Context, file iteminfo.ExtendedFileInfo) {
errSmall := service.fileCache.Delete(ctx, CacheKey(file.Checksums["md5"], "small", 0))
// Generate metadata-based cache hash for deletion
hasher := md5.New()
cacheString := fmt.Sprintf("%s:%d:%s", file.RealPath, file.Size, file.ModTime.Format(time.RFC3339Nano))
_, _ = hasher.Write([]byte(cacheString))
cacheHash := hex.EncodeToString(hasher.Sum(nil))
errSmall := service.fileCache.Delete(ctx, CacheKey(cacheHash, "small", 0))
if errSmall != nil {
errLarge := service.fileCache.Delete(ctx, CacheKey(file.Checksums["md5"], "large", 0))
errLarge := service.fileCache.Delete(ctx, CacheKey(cacheHash, "large", 0))
if errLarge != nil {
logger.Debugf("Could not delete thumbnail: %v", file.Name)
}
-21
View File
@@ -1,21 +0,0 @@
package preview
import (
"context"
)
func (s *Service) acquire(ctx context.Context) error {
select {
case s.sem <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Service) release() {
select {
case <-s.sem:
default:
}
}
+28 -10
View File
@@ -1,23 +1,41 @@
package preview
import (
"bytes"
"context"
"fmt"
"os"
"github.com/gtsteffaniak/go-logger/logger"
)
// GenerateVideoPreview generates a single preview image from a video using ffmpeg.
// videoPath: path to the input video file.
// outputPath: path where the generated preview image will be saved (e.g., "/tmp/preview.jpg").
// seekTime: how many seconds into the video to seek before capturing the frame.
func (s *Service) GenerateVideoPreview(videoPath, outputPath string, percentageSeek int) ([]byte, error) {
if err := s.acquire(context.Background()); err != nil {
return nil, err
}
defer s.release()
// Use the shared video service
// percentageSeek: percentage of video duration to seek to (0–100).
// Returns: JPEG image bytes.
func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath string, percentageSeek int) ([]byte, error) {
if s.videoService == nil {
logger.Errorf("Video service not available for file '%s'", videoPath)
return nil, fmt.Errorf("video service not available")
}
return s.videoService.GenerateVideoPreview(videoPath, outputPath, percentageSeek)
// Validate file exists before processing
if _, err := os.Stat(videoPath); err != nil {
logger.Errorf("Video file does not exist or is not accessible: '%s': %v", videoPath, err)
return nil, fmt.Errorf("video file not accessible: %w", err)
}
var buf bytes.Buffer
err := s.videoService.GenerateVideoPreviewStreaming(ctx, videoPath, percentageSeek, &buf)
if err != nil {
return nil, err
}
previewBytes := buf.Bytes()
if len(previewBytes) == 0 {
logger.Errorf("Video service returned empty result for '%s'", videoPath)
return nil, fmt.Errorf("video preview generation returned empty result")
}
return previewBytes, nil
}
+382 -119
View File
@@ -499,11 +499,6 @@ const docTemplate = `{
},
"/api/auth/otp/generate": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Generates a new TOTP secret and QR code for the authenticated user.",
"consumes": [
"application/json"
@@ -534,16 +529,16 @@ const docTemplate = `{
}
}
}
}
}
},
"/api/auth/otp/verify": {
"post": {
},
"security": [
{
"ApiKeyAuth": []
}
],
]
}
},
"/api/auth/otp/verify": {
"post": {
"description": "Verifies the provided TOTP code for the authenticated user.",
"consumes": [
"application/json"
@@ -580,7 +575,12 @@ const docTemplate = `{
}
}
}
}
},
"security": [
{
"ApiKeyAuth": []
}
]
}
},
"/api/auth/token": {
@@ -1027,96 +1027,6 @@ const docTemplate = `{
}
}
},
"/api/public/preview": {
"get": {
"description": "Returns a preview image based on the requested path and size.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Resources"
],
"summary": "Get image preview",
"parameters": [
{
"type": "string",
"description": "source hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "File path of the image to preview",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Preview size ('small' or 'large'). Default is based on server config.",
"name": "size",
"in": "query"
}
],
"responses": {
"200": {
"description": "Preview image content",
"schema": {
"type": "file"
}
},
"202": {
"description": "Download permissions required",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"400": {
"description": "Invalid request path",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "File not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"415": {
"description": "Unsupported file type for preview",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/raw": {
"get": {
"description": "Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.",
@@ -2232,25 +2142,114 @@ const docTemplate = `{
}
}
},
"/public/dl": {
"/public/api/preview": {
"get": {
"description": "Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.",
"description": "Returns a preview (thumbnail) for images or videos accessible via a public share. Preview generation can be disabled globally or per-share. Not available for upload-only shares.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
"image/jpeg"
],
"tags": [
"Resources"
"Public Shares"
],
"summary": "Get raw content of a file, multiple files, or directory",
"summary": "Get image/video preview from a public share",
"parameters": [
{
"type": "string",
"description": "if specified, only the files in the list will be downloaded. eg. files=/file1||/folder/file2",
"name": "files",
"description": "Share hash for authentication",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "File path within the share to preview",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Preview size: 'small' or 'large'. Default is based on server config.",
"name": "size",
"in": "query"
}
],
"responses": {
"200": {
"description": "Preview image content (JPEG)",
"schema": {
"type": "file"
}
},
"403": {
"description": "Share unavailable or access denied",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "File not found or preview not available",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"501": {
"description": "Previews disabled globally, for this share, or for upload shares",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/public/api/raw": {
"get": {
"description": "Downloads raw content from a public share. Supports single files, multiple files, or directories as archives. Enforces download limits (global or per-user) and blocks anonymous users when per-user limits are enabled.",
"consumes": [
"application/json"
],
"produces": [
"application/octet-stream"
],
"tags": [
"Public Shares"
],
"summary": "Download files from a public share",
"parameters": [
{
"type": "string",
"description": "Share hash for authentication",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Files to download in format: 'source::path||source::path'. Example: '/file1||/folder/file2'",
"name": "files",
"in": "query",
"required": true
},
{
"type": "boolean",
@@ -2272,8 +2271,8 @@ const docTemplate = `{
"type": "file"
}
},
"202": {
"description": "Modify permissions required",
"400": {
"description": "Invalid request path or encoding",
"schema": {
"type": "object",
"additionalProperties": {
@@ -2281,8 +2280,8 @@ const docTemplate = `{
}
}
},
"400": {
"description": "Invalid request path",
"403": {
"description": "Download limit reached, anonymous access blocked, or share unavailable",
"schema": {
"type": "object",
"additionalProperties": {
@@ -2291,7 +2290,7 @@ const docTemplate = `{
}
},
"404": {
"description": "File or directory not found",
"description": "Share not found or file not found",
"schema": {
"type": "object",
"additionalProperties": {
@@ -2307,6 +2306,204 @@ const docTemplate = `{
"type": "string"
}
}
},
"501": {
"description": "Downloads disabled for upload shares",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/public/api/resources": {
"post": {
"description": "Handles file and directory uploads to an upload-only public share. Supports chunked uploads, conflict resolution (override), and directory creation.",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"Public Shares"
],
"summary": "Upload files to a public upload share",
"parameters": [
{
"type": "string",
"description": "Share hash for authentication",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Target path within the share to upload to. Must be relative to share root.",
"name": "targetPath",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "If true, overwrite existing files/folders. Defaults to false.",
"name": "override",
"in": "query"
},
{
"type": "string",
"description": "Upload action: 'override' to replace files, 'rename' to auto-rename",
"name": "action",
"in": "query"
},
{
"type": "file",
"description": "File to upload",
"name": "file",
"in": "formData",
"required": true
}
],
"responses": {
"200": {
"description": "Upload successful",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"400": {
"description": "Invalid request or parameters",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Share unavailable or upload not allowed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Share not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"409": {
"description": "File or directory already exists (conflict)",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error during upload",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"501": {
"description": "Uploading disabled for non-upload shares",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/public/api/share": {
"get": {
"description": "Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Public Shares"
],
"summary": "Get file/directory information from a public share",
"parameters": [
{
"type": "string",
"description": "Share hash for authentication",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share to retrieve information for. Defaults to share root.",
"name": "path",
"in": "query"
}
],
"responses": {
"200": {
"description": "File or directory metadata",
"schema": {
"$ref": "#/definitions/iteminfo.FileInfo"
}
},
"403": {
"description": "Share unavailable or access denied",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Share not found or file not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"501": {
"description": "Browsing disabled for upload shares",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
@@ -2743,11 +2940,35 @@ const docTemplate = `{
"type": "object",
"properties": {
"imagePreview": {
"description": "supported image preview formats. default is heic",
"description": "supported image preview formats. defaults to false for all types unless explicitly enabled.",
"type": "object",
"additionalProperties": {
"type": "boolean"
}
},
"videoPreview": {
"description": "supported video preview formats. defaults to true for all types unless explicitly disabled.",
"type": "object",
"additionalProperties": {
"type": "boolean"
}
}
}
},
"settings.Filesystem": {
"type": "object",
"required": [
"createDirectoryPermission",
"createFilePermission"
],
"properties": {
"createDirectoryPermission": {
"description": "Unix permissions like 755, 2755, 1777 (default: 755)",
"type": "string"
},
"createFilePermission": {
"description": "Unix permissions like 644, 755, 2755 (default: 644)",
"type": "string"
}
}
},
@@ -2877,6 +3098,14 @@ const docTemplate = `{
}
]
},
"debug": {
"description": "output ffmpeg stdout for media integration -- careful can produces lots of output!",
"type": "boolean"
},
"extractEmbeddedSubtitles": {
"description": "extract embedded subtitles from media files",
"type": "boolean"
},
"ffmpegPath": {
"description": "path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)",
"type": "string"
@@ -3042,10 +3271,6 @@ const docTemplate = `{
"description": "path to the database file",
"type": "string"
},
"debugMedia": {
"description": "output ffmpeg stdout for media integration -- careful can produces lots of output!",
"type": "boolean"
},
"disablePreviewResize": {
"description": "disable resizing of previews for faster loading over slow connections",
"type": "boolean"
@@ -3066,6 +3291,14 @@ const docTemplate = `{
"description": "used by share links if set (eg. http://mydomain.com)",
"type": "string"
},
"filesystem": {
"description": "filesystem settings",
"allOf": [
{
"$ref": "#/definitions/settings.Filesystem"
}
]
},
"internalUrl": {
"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"
@@ -3262,6 +3495,10 @@ const docTemplate = `{
"description": "debug onlyoffice editor",
"type": "boolean"
},
"defaultLandingPage": {
"description": "default landing page to use if no redirect is specified: eg. /files/mysource/mysubpath, /settings, etc.",
"type": "string"
},
"deleteWithoutConfirming": {
"description": "delete files without confirmation",
"type": "boolean"
@@ -3419,6 +3656,10 @@ const docTemplate = `{
"expires": {
"type": "string"
},
"extractEmbeddedSubtitles": {
"description": "can be io intensive for large files and take 10-30 seconds.",
"type": "boolean"
},
"favicon": {
"type": "string"
},
@@ -3440,12 +3681,19 @@ const docTemplate = `{
"path": {
"type": "string"
},
"perUserDownloadLimit": {
"type": "boolean"
},
"quickDownload": {
"type": "boolean"
},
"shareTheme": {
"type": "string"
},
"shareType": {
"description": "type of share: normal, upload, max",
"type": "string"
},
"source": {
"description": "backend source is path to maintain between name changes",
"type": "string"
@@ -3521,6 +3769,10 @@ const docTemplate = `{
"expire": {
"type": "integer"
},
"extractEmbeddedSubtitles": {
"description": "can be io intensive for large files and take 10-30 seconds.",
"type": "boolean"
},
"favicon": {
"type": "string"
},
@@ -3542,12 +3794,19 @@ const docTemplate = `{
"path": {
"type": "string"
},
"perUserDownloadLimit": {
"type": "boolean"
},
"quickDownload": {
"type": "boolean"
},
"shareTheme": {
"type": "string"
},
"shareType": {
"description": "type of share: normal, upload, max",
"type": "string"
},
"source": {
"description": "backend source is path to maintain between name changes",
"type": "string"
@@ -3733,6 +3992,10 @@ const docTemplate = `{
"description": "debug onlyoffice editor",
"type": "boolean"
},
"defaultLandingPage": {
"description": "default landing page to use: eg. /files/mysource/mysubpath, /settings, etc.",
"type": "string"
},
"deleteWithoutConfirming": {
"description": "delete files without confirmation",
"type": "boolean"
+382 -119
View File
@@ -488,11 +488,6 @@
},
"/api/auth/otp/generate": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"description": "Generates a new TOTP secret and QR code for the authenticated user.",
"consumes": [
"application/json"
@@ -523,16 +518,16 @@
}
}
}
}
}
},
"/api/auth/otp/verify": {
"post": {
},
"security": [
{
"ApiKeyAuth": []
}
],
]
}
},
"/api/auth/otp/verify": {
"post": {
"description": "Verifies the provided TOTP code for the authenticated user.",
"consumes": [
"application/json"
@@ -569,7 +564,12 @@
}
}
}
}
},
"security": [
{
"ApiKeyAuth": []
}
]
}
},
"/api/auth/token": {
@@ -1016,96 +1016,6 @@
}
}
},
"/api/public/preview": {
"get": {
"description": "Returns a preview image based on the requested path and size.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Resources"
],
"summary": "Get image preview",
"parameters": [
{
"type": "string",
"description": "source hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "File path of the image to preview",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Preview size ('small' or 'large'). Default is based on server config.",
"name": "size",
"in": "query"
}
],
"responses": {
"200": {
"description": "Preview image content",
"schema": {
"type": "file"
}
},
"202": {
"description": "Download permissions required",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"400": {
"description": "Invalid request path",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "File not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"415": {
"description": "Unsupported file type for preview",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/raw": {
"get": {
"description": "Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.",
@@ -2221,25 +2131,114 @@
}
}
},
"/public/dl": {
"/public/api/preview": {
"get": {
"description": "Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.",
"description": "Returns a preview (thumbnail) for images or videos accessible via a public share. Preview generation can be disabled globally or per-share. Not available for upload-only shares.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
"image/jpeg"
],
"tags": [
"Resources"
"Public Shares"
],
"summary": "Get raw content of a file, multiple files, or directory",
"summary": "Get image/video preview from a public share",
"parameters": [
{
"type": "string",
"description": "if specified, only the files in the list will be downloaded. eg. files=/file1||/folder/file2",
"name": "files",
"description": "Share hash for authentication",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "File path within the share to preview",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Preview size: 'small' or 'large'. Default is based on server config.",
"name": "size",
"in": "query"
}
],
"responses": {
"200": {
"description": "Preview image content (JPEG)",
"schema": {
"type": "file"
}
},
"403": {
"description": "Share unavailable or access denied",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "File not found or preview not available",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"501": {
"description": "Previews disabled globally, for this share, or for upload shares",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/public/api/raw": {
"get": {
"description": "Downloads raw content from a public share. Supports single files, multiple files, or directories as archives. Enforces download limits (global or per-user) and blocks anonymous users when per-user limits are enabled.",
"consumes": [
"application/json"
],
"produces": [
"application/octet-stream"
],
"tags": [
"Public Shares"
],
"summary": "Download files from a public share",
"parameters": [
{
"type": "string",
"description": "Share hash for authentication",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Files to download in format: 'source::path||source::path'. Example: '/file1||/folder/file2'",
"name": "files",
"in": "query",
"required": true
},
{
"type": "boolean",
@@ -2261,8 +2260,8 @@
"type": "file"
}
},
"202": {
"description": "Modify permissions required",
"400": {
"description": "Invalid request path or encoding",
"schema": {
"type": "object",
"additionalProperties": {
@@ -2270,8 +2269,8 @@
}
}
},
"400": {
"description": "Invalid request path",
"403": {
"description": "Download limit reached, anonymous access blocked, or share unavailable",
"schema": {
"type": "object",
"additionalProperties": {
@@ -2280,7 +2279,7 @@
}
},
"404": {
"description": "File or directory not found",
"description": "Share not found or file not found",
"schema": {
"type": "object",
"additionalProperties": {
@@ -2296,6 +2295,204 @@
"type": "string"
}
}
},
"501": {
"description": "Downloads disabled for upload shares",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/public/api/resources": {
"post": {
"description": "Handles file and directory uploads to an upload-only public share. Supports chunked uploads, conflict resolution (override), and directory creation.",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"Public Shares"
],
"summary": "Upload files to a public upload share",
"parameters": [
{
"type": "string",
"description": "Share hash for authentication",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Target path within the share to upload to. Must be relative to share root.",
"name": "targetPath",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "If true, overwrite existing files/folders. Defaults to false.",
"name": "override",
"in": "query"
},
{
"type": "string",
"description": "Upload action: 'override' to replace files, 'rename' to auto-rename",
"name": "action",
"in": "query"
},
{
"type": "file",
"description": "File to upload",
"name": "file",
"in": "formData",
"required": true
}
],
"responses": {
"200": {
"description": "Upload successful",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"400": {
"description": "Invalid request or parameters",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Share unavailable or upload not allowed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Share not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"409": {
"description": "File or directory already exists (conflict)",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error during upload",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"501": {
"description": "Uploading disabled for non-upload shares",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/public/api/share": {
"get": {
"description": "Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Public Shares"
],
"summary": "Get file/directory information from a public share",
"parameters": [
{
"type": "string",
"description": "Share hash for authentication",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share to retrieve information for. Defaults to share root.",
"name": "path",
"in": "query"
}
],
"responses": {
"200": {
"description": "File or directory metadata",
"schema": {
"$ref": "#/definitions/iteminfo.FileInfo"
}
},
"403": {
"description": "Share unavailable or access denied",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Share not found or file not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"501": {
"description": "Browsing disabled for upload shares",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
@@ -2732,11 +2929,35 @@
"type": "object",
"properties": {
"imagePreview": {
"description": "supported image preview formats. default is heic",
"description": "supported image preview formats. defaults to false for all types unless explicitly enabled.",
"type": "object",
"additionalProperties": {
"type": "boolean"
}
},
"videoPreview": {
"description": "supported video preview formats. defaults to true for all types unless explicitly disabled.",
"type": "object",
"additionalProperties": {
"type": "boolean"
}
}
}
},
"settings.Filesystem": {
"type": "object",
"required": [
"createDirectoryPermission",
"createFilePermission"
],
"properties": {
"createDirectoryPermission": {
"description": "Unix permissions like 755, 2755, 1777 (default: 755)",
"type": "string"
},
"createFilePermission": {
"description": "Unix permissions like 644, 755, 2755 (default: 644)",
"type": "string"
}
}
},
@@ -2866,6 +3087,14 @@
}
]
},
"debug": {
"description": "output ffmpeg stdout for media integration -- careful can produces lots of output!",
"type": "boolean"
},
"extractEmbeddedSubtitles": {
"description": "extract embedded subtitles from media files",
"type": "boolean"
},
"ffmpegPath": {
"description": "path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)",
"type": "string"
@@ -3031,10 +3260,6 @@
"description": "path to the database file",
"type": "string"
},
"debugMedia": {
"description": "output ffmpeg stdout for media integration -- careful can produces lots of output!",
"type": "boolean"
},
"disablePreviewResize": {
"description": "disable resizing of previews for faster loading over slow connections",
"type": "boolean"
@@ -3055,6 +3280,14 @@
"description": "used by share links if set (eg. http://mydomain.com)",
"type": "string"
},
"filesystem": {
"description": "filesystem settings",
"allOf": [
{
"$ref": "#/definitions/settings.Filesystem"
}
]
},
"internalUrl": {
"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"
@@ -3251,6 +3484,10 @@
"description": "debug onlyoffice editor",
"type": "boolean"
},
"defaultLandingPage": {
"description": "default landing page to use if no redirect is specified: eg. /files/mysource/mysubpath, /settings, etc.",
"type": "string"
},
"deleteWithoutConfirming": {
"description": "delete files without confirmation",
"type": "boolean"
@@ -3408,6 +3645,10 @@
"expires": {
"type": "string"
},
"extractEmbeddedSubtitles": {
"description": "can be io intensive for large files and take 10-30 seconds.",
"type": "boolean"
},
"favicon": {
"type": "string"
},
@@ -3429,12 +3670,19 @@
"path": {
"type": "string"
},
"perUserDownloadLimit": {
"type": "boolean"
},
"quickDownload": {
"type": "boolean"
},
"shareTheme": {
"type": "string"
},
"shareType": {
"description": "type of share: normal, upload, max",
"type": "string"
},
"source": {
"description": "backend source is path to maintain between name changes",
"type": "string"
@@ -3510,6 +3758,10 @@
"expire": {
"type": "integer"
},
"extractEmbeddedSubtitles": {
"description": "can be io intensive for large files and take 10-30 seconds.",
"type": "boolean"
},
"favicon": {
"type": "string"
},
@@ -3531,12 +3783,19 @@
"path": {
"type": "string"
},
"perUserDownloadLimit": {
"type": "boolean"
},
"quickDownload": {
"type": "boolean"
},
"shareTheme": {
"type": "string"
},
"shareType": {
"description": "type of share: normal, upload, max",
"type": "string"
},
"source": {
"description": "backend source is path to maintain between name changes",
"type": "string"
@@ -3722,6 +3981,10 @@
"description": "debug onlyoffice editor",
"type": "boolean"
},
"defaultLandingPage": {
"description": "default landing page to use: eg. /files/mysource/mysubpath, /settings, etc.",
"type": "string"
},
"deleteWithoutConfirming": {
"description": "delete files without confirmation",
"type": "boolean"
+270 -79
View File
@@ -253,8 +253,27 @@ definitions:
imagePreview:
additionalProperties:
type: boolean
description: supported image preview formats. default is heic
description: supported image preview formats. defaults to false for all types
unless explicitly enabled.
type: object
videoPreview:
additionalProperties:
type: boolean
description: supported video preview formats. defaults to true for all types
unless explicitly disabled.
type: object
type: object
settings.Filesystem:
properties:
createDirectoryPermission:
description: 'Unix permissions like 755, 2755, 1777 (default: 755)'
type: string
createFilePermission:
description: 'Unix permissions like 644, 755, 2755 (default: 644)'
type: string
required:
- createDirectoryPermission
- createFilePermission
type: object
settings.Frontend:
properties:
@@ -345,6 +364,13 @@ definitions:
allOf:
- $ref: '#/definitions/settings.FfmpegConvert'
description: config for ffmpeg conversion settings
debug:
description: output ffmpeg stdout for media integration -- careful can produces
lots of output!
type: boolean
extractEmbeddedSubtitles:
description: extract embedded subtitles from media files
type: boolean
ffmpegPath:
description: path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
type: string
@@ -470,10 +496,6 @@ definitions:
database:
description: path to the database file
type: string
debugMedia:
description: output ffmpeg stdout for media integration -- careful can produces
lots of output!
type: boolean
disablePreviewResize:
description: disable resizing of previews for faster loading over slow connections
type: boolean
@@ -489,6 +511,10 @@ definitions:
externalUrl:
description: used by share links if set (eg. http://mydomain.com)
type: string
filesystem:
allOf:
- $ref: '#/definitions/settings.Filesystem'
description: filesystem settings
internalUrl:
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)
@@ -641,6 +667,10 @@ definitions:
debugOffice:
description: debug onlyoffice editor
type: boolean
defaultLandingPage:
description: 'default landing page to use if no redirect is specified: eg.
/files/mysource/mysubpath, /settings, etc.'
type: string
deleteWithoutConfirming:
description: delete files without confirmation
type: boolean
@@ -755,6 +785,9 @@ definitions:
type: string
expires:
type: string
extractEmbeddedSubtitles:
description: can be io intensive for large files and take 10-30 seconds.
type: boolean
favicon:
type: string
hash:
@@ -769,10 +802,15 @@ definitions:
type: string
path:
type: string
perUserDownloadLimit:
type: boolean
quickDownload:
type: boolean
shareTheme:
type: string
shareType:
description: 'type of share: normal, upload, max'
type: string
source:
description: backend source is path to maintain between name changes
type: string
@@ -825,6 +863,9 @@ definitions:
type: string
expire:
type: integer
extractEmbeddedSubtitles:
description: can be io intensive for large files and take 10-30 seconds.
type: boolean
favicon:
type: string
hash:
@@ -839,10 +880,15 @@ definitions:
type: string
path:
type: string
perUserDownloadLimit:
type: boolean
quickDownload:
type: boolean
shareTheme:
type: string
shareType:
description: 'type of share: normal, upload, max'
type: string
source:
description: backend source is path to maintain between name changes
type: string
@@ -976,6 +1022,10 @@ definitions:
debugOffice:
description: debug onlyoffice editor
type: boolean
defaultLandingPage:
description: 'default landing page to use: eg. /files/mysource/mysubpath,
/settings, etc.'
type: string
deleteWithoutConfirming:
description: delete files without confirmation
type: boolean
@@ -1756,67 +1806,6 @@ paths:
summary: Get image preview
tags:
- Resources
/api/public/preview:
get:
consumes:
- application/json
description: Returns a preview image based on the requested path and size.
parameters:
- description: source hash
in: query
name: hash
required: true
type: string
- description: File path of the image to preview
in: query
name: path
required: true
type: string
- description: Preview size ('small' or 'large'). Default is based on server
config.
in: query
name: size
type: string
produces:
- application/json
responses:
"200":
description: Preview image content
schema:
type: file
"202":
description: Download permissions required
schema:
additionalProperties:
type: string
type: object
"400":
description: Invalid request path
schema:
additionalProperties:
type: string
type: object
"404":
description: File not found
schema:
additionalProperties:
type: string
type: object
"415":
description: Unsupported file type for preview
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
summary: Get image preview
tags:
- Resources
/api/raw:
get:
consumes:
@@ -2575,17 +2564,81 @@ paths:
summary: Health Check
tags:
- Health
/public/dl:
/public/api/preview:
get:
consumes:
- application/json
description: Returns the raw content of a file, multiple files, or a directory.
Supports downloading files as archives in various formats.
description: Returns a preview (thumbnail) for images or videos accessible via
a public share. Preview generation can be disabled globally or per-share.
Not available for upload-only shares.
parameters:
- description: if specified, only the files in the list will be downloaded.
eg. files=/file1||/folder/file2
- description: Share hash for authentication
in: query
name: hash
required: true
type: string
- description: File path within the share to preview
in: query
name: path
required: true
type: string
- description: 'Preview size: ''small'' or ''large''. Default is based on server
config.'
in: query
name: size
type: string
produces:
- image/jpeg
responses:
"200":
description: Preview image content (JPEG)
schema:
type: file
"403":
description: Share unavailable or access denied
schema:
additionalProperties:
type: string
type: object
"404":
description: File not found or preview not available
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
additionalProperties:
type: string
type: object
"501":
description: Previews disabled globally, for this share, or for upload shares
schema:
additionalProperties:
type: string
type: object
summary: Get image/video preview from a public share
tags:
- Public Shares
/public/api/raw:
get:
consumes:
- application/json
description: Downloads raw content from a public share. Supports single files,
multiple files, or directories as archives. Enforces download limits (global
or per-user) and blocks anonymous users when per-user limits are enabled.
parameters:
- description: Share hash for authentication
in: query
name: hash
required: true
type: string
- description: 'Files to download in format: ''source::path||source::path''.
Example: ''/file1||/folder/file2'''
in: query
name: files
required: true
type: string
- description: If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults
to 'attachment'.
@@ -2598,26 +2651,27 @@ paths:
name: algo
type: string
produces:
- application/json
- application/octet-stream
responses:
"200":
description: Raw file or directory content, or archive for multiple files
schema:
type: file
"202":
description: Modify permissions required
"400":
description: Invalid request path or encoding
schema:
additionalProperties:
type: string
type: object
"400":
description: Invalid request path
"403":
description: Download limit reached, anonymous access blocked, or share
unavailable
schema:
additionalProperties:
type: string
type: object
"404":
description: File or directory not found
description: Share not found or file not found
schema:
additionalProperties:
type: string
@@ -2628,9 +2682,146 @@ paths:
additionalProperties:
type: string
type: object
summary: Get raw content of a file, multiple files, or directory
"501":
description: Downloads disabled for upload shares
schema:
additionalProperties:
type: string
type: object
summary: Download files from a public share
tags:
- Resources
- Public Shares
/public/api/resources:
post:
consumes:
- multipart/form-data
description: Handles file and directory uploads to an upload-only public share.
Supports chunked uploads, conflict resolution (override), and directory creation.
parameters:
- description: Share hash for authentication
in: query
name: hash
required: true
type: string
- description: Target path within the share to upload to. Must be relative to
share root.
in: query
name: targetPath
required: true
type: string
- description: If true, overwrite existing files/folders. Defaults to false.
in: query
name: override
type: boolean
- description: 'Upload action: ''override'' to replace files, ''rename'' to
auto-rename'
in: query
name: action
type: string
- description: File to upload
in: formData
name: file
required: true
type: file
produces:
- application/json
responses:
"200":
description: Upload successful
schema:
additionalProperties:
type: string
type: object
"400":
description: Invalid request or parameters
schema:
additionalProperties:
type: string
type: object
"403":
description: Share unavailable or upload not allowed
schema:
additionalProperties:
type: string
type: object
"404":
description: Share not found
schema:
additionalProperties:
type: string
type: object
"409":
description: File or directory already exists (conflict)
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error during upload
schema:
additionalProperties:
type: string
type: object
"501":
description: Uploading disabled for non-upload shares
schema:
additionalProperties:
type: string
type: object
summary: Upload files to a public upload share
tags:
- Public Shares
/public/api/share:
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
/public/share/direct:
get:
consumes:
+4 -3
View File
@@ -9,13 +9,14 @@
},
"scripts": {
"build": "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",
"build:windows": "vite build --outDir ../backend/http/dist && robocopy ../backend/http/dist ../backend/http/embed /e",
"build:docker": "vite build",
"watch": "DEV_BUILD=true 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/",
"i18n:sync": "node ./scripts/sync-translations.js",
"i18n:check": "node ./scripts/sync-translations.js --check",
"format": "prettier --write .",
"test": "vitest run "
},
+58 -34
View File
@@ -17,10 +17,9 @@ server:
noColors: false # disable colors in the output
json: false # output in json format
utc: false # use UTC time in the output instead of local time
debugMedia: false # output ffmpeg stdout for media integration -- careful can produces lots of output!
database: "database.db" # path to the database file
sources: # validate:required,dive
- path: "/relative/or/absolute/path" # file system path. (Can be relative) validate:required
- path: "." # file system path. (Can be relative) validate:required
name: "backend" # display name
config:
denyByDefault: false # deny access unless an "allow" access rule was specifically created.
@@ -29,28 +28,31 @@ server:
indexingIntervalMinutes: 0 # optional manual overide interval in minutes to re-index the source
disableIndexing: false # disable the indexing of this source
maxWatchers: 0 # number of concurrent watchers to use for this source, currently not supported
neverWatchPaths: []
neverWatchPaths: # paths that get initially once. Useful for folders that rarely change contents (without source path prefix)
exclude: # exclude files and folders from indexing, if include is not set
hidden: false # exclude hidden files and folders.
ignoreZeroSizeFolders: false # ignore folders with 0 size
filePaths: []
folderPaths: []
fileNames: []
folderNames: []
fileEndsWith: []
folderEndsWith: []
fileStartsWith: []
folderStartsWith: []
filePaths: # list of filepaths Eg. "folder1" or "file1.txt" or "folder1/file1.txt" (without source path prefix)
folderPaths: # (filepath) list of folder names to include/exclude. Eg. "folder1" or "folder1/subfolder" (do not include source path, just the subpaths from the source path)
fileNames: # (global) list of file names to include/exclude. Eg. "a.jpg"
folderNames: # (global) list of folder names to include/exclude. Eg. "@eadir" or ".thumbnails"
fileEndsWith: # (global) exclude files that end with these suffixes. Eg. ".jpg" or ".txt"
folderEndsWith: # (global) exclude folders that end with these suffixes. Eg. ".thumbnails" or ".git"
fileStartsWith: # (global) exclude files that start with these prefixes. Eg. "archive-" or "backup-"
folderStartsWith: # (global) exclude folders that start with these prefixes. Eg. "archive-" or "backup-"
include: # include files and folders from indexing, if exclude is not set
rootFolders: []
rootFiles: []
rootFolders: # list of root folders to include, relative to the source path (eg. "folder1")
rootFiles: # list of root files to include, relative to the source path (eg. "file1.txt")
defaultUserScope: "/" # default "/" should match folders under path
defaultEnabled: false # should be added as a default source for new users?
defaultEnabled: true # should be added as a default source for new users?
createUserDir: false # create a user directory for each user
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
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
createDirectoryPermission: "755" # Unix permissions like 755, 2755, 1777 (default: 755) validate:required,file_permission
auth:
tokenExpirationHours: 2 # time in hours each web UI session token is valid for. Default is 2 hours.
methods:
@@ -71,8 +73,8 @@ auth:
enforcedOtp: false # if set to true, TOTP is enforced for all password users users. Otherwise, users can choose to enable TOTP.
oidc: # validate:omitempty
enabled: false # whether to enable OIDC authentication
clientId: "**hidden**" # secret: client id of the OIDC application
clientSecret: "**hidden**" # secret: client secret of the OIDC application
clientId: "" # secret: client id of the OIDC application
clientSecret: "" # secret: client secret of the OIDC application
issuerUrl: "" # authorization URL of the OIDC provider
scopes: "openid email profile" # scopes to request from the OIDC provider
userIdentifier: "preferred_username" # the field value to use as the username. Default is "preferred_username", can also be "email" or "username", or "phone"
@@ -81,10 +83,10 @@ auth:
createUser: false # create user if not exists
adminGroup: "" # if set, users in this group will be granted admin privileges.
groupsClaim: "groups" # the JSON field name to read groups from. Default is "groups"
key: "**hidden**" # secret: the key used to sign the JWT tokens. If not set, a random key will be generated.
key: "" # secret: the key used to sign the JWT tokens. If not set, a random key will be generated.
adminUsername: "**hidden**" # secret: the username of the admin user. If not set, the default is "admin".
adminPassword: "**hidden**" # secret: the password of the admin user. If not set, the default is "admin".
totpSecret: "**hidden**" # secret: secret used to encrypt TOTP secrets
totpSecret: "" # secret: secret used to encrypt TOTP secrets
frontend:
name: "FileBrowser Quantum" # display name
disableDefaultLinks: false # disable default links in the sidebar
@@ -102,12 +104,12 @@ frontend:
lightBackground: "#f5f5f5" # specify a valid CSS color property value to use as the background color in light mode
darkBackground: "#141D24" # Specify a valid CSS color property value to use as the background color in dark mode
customThemes: # A list of custom css files that each user can select to override the default styling. if "default" is key name then it will be the default option.
default:
description: "The default theme" # The description of the theme to display in the UI.
css: "" # The css file path and filename to use for the theme.
alternative:
description: "Reduce rounded corners" # The description of the theme to display in the UI.
css: "reduce-rounded-corners.css" # The css file path and filename to use for the theme.
default:
description: "The default theme" # The description of the theme to display in the UI.
css: "" # The css file path and filename to use for the theme.
favicon: "" # path to a favicon to use for the frontend
description: "FileBrowser Quantum is a file manager for the web which can be used to manage files on your server" # description that shows up in html head meta description
userDefaults:
@@ -130,16 +132,16 @@ userDefaults:
lockPassword: false # disable the user from changing their password
disableSettings: false # disable the user from viewing the settings page
preview:
disableHideSidebar: false
highQuality: false
image: false
video: false
motionVideoPreview: false
office: false
popup: false
autoplayMedia: false
defaultMediaPlayer: false
folder: false
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
permissions:
api: false
admin: false
@@ -153,18 +155,40 @@ userDefaults:
maxConcurrentUpload: 10
uploadChunkSizeMb: 10
clearAll: false
disableOnlyOfficeExt: "" # list of file extensions to disable onlyoffice editor for
disableOnlyOfficeExt: ".md .txt .pdf" # 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
internalUrl: "" # An optional internal address that the filebrowser server can use to communicate with the OnlyOffice Document Server, could be useful to bypass proxy.
secret: "**hidden**" # secret: authentication key for OnlyOffice integration validate:required
secret: "" # secret: authentication key for OnlyOffice integration validate:required
viewOnly: false # view only mode for OnlyOffice
media: # validate:omitempty
ffmpegPath: "" # path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
convert: # config for ffmpeg conversion settings
imagePreview: # supported image preview formats. default is heic
imagePreview: # supported image preview formats. defaults to false for all types unless explicitly enabled.
heic: false
videoPreview: # supported video preview formats. defaults to true for all types unless explicitly disabled.
3g2: true
3gp: true
asf: true
avi: true
f4v: true
flv: true
m2ts: true
m4v: true
mkv: true
mov: true
mp4: true
mpeg: true
mpg: true
ogv: true
ts: true
vob: true
webm: true
wmv: true
debug: false # output ffmpeg stdout for media integration -- careful can produces lots of output!
extractEmbeddedSubtitles: false # extract embedded subtitles from media files
+106 -31
View File
@@ -5,6 +5,10 @@ import { fileURLToPath } from 'node:url';
import * as glob from 'glob';
import * as deepl from 'deepl-node';
// Parse command line arguments
const args = process.argv.slice(2);
const checkOnly = args.includes('--check') || args.includes('-c');
// --- Configuration ---
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -12,15 +16,16 @@ const localesDir = path.resolve(__dirname, '../src/i18n');
const masterLocaleFile = path.join(localesDir, 'en.json');
const masterLanguageCode = 'en';
const targetLocaleFiles = glob.sync(path.join(localesDir, '*.json'))
.filter(file => path.basename(file) !== `${masterLanguageCode}.json`);
.filter(file => path.basename(file) !== `${masterLanguageCode}.json`)
.filter(file => path.basename(file) !== 'is.json'); // Exclude Icelandic - DeepL doesn't support it
const DEEPL_API_KEY = process.env.DEEPL_API_KEY;
if (!DEEPL_API_KEY) {
if (!checkOnly && !DEEPL_API_KEY) {
console.error("❌ Missing DEEPL_API_KEY in environment.");
process.exit(1);
}
const translator = new deepl.Translator(DEEPL_API_KEY);
const translator = checkOnly ? null : new deepl.Translator(DEEPL_API_KEY);
const deeplLangMap = {
'zh-cn': 'ZH-HANS',
@@ -40,6 +45,10 @@ const deeplLangMap = {
// --- Translation Function using DeepL ---
async function translateText(text, targetLanguage, keyPath = '') {
if (checkOnly) {
// In check mode, just return the original text to detect changes
return text;
}
if (!text || typeof text !== 'string' || text.trim() === '') {
console.warn(`Skipping translation for empty or non-string text: "${text}"`);
@@ -89,6 +98,7 @@ async function translateText(text, targetLanguage, keyPath = '') {
// --- Recursive key processor ---
async function processKeys(masterObj, targetObj, targetLangCode, currentPathParts = []) {
let changesMade = false;
let meaningfulChanges = 0; // Only count meaningful changes
// First pass: Add/update keys from master to target
for (const key in masterObj) {
@@ -100,16 +110,27 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
// Special handling for "languages" key - always copy the entire object from master
if (key === 'languages' && currentPathParts.length === 0) {
console.log(`Copying entire "languages" object from master to ${targetLangCode}.json`);
targetObj[key] = JSON.parse(JSON.stringify(masterValue)); // Deep copy
if (checkOnly) {
// Don't print or count languages object copying - it's routine
} else {
console.log(`Copying entire "languages" object from master to ${targetLangCode}.json`);
targetObj[key] = JSON.parse(JSON.stringify(masterValue)); // Deep copy
}
changesMade = true;
continue;
}
if (typeof masterValue === 'object' && masterValue !== null && !Array.isArray(masterValue)) {
if (!targetObj[key] || typeof targetObj[key] !== 'object') {
console.log(`Creating missing object structure for "${currentKeyPath}" in ${targetLangCode}.json`);
targetObj[key] = {};
if (checkOnly) {
console.log(`Would create missing object structure for "${currentKeyPath}" in ${targetLangCode}.json`);
meaningfulChanges++; // Creating new object structures is meaningful
// In check mode, create a temporary object for processing
targetObj[key] = {};
} else {
console.log(`Creating missing object structure for "${currentKeyPath}" in ${targetLangCode}.json`);
targetObj[key] = {};
}
changesMade = true;
}
const result = await processKeys(masterValue, targetObj[key], targetLangCode, currentPathPartsNext);
@@ -117,22 +138,35 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
console.log(`Skipping translation for "${targetLangCode}" due to unsupported structure.`);
return "UNSUPPORTED";
}
if (result) {
if (typeof result === 'number') {
meaningfulChanges += result;
changesMade = true;
} else if (result) {
changesMade = true;
}
} else if (typeof masterValue === 'string') {
if (!targetObj.hasOwnProperty(key) || targetObj[key] === '' || targetObj[key] === null) {
const result = await translateText(masterValue, targetLangCode, currentKeyPath);
if (result == "") {
return "UNSUPPORTED";
if (checkOnly) {
console.log(`Would translate "${currentKeyPath}" for ${targetLangCode}.json`);
meaningfulChanges++; // Translation is meaningful
} else {
const result = await translateText(masterValue, targetLangCode, currentKeyPath);
if (result == "") {
return "UNSUPPORTED";
}
targetObj[key] = result;
}
targetObj[key] = result;
changesMade = true;
}
} else {
if (!targetObj.hasOwnProperty(key)) {
console.log(`Key "${currentKeyPath}" (non-string) missing in ${targetLangCode}.json. Copying from English.`);
targetObj[key] = masterValue;
if (checkOnly) {
console.log(`Would copy key "${currentKeyPath}" (non-string) from English to ${targetLangCode}.json`);
meaningfulChanges++; // Copying non-string values is meaningful
} else {
console.log(`Key "${currentKeyPath}" (non-string) missing in ${targetLangCode}.json. Copying from English.`);
targetObj[key] = masterValue;
}
changesMade = true;
}
}
@@ -151,17 +185,26 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
for (const key of keysToRemove) {
const currentKeyPath = [...currentPathParts, key].join('.');
console.log(`🗑️ Removing obsolete key "${currentKeyPath}" from ${targetLangCode}.json`);
delete targetObj[key];
if (checkOnly) {
console.log(`Would remove obsolete key "${currentKeyPath}" from ${targetLangCode}.json`);
meaningfulChanges++; // Removing obsolete keys is meaningful
} else {
console.log(`🗑️ Removing obsolete key "${currentKeyPath}" from ${targetLangCode}.json`);
delete targetObj[key];
}
changesMade = true;
}
return changesMade;
return checkOnly ? meaningfulChanges : changesMade;
}
// --- Main synchronization ---
async function syncAllTranslations() {
console.warn("--- Using DeepL API for translation ---");
if (checkOnly) {
console.log("--- Checking for translation changes (no translations will be performed) ---");
} else {
console.warn("--- Using DeepL API for translation ---");
}
if (!await fs.pathExists(masterLocaleFile)) {
console.error(`Master locale file not found: ${masterLocaleFile}`);
@@ -171,6 +214,9 @@ async function syncAllTranslations() {
const masterContent = await fs.readJson(masterLocaleFile);
console.log(`Loaded master translations from ${masterLocaleFile}`);
let meaningfulChanges = 0;
let hasMeaningfulChanges = false;
for (const targetFile of targetLocaleFiles) {
const targetLangCode = path.basename(targetFile, '.json');
let targetContent = {};
@@ -188,24 +234,53 @@ async function syncAllTranslations() {
console.log(`\nTarget file ${targetFile} not found. Will create for language: ${targetLangCode}.`);
}
const wasUpdated = await processKeys(masterContent, targetContent, targetLangCode);
const result = await processKeys(masterContent, targetContent, targetLangCode);
if (wasUpdated || !fileExisted) {
try {
await fs.writeJson(targetFile, targetContent, { spaces: 2 });
console.log(`Successfully ${wasUpdated ? 'updated' : 'created'} ${targetFile}`);
} catch (error) {
console.error(`Error writing to ${targetFile}:`, error);
if (checkOnly) {
if (typeof result === 'number' && result > 0) {
meaningfulChanges += result;
hasMeaningfulChanges = true;
console.log(`Found ${result} meaningful changes needed for ${targetLangCode}.json`);
} else if (!fileExisted) {
meaningfulChanges++;
hasMeaningfulChanges = true;
console.log(`Would create new file for ${targetLangCode}.json`);
} else {
// Only print if there are meaningful changes
}
} else {
console.log(`No changes needed for ${targetFile}`);
if (result || !fileExisted) {
try {
await fs.writeJson(targetFile, targetContent, { spaces: 2 });
console.log(`Successfully ${result ? 'updated' : 'created'} ${targetFile}`);
} catch (error) {
console.error(`Error writing to ${targetFile}:`, error);
}
} else {
console.log(`No changes needed for ${targetFile}`);
}
}
}
console.log('\n✅ Translation synchronization complete (via DeepL).');
if (checkOnly) {
if (hasMeaningfulChanges) {
console.log(`\n⚠️ Found ${meaningfulChanges} meaningful translation changes needed.`);
return 1; // Exit code 1 for meaningful changes needed
} else {
console.log('\n✅ No meaningful translation changes needed.');
return 0; // Exit code 0 for no meaningful changes
}
} else {
console.log('\n✅ Translation synchronization complete (via DeepL).');
return 0;
}
}
syncAllTranslations().catch(error => {
console.error("\n❌ An error occurred during translation synchronization:", error);
process.exit(1);
});
syncAllTranslations()
.then(exitCode => {
process.exit(exitCode);
})
.catch(error => {
console.error("\n❌ An error occurred during translation synchronization:", error);
process.exit(1);
});
+31 -2
View File
@@ -6,6 +6,9 @@ import { globalVars } from '@/utils/constants'
// Notify if errors occur
export async function fetchFiles(source, path, content = false) {
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
}
try {
const apiPath = getApiPath('api/resources', {
path: doubleEncode(path),
@@ -23,6 +26,9 @@ export async function fetchFiles(source, path, content = false) {
}
async function resourceAction(source, path, method, content) {
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
}
try {
source = doubleEncode(source)
path = doubleEncode(path)
@@ -40,6 +46,9 @@ async function resourceAction(source, path, method, content) {
}
export async function remove(source, path) {
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
}
try {
return await resourceAction( source, path, 'DELETE')
} catch (err) {
@@ -49,6 +58,9 @@ export async function remove(source, path) {
}
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) {
@@ -94,6 +106,9 @@ export function post(
onupload,
headers = {}
) {
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
}
try {
const apiPath = getApiPath("api/resources", {
path: doubleEncode(path),
@@ -206,6 +221,9 @@ export async function moveCopy(
}
export async function checksum(source, path, algo) {
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
}
try {
const params = {
path: doubleEncode(path),
@@ -223,6 +241,9 @@ export async function checksum(source, path, algo) {
}
export function getDownloadURL(source, path, inline, useExternal) {
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
}
try {
const params = {
files: encodeURIComponent(source) + '::' + encodeURIComponent(path),
@@ -240,6 +261,9 @@ export function getDownloadURL(source, path, inline, useExternal) {
}
export function getPreviewURL(source, path, modified) {
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
}
try {
const params = {
path: encodeURIComponent(path),
@@ -259,9 +283,14 @@ export async function sources() {
try {
const apiPath = getApiPath('api/jobs/status/sources')
const res = await fetchURL(apiPath)
return await res.json()
const data = await res.json()
// Return empty object if no sources are available - this is not an error
return data || {}
} catch (err) {
notify.showError(err.message || 'Error fetching usage sources')
// Only show error for actual network/server errors, not for empty sources
if (err.status && err.status !== 200) {
notify.showError(err.message || 'Error fetching usage sources')
}
throw err
}
}
+75 -1
View File
@@ -1,6 +1,6 @@
import { adjustedData } from "./utils";
import { notify } from "@/notify";
import { getApiPath, getPublicApiPath, encodedPath } from "@/utils/url.js";
import { getApiPath, getPublicApiPath, encodedPath, doubleEncode } from "@/utils/url.js";
import { globalVars } from "@/utils/constants";
import { state } from "@/store";
@@ -104,3 +104,77 @@ export function getShareURL(share) {
}
return window.origin + getApiPath(`public/share/${share.hash}`);
}
export function post(
hash,
path,
content = "",
overwrite = false,
onupload,
headers = {}
) {
if (!hash || hash === undefined || hash === null) {
throw new Error('no hash provided')
}
try {
const apiPath = getPublicApiPath("resources", {
targetPath: doubleEncode(path),
hash: hash,
override: overwrite,
});
const request = new XMLHttpRequest();
request.open("POST", apiPath, true);
for (const header in headers) {
request.setRequestHeader(header, headers[header]);
}
if (typeof onupload === "function") {
request.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentComplete = Math.round(
(event.loaded / event.total) * 100
);
onupload(percentComplete); // Pass the percentage to the callback
}
};
}
const promise = new Promise((resolve, reject) => {
request.onload = () => {
if (request.status >= 200 && request.status < 300) {
resolve(request.responseText);
} else if (request.status === 409) {
const error = new Error("conflict");
error.response = { status: request.status, responseText: request.responseText };
reject(error);
} else {
reject(new Error(request.responseText || "Upload failed"));
}
};
request.onerror = () => reject(new Error("Network error"));
request.onabort = () => reject(new Error("Upload aborted"));
if (
content instanceof Blob &&
!["http:", "https:"].includes(window.location.protocol)
) {
new Response(content).arrayBuffer()
.then(buffer => request.send(buffer))
.catch(err => reject(err));
} else {
request.send(content);
}
});
promise.xhr = request;
return promise;
} catch (err) {
notify.showError(err.message || "Error posting resource");
// We are returning a promise, so we should return a rejected promise on error.
return Promise.reject(err);
}
}
+1 -6
View File
@@ -2,8 +2,6 @@ import { fetchURL, fetchJSON } from '@/api/utils'
import { getApiPath, getPublicApiPath } from '@/utils/url.js'
import { notify } from '@/notify' // Import notify for error handling
import { setNewToken } from '@/utils/auth.js' // Import setNewToken for token management
import { shareInfo } from '@/utils/constants'
export async function getAllUsers() {
try {
const apiPath = getApiPath('api/users')
@@ -92,10 +90,7 @@ export async function login(username, password, recaptcha, otp) {
}
export async function get(id) {
try {
let apiPath = getApiPath('api/users', { id: id })
if (shareInfo.isShare) {
apiPath = getPublicApiPath('users', { id: id })
}
let apiPath = getPublicApiPath('users', { id: id })
return await fetchJSON(apiPath)
} catch (err) {
notify.showError(err.message || `Failed to fetch user with ID: ${id}`)
+12 -4
View File
@@ -1,7 +1,7 @@
import { state } from "@/store";
import { renew, logout } from "@/utils/auth";
import { notify } from "@/notify";
import { shareInfo } from "@/utils/constants";
import { getters } from "@/store/getters";
export async function fetchURL(url, opts, auth = true) {
opts = opts || {};
@@ -35,7 +35,7 @@ export async function fetchURL(url, opts, auth = true) {
let error = new Error(await res.text());
error.status = res.status;
if (auth && res.status == 401 && !shareInfo.isShare) {
if (auth && res.status == 401 && !getters.isShare()) {
logout();
}
@@ -62,9 +62,17 @@ export function adjustedData(data) {
data.items = data.items.map((item) => {
item.source = data.source
if (data.path == "/") {
item.path = `/${item.name}`
if (item.type === "directory") {
item.path = `/${item.name}/`
} else {
item.path = `/${item.name}`
}
} else {
item.path = `${data.path}/${item.name}`
if (item.type === "directory") {
item.path = `${data.path}${item.name}/`
} else {
item.path = `${data.path}${item.name}`
}
}
return item;
});
+4 -4
View File
@@ -55,7 +55,7 @@ describe('adjustedData', () => {
{ name: "file1.txt", type: "file" },
{ name: "file2.txt", type: "file" },
],
path: "/root"
path: "/root/"
};
const expected = {
@@ -63,12 +63,12 @@ describe('adjustedData', () => {
folders: [],
files: [],
items: [
{ name: "folder1", path: "/root/folder1", source: undefined, type: "directory" },
{ name: "folder2", path: "/root/folder2", source: undefined, type: "directory" },
{ name: "folder1", path: "/root/folder1/", source: undefined, type: "directory" },
{ name: "folder2", path: "/root/folder2/", source: undefined, type: "directory" },
{ name: "file1.txt", path: "/root/file1.txt", source: undefined, type: "file" },
{ name: "file2.txt", path: "/root/file2.txt", source: undefined, type: "file" },
],
path: "/root",
path: "/root/",
};
expect(adjustedData(input)).toEqual(expected);
+1 -2
View File
@@ -411,8 +411,7 @@ export default {
return mutations.showHover(value);
},
showShareHover() {
mutations.showHover({
name: "share",
mutations.showHover({name: "share",
props: {
item: getters.selectedCount() == 1 ? getters.getFirstSelected() : state.req
},
+14 -8
View File
@@ -6,11 +6,17 @@
:class="{ 'image-preview': hasPreviewImage }"
>
<i
v-if="hasMotion"
v-if="hasMotion && isFile"
class="material-icons"
:class="{ larger: showLarger, smaller: !showLarger }"
>animation</i
>
<i
v-else-if="!isFile"
class="material-icons"
:class="{ larger: showLarger, smaller: !showLarger }"
>folder</i
>
<img
:key="imageTargetSrc"
:src="imageDisplaySrc"
@@ -73,6 +79,9 @@ export default {
};
},
computed: {
isFile() {
return this.mimetype !== "directory";
},
hasPreviewImage() {
if (shareInfo.disableThumbnails) {
return false;
@@ -83,18 +92,15 @@ export default {
if (!this.hasPreview) {
return false;
}
if (this.getIconForType().simpleType === "video" && !state.user.preview?.video) {
const simpleType = this.getIconForType().simpleType;
if (simpleType === "video" && !state.user.preview?.video) {
return false;
}
if (this.getIconForType().simpleType === "image" && !state.user.preview?.image) {
if (simpleType === "image" && !state.user.preview?.image) {
return false;
}
// office files
if (this.getIconForType().simpleType === "document" && !state.user.preview?.office) {
return false;
}
// text files
if (this.getIconForType().simpleType === "text" && !state.user.preview?.text) {
if ((simpleType === "document" || simpleType === "text") && !state.user.preview?.office) {
return false;
}
if (!state.user.preview.folder && this.mimetype == "directory") {
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more