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:
173 files changed
+9020
-2883
No files matched your search
@@ -1,5 +1,6 @@
|
||||
backend/**.db
|
||||
backend/tmp
|
||||
backend/dist
|
||||
backend/backend*
|
||||
backend/vendor/**
|
||||
frontend/dist/**
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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,4 +1,4 @@
|
||||
FROM gtstef/playwright-base
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
FROM gtstef/playwright-base
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
FROM gtstef/playwright-base
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
COPY [ "./_docker/src/proxy/", "./" ]
|
||||
WORKDIR /app/frontend
|
||||
|
||||
@@ -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,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 @@
|
||||
FROM gtstef/playwright-base
|
||||
FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -104,7 +104,7 @@ archives:
|
||||
{{- else -}}
|
||||
{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{.ProjectName}}
|
||||
{{- end -}}
|
||||
format: binary
|
||||
formats: [ 'binary' ]
|
||||
|
||||
checksum:
|
||||
disable: true
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 "
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
Reference in new issue
Block a user