Compare commits

...
19 Commits
Author SHA1 Message Date
Graham Steffaniak a3b0fc0038 Upgrade ffmpeg 8.0.1 (#1634) 2025-11-20 13:50:57 -06:00
dependabot[bot]andGraham Steffaniak 695364c2df Bump golang.org/x/crypto from 0.44.0 to 0.45.0 in /backend in the go_modules group across 1 directory (#1627)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-20 13:36:46 -06:00
Graham Steffaniak 3298111560 Add exif tool (#1625) 2025-11-19 17:54:48 -06:00
Graham Steffaniak 3ca9f54de0 updated with compile safe i18n checks (#1623) 2025-11-19 16:48:44 -06:00
a33dbfc464 Beta/v1.1.0 (#1614)
Co-authored-by: Swedish Wiking <121635232+Swedish-Wiking@users.noreply.github.com>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: ✿ corey <corey@x64.co>
2025-11-19 08:50:49 -06:00
18738b3273 Stable/v1.0.1 (#1559)
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
Co-authored-by: Ironic <202444094+YoureIronic@users.noreply.github.com>
2025-11-10 10:57:17 -06:00
Ironic 1d8c74dbd9 Remove unstable version warning from README (#1512) 2025-11-04 08:05:18 -05:00
0ab22e3a89 Beta/v1.0.1 (#1500)
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: lps-rocks <10893911+lps-rocks@users.noreply.github.com>
Co-authored-by: contributor <foss.contributor@gmail.com>
Co-authored-by: Sarabanga <96509626+Sarabanga@users.noreply.github.com>
2025-11-02 15:40:06 -06:00
8b1bf3cebf Beta/v0.8.11 (#1453)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Srinivas A <56465971+srini-abhiram@users.noreply.github.com>
Co-authored-by: Chanwoong Kim <me@chanwoong.kim>
Co-authored-by: fox34 <github@noecho.de>
Co-authored-by: Pyro <pyrodd@gmail.com>
Co-authored-by: Noé Larrieu-Lacoste <noelarrieulacoste@yahoo.fr>
2025-10-28 12:29:55 -05:00
6d11e01054 Beta/v0.8.10 (#1417)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Srinivas A <56465971+srini-abhiram@users.noreply.github.com>
Co-authored-by: Chanwoong Kim <me@chanwoong.kim>
2025-10-22 15:11:47 -05:00
Graham Steffaniak 3450149d24 Update README.md (#1386) 2025-10-18 06:35:18 -05:00
Graham Steffaniak 54658308f2 Update README.md (#1385) 2025-10-18 06:32:48 -05:00
junaid javed e34b65918c Fix typo in README about FileBrowser Quantum (#1384) 2025-10-18 06:29:13 -05:00
eca9086a62 Beta/v0.8.9 (#1379)
Co-authored-by: Srinivas A <56465971+srini-abhiram@users.noreply.github.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Chanwoong Kim <me@chanwoong.kim>
2025-10-17 17:53:33 -05:00
39514169f1 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>
2025-10-04 14:28:15 -05:00
Kevin B. fbc29056aa fix light and dark mode on the player and editor nextPrevious (#1279) 2025-09-23 07:13:49 -05:00
3355306bd7 Beta/v0.8.7 (#1276)
Co-authored-by: Markos <xmarkos@gmail.com>
Co-authored-by: Zhiyuan Zheng <zhzy0077@hotmail.com>
Co-authored-by: Kurami <kurami32_kbm@proton.me>
2025-09-22 16:05:37 -05:00
9ff92e079f Beta/v0.8.6 (#1233)
Co-authored-by: Max <mail@kjmax.com>
Co-authored-by: Kurami <kurami32_kbm@proton.me>
Co-authored-by: Alexander Noyle <alexnoyle@icloud.com>
2025-09-17 09:12:43 -05:00
e398f1f5af Beta/v0.8.5 (#1218)
Co-authored-by: Max <mail@kjmax.com>
Co-authored-by: Kurami <kurami32_kbm@proton.me>
Co-authored-by: Alexander Noyle <alexnoyle@icloud.com>
2025-09-14 10:30:47 -05:00
409 changed files with 69192 additions and 12208 deletions

No files matched your search

+5 -1
View File
@@ -1,6 +1,10 @@
backend/**.db
backend/tmp
backend/dist
backend/backend*
backend/http/dist/**
backend/vendor/**
frontend/dist/**
frontend/node_modules/**
frontend/node_modules/**
frontend/pkg/**
+6 -9
View File
@@ -7,6 +7,7 @@ on:
- "dev/v*.*.*"
- "beta/v*.*.*"
- "stable/v*.*.*"
- "staging/v*.*.*"
jobs:
prime_docker_cache:
name: Prime docker cache
@@ -75,6 +76,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 +117,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 +126,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 +141,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
@@ -86,25 +86,3 @@ jobs:
git push origin "$STABLE_BRANCH"
echo "✅ Successfully merged '$BETA_BRANCH' into '$STABLE_BRANCH'."
# - name: Delete beta branch after successful merge
# run: |
# VERSION="${{ github.event.inputs.version }}"
# BETA_BRANCH="beta/v$VERSION"
#
# # Delete beta branch locally and remotely
# git branch -d "$BETA_BRANCH"
# git push origin --delete "$BETA_BRANCH"
#
# echo "✅ Successfully deleted '$BETA_BRANCH'."
- name: Create Pull Request to Main
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.PAT }}
commit-message: "Promote stable/v${{ github.event.inputs.version }} to main"
title: "Promote stable/v${{ github.event.inputs.version }} to main"
body: "This PR promotes stable/v${{ github.event.inputs.version }} to the main branch."
base: main
branch: stable/v${{ github.event.inputs.version }}
delete-branch: false
@@ -86,14 +86,3 @@ jobs:
git push origin "$BETA_BRANCH"
echo "✅ Successfully merged '$DEV_BRANCH' into '$BETA_BRANCH'."
# - name: Delete dev branch after successful merge
# run: |
# VERSION="${{ github.event.inputs.version }}"
# DEV_BRANCH="dev/v$VERSION"
#
# # Delete dev branch locally and remotely
# git branch -d "$DEV_BRANCH"
# git push origin --delete "$DEV_BRANCH"
#
# echo "✅ Successfully deleted '$DEV_BRANCH'."
+8 -1
View File
@@ -21,7 +21,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
go-version: 'stable'
- uses: golangci/golangci-lint-action@v7
with:
version: 'v2.1.6'
@@ -50,3 +50,10 @@ jobs:
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run test
check-translations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run i18n:check
+2 -2
View File
@@ -59,8 +59,8 @@ jobs:
target_commitish: ${{ github.sha }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
prerelease: false # change this to false when stable gets released
make_latest: true # change this to false when stable gets released
prerelease: true
make_latest: false
draft: false
generate_release_notes: true
name: ${{ steps.extract_branch.outputs.tag_name }}
-62
View File
@@ -1,62 +0,0 @@
name: dev release
on:
push:
branches:
- "dev/v[0-9]+.[0-9]+.[0-9]+"
permissions:
contents: write
jobs:
push_release_to_registry:
name: Push release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{github.actor}}
password: ${{ secrets.PAT }}
- name: Extract metadata (tags, labels) for Docker and GHCR
id: meta
uses: docker/metadata-action@v5
with:
images: |
gtstef/filebrowser
ghcr.io/gtsteffaniak/filebrowser
- name: Modify version names
id: modify-json
run: |
TAGS="${{ steps.meta.outputs.tags }}"
# Apply modifications to both docker.io and ghcr.io tags
CLEANED_TAGS=$(echo "$TAGS" | sed 's/filebrowser:dev-v/filebrowser:/g' | sed -E 's/(filebrowser:[0-9]+\.[0-9]+\.[0-9]+)/\1-dev/g')
# Add dev tag to both registries
CLEANED_TAGS="$CLEANED_TAGS,gtstef/filebrowser:dev,ghcr.io/gtsteffaniak/filebrowser:dev"
echo "cleaned_tag<<EOF" >> $GITHUB_OUTPUT
echo "$CLEANED_TAGS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
build-args: |
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
platforms: linux/amd64,linux/arm64
file: ./_docker/Dockerfile
push: true
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
labels: ${{ steps.meta.outputs.labels }}
+2 -1
View File
@@ -10,7 +10,7 @@ permissions:
jobs:
test_playwright:
name: Test Playwright
name: Test Playwright - regular
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -60,6 +60,7 @@ jobs:
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_branch.outputs.tag_name }}
prerelease: false
make_latest: true
draft: false
generate_release_notes: true
name: ${{ steps.extract_branch.outputs.tag_name }}
@@ -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
+10 -3
View File
@@ -8,22 +8,25 @@ rice-box.go
/backend/backend
/backend/filebrowser
/backend/filebrowser.exe
frontend/tsconfig.tsbuildinfo
/backend/backend.exe
/backend/playwright-files.db
/backend/tmp
/backend/dist
/frontend/dist
/frontend/pkg
/frontend/test-results
/frontend/playwright-files
/frontend/screenshots
/frontend/tests/playwright-files/demo*
/frontend/loginAuth.json
/frontend/package-lock.json
/backend/vendor
/backend/*.cov
/backend/test_config.yaml
/backend/*test_config.yaml
/backend/generated.yaml
/backend/srv
/backend/http/dist
/backend/http/embed/*
/backend/http/embed
.DS_Store
node_modules
@@ -49,3 +52,7 @@ bin/
build/
*__debug*
backend/custom.css
# Runtime cache directories created by tests/application
backend/preview/heic/
backend/preview/thumbnails/
+318
View File
@@ -2,6 +2,324 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v1.1.1-beta
**Notes**:
- [docker] upgraded ffmpeg 8.0 to 8.0.1
**BugFixes**:
- added missing exiftool to docker image for heic conversion orientation support
- v1.1.0-beta - Incorrect naming of 1 file in directory-info #1621
## v1.1.0-beta
**New Features**:
- Added tools
- size analyzer -- quickly show largest files in interactive graphical chart
- duplicate finder -- find duplicate files larger than 1MB
- material icon picker and previewer
- Listing View Style updates
- Status bar for listing view #1459
- Duration field shows up if media is shown with duration
- More dynamic view modes with additional styling changes
- Total display modes consolidated into 3 main groups: list, gallery, normal. "icon" and "compact" mode exist as views based on display size setting.
- Customizable sidebar links
- Logged in users can have their preferences saved and synced
- Can rearrange, add custom links, and save your preferences.
- Can customize shares with their own custom sidebar links.
- Dynamic scopes for OIDC #1414 #1363
- Share and Access Rules validation
- If a file/path is moved/renamed in UI its rules and shares will always follow.
- If a file/path is moved/renamed outside UI, a warning message will show and ability to associate to new path
- Cascade delete access in Access Management when deleting a User #1347
- Enhanced notifications #1331
- Added "toast" notification type support -- most success messages show as toast now.
- multiple stacked notifications possible
- notification button support, such as click to open folder after moving file.
- notification history (not including toasts)
- Add swipe and visual timeout to notifications #1600
- Choose different bind ip via `server.listen` #1573
- Allow disabling clearing cache each startup via `server.cacheDirCleanup: false` in config #1576
**Notes**:
- more efficient user update actions. Successful PUT actions return only 204 if successful.
- Do not enter directory on "New Folder" action #1343
- Improved indexing performance - individual scanners per directory at root.
- Improved search memory efficiency
- Improved scrolling performance
- Static file handling has been streamlined, could potentially see some changes from this.
- No longer automatically publishing any dev builds to docker
- deprecated "DefaultLandingPage" option -- now determined by first source link in sidebar.
- Improve rename prompt #1608 #1556
**BugFixes**:
- uploading/editing/saving to password protected shares error
- updating a share with password protection without a password removed the password.
- per-user download counts were not persistant accross restarts
- "Update" button in Chinese wraps incorrectely #1542
## v1.0.1-stable
**BugFixes**:
- UserDefault always applies to newly created users #1518
- Updating user's own password sometimes doesn't work
- Anonymous Share link with optional password does not allow downloads #1553
- Share options should go in the meta tags #1511
- Unable to select compression format when downloading multiple files/dir via Share link (v1.0.0) #1557
- fixed sidebar clipping on some browsers like safari
- Files show as Download is not available even though it is #1537
## v1.0.1-beta & v1.0.0-stable
**New Features**:
- login icon support added via `frontend.loginIcon` config path variable
**Notes**:
- updated default login icon
- stopped publishing rolling `dev` docker tag.
- build requirement change -- from node 18 > node 20 with npm 9.0.0+
- version is not shown for unauthenticated users #1444
- adjusted how static assets are served to better handle icon standards
- fixed signup login #1444
- update makefile linker flags to properly set version and sha #1474
- better windows build support -- `make setup` and `make dev` work as long as git is installed on windows
- added better first initilization detection
- If filebrowser initialized and does not detect a database, a new warning message in server logs appears.
- If theres no database on start, any admin user's first login will see a welcome message in the ui.
- For docker, it defaults to ./data/database.db, but will also fallback to ./database.db without any additional configuration.
**BugFixes**:
- Generating multiple HEIC previews in parallel fails #1470
- ? in path not always encoded right #1447
- fixed some condition that the halloween background doesn't load properly
- some comments not showing up on config viewer in settings
## v1.0.0-beta
**Notes**:
- Enhanced onlyoffice debugger with more wholistic backend logs
- Updated info prompt styling
- share qr code links to externalUrl for entire share if exists #1329
**BugFixes**:
- cookie host is set using x-Forwarded-Host to better support reverse proxies.
- HEIC conversion not working #1460
- 'Prevent user changing their password' also prevents admins changing the users password. #1365
- OnlyOffice write problem #1397 #1068
- Conditionals starting with a . seem to not work. #1455
- Last modified in file info is empty #1443
- Share links: 'Default View Mode' does not apply to subfolders. #1463
## v0.8.11-beta
**New Features**:
- OnlyOffice debugger now shows backend logs as well for admin users.
- If proxy auth based `username` equals `auth.adminUsername`, the user will be promoted to admin.
- Guard against accidently cancelling uploads #1419
- Added a quirky halloween theme that automatically applies on october 31st -- you can disable this from happening by setting `frontend.styling.disableEventThemes`
**Notes**:
- Removed share setting `enableOnlyOfficeEditing` -- uses value from `allowEditing`
- Anonymous users can now edit if a share has edit permissions enabled.
- No "Incompatible user settings detected" message if its first setup
- cookie handling revamped
- fully backend managed
- `auth` cookie name changed to `filebrowser_quantum_jwt`
- auth cookie for password users no longer session based #1439
- removed state jwt state variable -- 100% cookie based.
**BugFixes**:
- Fixed issue editing onlyoffice on shares #1397
- Added proper and robust logging / error handling for the onlyoffice callback. #1422 #1068
- If a file was moved/renamed/deleted onlyoffice would re-save the original file without error, now it gives error the file doesn't exist anymore.
- OnlyOffice integration does not work behind proxy authentication #1422
- proxy-based authentication with docker does not work unless set up with password-based login first #1226
- Select all bug on safari mobile #1421
- Dragging a item into itself #1446
## v0.8.10-beta
**New Features**:
- Add marquee selection to listing view -- thanks @Kurami32 #1388
**Notes**:
- More changes to login auth flow
- respects `FILEBROWSER_DISABLE_AUTOMATIC_BACKUP='true'` env var #1398
- Deprecating `conditionals.hidden` instead use `conditionals.ignoreHidden`
**BugFixes**:
- Fix material outline styling
- "feels lonely here" when clicking at source tab #1387
- Anonymous upload bug 0.8.9 #1383
- changing previews between documents in onlyoffice #1410
- onlyoffice should display user language #1061
- Filebrowser will hard fail when OIDC provider is not found #733
- some API endpoints always return 404 #849
## v0.8.9-beta
> [!CAUTION]
> If you have indexing rules -- such as [maxWatchers](https://github.com/gtsteffaniak/filebrowser/blob/39514169f17ed9586d587dd496257588ded6e532/frontend/public/config.generated.yaml#L30), [neverWatchPaths](https://github.com/gtsteffaniak/filebrowser/blob/39514169f17ed9586d587dd496257588ded6e532/frontend/public/config.generated.yaml#L31C9-L31C24), [exclude](https://github.com/gtsteffaniak/filebrowser/blob/39514169f17ed9586d587dd496257588ded6e532/frontend/public/config.generated.yaml#L32C9-L32C16) rules, [include](https://github.com/gtsteffaniak/filebrowser/blob/39514169f17ed9586d587dd496257588ded6e532/frontend/public/config.generated.yaml#L43) rules, see [the migration guide](https://filebrowserquantum.com/en/docs/user-guides/general-configuration/exclusion-rules/) for help.
**New Features**:
- Playback Queue for media -- thanks @Kurami32 #1339
- Ctrl+b Shortcut to toggle sidebar, open settings, search #1172
- Add a "select all" toggle in the menu #974
- Save on exit text editor -- thanks @srini-abhiram #1334 #1241
- Disabled indexing for subfolders but allow browsing #783
- Added more granular create/upload, delete, and download (includes viewing content) permission #1200 #1199
- Upload settings can be adjusted directly in the upload prompt
- Added paste confirmation prompt when using copy/paste keyboard shortcuts.
- Updated shares
- `disableDownload` to disable download and (content viewing).
- `allowReplacements` to share permission -- prohibits replacing files on conflict #661
- normal shares can create/upload files if given permission
**Notes**:
- Optimized font and icon loading.
- the next/previous buttons will follow media queue order if playback mode has a queue.
- All new exclude rules -- see [full config example](https://github.com/gtsteffaniak/filebrowser/wiki/Full-Config-Example)
- The order of sources will strictly match order in config.yaml. Default landing page is root first source unless otherwise configured in profile settings.
- Uploading changes #1371
- Chunked uploading can no longer be explicitly disabled, defaults to 5MB chunks
- Automatic chunked/non-chunked uploads based on size. If an item exceeds the chunk size, chunked uploading will occur. If its less than chunk size, no chunked upload.
- To "disable" chunked upload, you an set your chunk size very large -- 0 chunk size will be reset to 5MB.
**BugFixes**:
- Remember the previous location after closing a preview #1336
- fixed issue related to multi-config parsing #1267
- Cannot save text files, missing button #1326 #1367
- Files are being overwritten and/or content disappears #1312
- moving between items using next/previous will reset the req and show a responsive loading spinner -- fixing several state related issues.
- access management: child folders accessible stopped showing up #1332
- Make source inaccessible if directory does not exist rather than exiting on start #1264
- HTTP Proxy environment gets ignored since version v0.8.6-beta #1324
- album artwork preview not showing on shares
- fixed OIDC logout causing a loop #995 #1361
- fixed checksum failure #1372
- Copy, paste, cut shortcuts don't work and make the UI unusable #1375
## 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.
- OnlyOffice source not found when opening shared file #1285
- user scoped onlyoffice error saving files with Onlyoffice #1068
## v0.8.7-beta
**New Features**:
- json logging format support
**Notes**:
- increased overall file size limit to retrieve metadata from 50MB to 300MB #1234
- document previews don't show up as folder previews.
**BugFixes**:
- Fix directories empty issue when disableIndexing is true #1249
- OIDC to Authentik behind CloudFlare with Bot Fight turned on triggers a JS challenge #1165
- OIDC Auth - loop with expired token #995
- We lost the ability for download all? #1250
- Directories are all empty when disableIndexing is true #1248
- Access Management: issues on the shared folder when there's a sub folder #1208
- user preferences for individual preview file types not getting respected (disable/enable office,image,video)
- High server load when browsing folders with .mkv files #1259
- 2FA can not be disabled with non-admin user #1244
- 2FA not working on login #1243
- Thumbnails of .m4a, .flac, and .opus are not displaying #1234
- creating files/folders with same name overwrite each other #1242
## v0.8.6-beta
**Notes**:
- Please remove `indexAbumArt` config option from your config: it has been deprecated... I found a way to detect album art without impacting indexing performance -- so its default behavior.
- Download button should be invisible before password input #1162
- opus files / opus files detected wrongly #1164
- changed autoplay behavior on plyr to not start playing if blocked instead of playing muted.
- QR-Code disappearing / sliding out from shared link #1155
- audio player matches dark/light mode better
**BugFixes**:
- Copy/Move index update changes to be more thorough and update album artwork #1220 #1219
- fix preview related issues #1225 #1223
- Scrollbar missing on user-edit-prompt in v0.8.5-beta #1221
- copy share download link inconsistency #1207
- fixed some onlyoffice related issues #1192 #1068
- Creating a file, then a folder with the same name makes the folder unusable #1167
- fix plyr issue switching from video to audio via next/previous button.
## v0.8.5-beta
**New Features**:
- backend media metadata processing
- significantly improved performance
- album artwork shows in listing view as preview thumbnail Album art thumbnail: display embedded covers of mp3/flac files #925
- folders can show album art if enabled with indexing `server.sources.config.indexAlbumArt:true`
- removed need for frontend dependancies.
- optional thumbnail support for folders -- uses first child item with thumbnail as cover. Useful in combination with album metadata support to show album covers for folders with music.
- Remember folder view type (and sorting) for each folder #966
- heic is supported in viewer (via ffmpeg conversion) when on non-safari browser. This can be enabled via `integrations.ffmpeg.convert.impagePreview.heic: true` #1191
- OnlyOffice: Add option to open documents in viewer mode (`integrations.office.viewOnly`) #1193
- Exclude folders and file names 'starting with' or wildcard option #1054
- added better info for failed uploads #1050
- upload prompt "clear completed" can be changed to "clear all" in uploads & downloads settings to clear error and conflict states as well. #1128
- share 'default view mode' option #1212
- Universal Next/Previous buttons
- available for all listing items (including office/markdown etc)
- remembers sort order from parent directory
- new "Quick Jump" prompt if you drag the next/previous icons. This shows other files available to "jump" directly to quickly.
**Notes**:
- Added more tests to ensure new features work in future.
- caching for preview images is md5 based. Moved, renamed, or duplicate images don't get re-generated. Same album artwork shares cache.
- Hide @eaDir folder by default #1212
- defaults to hide "@eadir" folders (common for synology) #1212
- After move/copy, ability to move to the destination folder #999
**BugFixes**:
- Access Management: issue with access settings #1195
- fix shutdown panic related to sse connection
- Custom theming not working in 0.8.4 beta #1204
- Config Viewer not working #1189
- "Path not found" when trying to share a file or folder inside a sub-directory #1139
- files containing "+" in share issue #1089
## v0.8.4-beta
**New Features**:
+5 -5
View File
@@ -5,18 +5,18 @@ Thank you for your interest in contributing to FileBrowser Quantum! This guide w
## Prerequisites
- **Go 1.25+** (see `backend/go.mod`)
- **Node.js 18.0.0+** with npm 7.0.0+ (see `frontend/package.json`)
- **Node.js 20.0.0+** with npm 9.0.0+ (see `frontend/package.json`)
- **Docker** (optional, for containerized development)
- **Git**
- **Git** especially improtant on windows -- needed for bash support.
### Required Tools
### Optional Tools
- **ffmpeg**: Required for video thumbnail generation
- **ffmpeg**: For media features (subtitles, thumbnails, heic)
- Ubuntu/Debian: `sudo apt-get install ffmpeg`
- macOS: `brew install ffmpeg`
- Windows: Download from [ffmpeg.org](https://ffmpeg.org/download.html)
- **mupdf-tools**: Required for PDF preview generation
- **mupdf-tools**: For PDF preview generation
- Ubuntu/Debian: `sudo apt-get install mupdf-tools`
- macOS: `brew install mupdf-tools`
- Windows: Download from [mupdf.com](https://mupdf.com/downloads/)
+26 -43
View File
@@ -8,52 +8,53 @@
[![Donate](https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif)](https://github.com/gtsteffaniak/filebrowser/wiki/Q&A#is-there-a-way-to-donate-or-support-this-project)
<img width="150" src="https://github.com/user-attachments/assets/59986a2a-f960-4536-aa35-4a9a7c98ad48" title="Logo">
<img width="150" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174" title="Logo">
<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))
## Pinned
[FYI: how much RAM does it require?](https://github.com/gtsteffaniak/filebrowser/discussions/787)
:loudspeaker: [What's Coming Soon](https://github.com/gtsteffaniak/filebrowser/discussions/1622)
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
## About
FileBrowser Quantum provides an easy way to access and manage your files from the web. It has has a modern responsive interface that has many advanced features to manage users, access, sharing, and file preview and editing.
FileBrowser Quantum provides an easy way to access and manage your files from the web. It has a modern responsive interface that has many advanced features to manage users, access, sharing, and file preview and editing.
This version is called "Quantum" because it packs tons of advanced features into a tiny easy to run file. Unlike the majority of alternative options, FileBrowser Quantum is simple to install and easy to configure.
The goal for this repo is to become the best open-source self-hosted file browsing application that exists -- **all for free**. This repo will always be free and open-source.
Ready to try it out? See [Getting Started Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Getting-Started).
Ready to try it out? See [Getting Started Docs](https://filebrowserquantum.com/en/docs/getting-started/).
## How its different
FileBrowser Quantum is a massive fork of the file browser open-source project with the following changes:
1. ✅ Multiple sources support
1. ✅ Add and configure multiple sources
2. ✅ Login support for OIDC, password + 2FA, and proxy.
3. ✅ Beautiful, Responsive, and Customizable user interface.
4. ✅ Simplified configuration via `config.yaml` config file.
4. ✅ Streamlined configuration via `config.yaml` config file.
5. ✅ Ultra-efficient [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) and real-time updates
- Real-time search results as you type.
- Real-time monitoring and updates in the UI.
- Search supports file and folder sizes, along with various filters.
6. ✅ Better listing browsing
- More file type previews, such as **office** and **video** file previews
- Instantly switches view modes and sort order without reloading data.
- Folder sizes are displayed.
- Better thumbnail support including **office**, **video**, and **album artwork**
- Faster and more responsive views with animations.
- Folder sizes are displayed and support for thumbnails
- Navigating remembers the last scroll position.
7. ✅ Highly configurable and customizable sharing options, incuding:
7. ✅ Highly configurable and customizable sharing options
- share expiration time
- users who can access share (including anonymous)
- styling and themes
- file viewing, editing, and uploading permissions
8. ✅ Directory-level access control that can be scoped to user or group.
8. ✅ Developer API support
9. ✅ Developer API support
- Ability to create long-lived API Tokens.
- A helpful Swagger page is available at `/swagger` endpoint for API enabled users.
@@ -62,10 +63,15 @@ 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
@@ -84,46 +90,23 @@ a pop-up menu.
<img width="1000" src="https://github.com/user-attachments/assets/aa32b05c-f917-47bb-b07f-857edc5e47f7" title="Search GIF">
</p>
## Install and Configuration
## Official Docs
Check out the [Getting Started Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Getting-Started)
For help configuring your filebrowser see [Configuration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Configuration-And-Examples)
## Command Line Usage
See the [CLI Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/CLI)
## API Usage
See the [API Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/API)
## Office File Support
See [Office Support Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Office-Support#adding-open-office-integration-for-docker) on how to enable office file editing and office-related features.
## Migration from the original filebrowser
See the [Migration Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Migration)
## Other Questions
For more, see the [Q&A Wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Q&A)
See the [Official Docs](https://filebrowserquantum.com/). Contributions are welcome and encouraged! See [FilebrowserDocs Github](https://github.com/quantumx-apps/filebrowserDocs).
## Comparison Chart
Application Name | <img width="48" src="https://github.com/user-attachments/assets/59986a2a-f960-4536-aa35-4a9a7c98ad48" > Quantum | <img width="48" src="https://github.com/filebrowser/filebrowser/blob/master/frontend/public/img/logo.svg" > Filebrowser | <img width="48" src="https://github.com/mickael-kerjean/filestash/blob/master/public/assets/logo/app_icon.png?raw=true" > Filestash | <img width="48" src="https://avatars.githubusercontent.com/u/19211038?s=200&v=4" > Nextcloud | <img width="48" src="https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Google_Drive_logo.png/480px-Google_Drive_logo.png" > Google_Drive | <img width="48" src="https://avatars.githubusercontent.com/u/6422152?v=4" > FileRun
Application Name | <img width="48" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174" > Quantum | <img width="48" src="https://github.com/filebrowser/filebrowser/blob/master/frontend/public/img/logo.svg" > Filebrowser | <img width="48" src="https://github.com/mickael-kerjean/filestash/blob/master/public/assets/logo/app_icon.png?raw=true" > Filestash | <img width="48" src="https://avatars.githubusercontent.com/u/19211038?s=200&v=4" > Nextcloud | <img width="48" src="https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Google_Drive_logo.png/480px-Google_Drive_logo.png" > Google_Drive | <img width="48" src="https://avatars.githubusercontent.com/u/6422152?v=4" > FileRun
--- | --- | --- | --- | --- | --- | --- |
Filesystem support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Linux | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Windows | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Has Stable Release? | :construction: | ✅ | ✅ | ✅ | ✅ | ✅ |
Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | :construction: | ✅ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Docker image size | 180 MB (with ffmpeg) | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
Min. Memory Requirements | 256 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
+9 -5
View File
@@ -1,4 +1,4 @@
FROM gtstef/ffmpeg:8.0-decode AS ffmpeg
FROM gtstef/ffmpeg:8.0.1-decode AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
@@ -13,7 +13,7 @@ RUN go build -tags mupdf,musl -ldflags="-w -s \
-o filebrowser .
RUN upx filebrowser
FROM node:lts-slim AS nbuild
FROM node:jod-slim AS nbuild
WORKDIR /app
COPY ./frontend/package.json ./
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
@@ -21,27 +21,31 @@ 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 exiftool
RUN adduser -D -s /bin/true -u 1000 filebrowser
USER filebrowser
WORKDIR /home/filebrowser
# copy config to two locations for compatibility
COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "/app/config.yaml", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
## sanity checks
RUN [ "filebrowser", "version" ]
RUN [ "ffmpeg", "-version" ]
RUN [ "ffprobe", "-version" ]
RUN [ "exiftool", "-ver" ]
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" ]
+3 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/playwright-base
FROM ghcr.io/gtsteffaniak/playwright-base:latest
WORKDIR /app
# Copy setup scripts first to leverage caching
@@ -10,9 +10,11 @@ COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser set -u testuser1,testuser1
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+18
View File
@@ -0,0 +1,18 @@
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
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+4 -2
View File
@@ -1,4 +1,4 @@
FROM gtstef/playwright-base
FROM ghcr.io/gtsteffaniak/playwright-base:latest
WORKDIR /app
# Copy setup scripts first to leverage caching
@@ -6,6 +6,7 @@ COPY [ "./_docker/src/noauth/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/tests", "./tests" ]
COPY [ "./frontend/tests/playwright-files", "./tests/playwright-files2" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
@@ -13,4 +14,5 @@ RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+3 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/playwright-base
FROM ghcr.io/gtsteffaniak/playwright-base:latest
COPY [ "./_docker/src/oidc/frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
@@ -8,6 +8,8 @@ RUN mkdir -p ./http/dist
COPY [ "./_docker/src/oidc/backend/", "./"]
COPY ["./backend/filebrowser", "./"]
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
WORKDIR /app/frontend
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN node mock-oidc-server.js & sleep 2 && cd ../backend/ && ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+2 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/playwright-base
FROM ghcr.io/gtsteffaniak/playwright-base:latest
WORKDIR /app
COPY [ "./_docker/src/proxy/", "./" ]
WORKDIR /app/frontend
@@ -11,4 +11,5 @@ RUN apt update && apt install nginx -y
RUN mv default.conf /etc/nginx/conf.d/default.conf
RUN mv .htpasswd /etc/nginx/conf.d/.htpasswd
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+20
View File
@@ -0,0 +1,20 @@
FROM ghcr.io/gtsteffaniak/playwright-base:latest
WORKDIR /app
# Copy setup scripts first to leverage caching
COPY [ "./_docker/src/settings/", "./" ]
WORKDIR /app/frontend
COPY [ "./_docker/src/settings/frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser set -u testuser1,testuser1
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+2 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/playwright-base
FROM ghcr.io/gtsteffaniak/playwright-base:latest
WORKDIR /app
# Copy setup scripts first to leverage caching
@@ -14,4 +14,5 @@ RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+1 -1
View File
@@ -1 +1 @@
FROM gtstef/playwright-base
FROM ghcr.io/gtsteffaniak/playwright-base:latest
+6 -3
View File
@@ -11,7 +11,7 @@ RUN go build -ldflags="-w -s \
-o filebrowser .
RUN upx filebrowser
FROM node:lts-slim AS nbuild
FROM node:jod-slim AS nbuild
WORKDIR /app
COPY ./frontend/package.json ./
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
@@ -19,17 +19,18 @@ 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
COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
## sanity checks
@@ -37,4 +38,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" ]
+13 -19
View File
@@ -1,21 +1,15 @@
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:
defaultEnabled: true
<<: *server-main
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
<<: *frontend-main
userDefaults:
preview:
image: true
popup: true
video: true
office: true
highQuality: true
permissions:
download: true
create: true
@@ -0,0 +1,13 @@
frontend-main: &frontend-main
name: "Graham's Filebrowser"
disableDefaultLinks: true
styling:
disableEventThemes: true
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,15 @@
server-main: &server-main
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
@@ -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/no-config-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"] },
},
],
});
+38 -4
View File
@@ -5,15 +5,38 @@ server:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
name: "exclude"
config:
defaultEnabled: true
exclude:
folderPaths:
- "excluded"
conditionals:
rules:
- folderPath: "excluded"
- folderPath: "excludedButVisible"
viewable: true
- path: "../frontend/tests/playwright-files2"
name: "include"
config:
defaultEnabled: true
conditionals:
rules:
- includeRootItem: "myfolder"
- includeRootItem: "subfolderExclusions"
- filePath: "/subfolderExclusions/exclusionlist.sh"
- folderPath: "/subfolderExclusions/folderItem"
viewable: true
- fileStartsWith: "startsWith"
- folderEndsWith: "Folder"
- folderNames: 'folderNames'
- fileNames: 'fileNames'
- folderName: 'folderName'
- fileName: 'fileName'
- folderName: '.hiddenDir'
- fileName: '.hidden'
auth:
methods:
noauth: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
@@ -21,6 +44,17 @@ frontend:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
styling:
disableEventThemes: true
userDefaults:
showHidden: true
preview:
image: true
popup: true
video: true
office: true
highQuality: true
permissions:
realtime: true
download: true
create: true
+11 -2
View File
@@ -14,7 +14,8 @@ frontend:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
styling:
disableEventThemes: true
auth:
methods:
password:
@@ -28,6 +29,12 @@ auth:
adminGroup: "Admins"
userDefaults:
preview:
image: true
popup: true
video: true
office: true
highQuality: true
darkMode: true
disableSettings: false
singleClick: false
@@ -35,4 +42,6 @@ userDefaults:
admin: false
modify: true
share: false
api: false
api: false
download: true
create: true
+13 -2
View File
@@ -16,9 +16,12 @@ frontend:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
styling:
disableEventThemes: true
auth:
methods:
password:
enabled: false
proxy:
enabled: true
header: "X-Username"
@@ -26,6 +29,12 @@ auth:
logoutRedirectUrl: "https://localhost/api/auth/logout2"
userDefaults:
preview:
image: true
popup: true
video: true
office: true
highQuality: true
darkMode: true
disableSettings: false
singleClick: false
@@ -33,4 +42,6 @@ userDefaults:
admin: false
modify: true
share: true
api: false
api: false
download: true
create: true
+2 -1
View File
@@ -13,7 +13,8 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
proxy_set_header X-Username $uuid;
}
# requires basic auth demo-127.0.0.1:SecurePass123!
location /subpath {
+33
View File
@@ -0,0 +1,33 @@
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:
disableEventThemes: true
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"
auth:
methods:
password:
signup: true
@@ -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: 3000,
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: 1,
/* 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"] },
},
],
});
+4 -1
View File
@@ -13,10 +13,13 @@ server:
config:
private: true
defaultEnabled: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
title: "Playwright"
styling:
disableEventThemes: true
+1 -1
View File
@@ -2,7 +2,7 @@ root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/filebrowser --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/version.CommitSHA=testingCommit' -X 'github.com/gtsteffaniak/filebrowser/backend/version.Version=testing'\" ."
cmd = "go build -o ./tmp/filebrowser --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=version'\" ."
bin = "tmp/filebrowser"
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -c test_config.yaml"
include_ext = ["go", "tpl", "tmpl", "html"]
+21
View File
@@ -0,0 +1,21 @@
root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -o ./tmp/filebrowser.exe --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=version'\" ."
bin = "tmp/filebrowser.exe"
args_bin = ["-c", "test_config.yaml"]
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "http\\dist", "http\\embed", "swagger\\docs"]
exclude_unchanged = true
follow_symlink = false
poll = true
poll_interval = 1000
log = "air.log"
delay = 1000 # ms
[log]
time = true
[misc]
clean_on_exit = true
+1 -1
View File
@@ -104,7 +104,7 @@ archives:
{{- else -}}
{{.Os}}-{{.Arch}}{{if .Arm}}v{{.Arm}}{{end}}-{{.ProjectName}}
{{- end -}}
format: binary
formats: [ 'binary' ]
checksum:
disable: true
+2 -2
View File
@@ -2,7 +2,7 @@ package diskcache
import (
"context"
"crypto/sha1" //nolint:gosec
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
@@ -106,7 +106,7 @@ func (f *FileCache) getScopedLocks(key string) (lock sync.Locker) {
}
func (f *FileCache) getFileName(key string) string {
hasher := sha1.New() //nolint:gosec
hasher := sha1.New()
_, _ = hasher.Write([]byte(key))
hash := hex.EncodeToString(hasher.Sum(nil))
return filepath.Join(f.dir, fmt.Sprintf("%s/%s/%s", hash[:1], hash[1:3], hash))
+186 -23
View File
@@ -98,11 +98,11 @@ func TestSortItems(t *testing.T) {
{Name: "apple"},
{Name: "Banana"},
},
Files: []iteminfo.ItemInfo{
{Name: "File2.txt"},
{Name: "File10.txt"},
{Name: "File1"},
{Name: "banana"},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "File2.txt"}},
{ItemInfo: iteminfo.ItemInfo{Name: "File10.txt"}},
{ItemInfo: iteminfo.ItemInfo{Name: "File1"}},
{ItemInfo: iteminfo.ItemInfo{Name: "banana"}},
},
},
expected: iteminfo.FileInfo{
@@ -112,11 +112,11 @@ func TestSortItems(t *testing.T) {
{Name: "apple"},
{Name: "Banana"},
},
Files: []iteminfo.ItemInfo{
{Name: "banana"},
{Name: "File1"},
{Name: "File10.txt"},
{Name: "File2.txt"},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "banana"}},
{ItemInfo: iteminfo.ItemInfo{Name: "File1"}},
{ItemInfo: iteminfo.ItemInfo{Name: "File10.txt"}},
{ItemInfo: iteminfo.ItemInfo{Name: "File2.txt"}},
},
},
},
@@ -128,10 +128,10 @@ func TestSortItems(t *testing.T) {
{Name: "Cat.txt"},
{Name: "apple"},
},
Files: []iteminfo.ItemInfo{
{Name: "Zebra"},
{Name: "apple"},
{Name: "cat"},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "Zebra"}},
{ItemInfo: iteminfo.ItemInfo{Name: "apple"}},
{ItemInfo: iteminfo.ItemInfo{Name: "cat"}},
},
},
expected: iteminfo.FileInfo{
@@ -140,10 +140,10 @@ func TestSortItems(t *testing.T) {
{Name: "Cat.txt"},
{Name: "dog.txt"},
},
Files: []iteminfo.ItemInfo{
{Name: "apple"},
{Name: "cat"},
{Name: "Zebra"},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "apple"}},
{ItemInfo: iteminfo.ItemInfo{Name: "cat"}},
{ItemInfo: iteminfo.ItemInfo{Name: "Zebra"}},
},
},
},
@@ -153,7 +153,7 @@ func TestSortItems(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
test.input.SortItems()
getNames := func(items []iteminfo.ItemInfo) []string {
getFolderNames := func(items []iteminfo.ItemInfo) []string {
names := []string{}
for _, folder := range items {
names = append(names, folder.Name)
@@ -161,15 +161,23 @@ func TestSortItems(t *testing.T) {
return names
}
actualFolderNames := getNames(test.input.Folders)
expectedFolderNames := getNames(test.expected.Folders)
getFileNames := func(items []iteminfo.ExtendedItemInfo) []string {
names := []string{}
for _, file := range items {
names = append(names, file.Name)
}
return names
}
actualFolderNames := getFolderNames(test.input.Folders)
expectedFolderNames := getFolderNames(test.expected.Folders)
if !reflect.DeepEqual(actualFolderNames, expectedFolderNames) {
t.Errorf("Folders not sorted correctly.\nGot: %v\nExpected: %v", actualFolderNames, expectedFolderNames)
}
actualFileNames := getNames(test.input.Files)
expectedFileNames := getNames(test.expected.Files)
actualFileNames := getFileNames(test.input.Files)
expectedFileNames := getFileNames(test.expected.Files)
if !reflect.DeepEqual(actualFileNames, expectedFileNames) {
t.Errorf("Files not sorted correctly.\nGot: %v\nExpected: %v", actualFileNames, expectedFileNames)
@@ -177,3 +185,158 @@ func TestSortItems(t *testing.T) {
})
}
}
// TestDeleteFilesCacheClearing was removed because cache clearing is not performed
// per design decision - RealPathCache is auxiliary and doesn't need clearing
func TestOverrideDirectoryToFile(t *testing.T) {
// Initialize the index in mock mode (no filesystem operations)
indexing.Initialize(&settings.Source{
Name: "test",
Path: "/mock/path",
}, true) // true for mock mode
// Get the index and set up mock data
idx := indexing.GetIndex("test")
if idx == nil { //nolint:staticcheck // t.Fatal terminates execution
t.Fatal("Failed to get test index")
}
// Create mock directory structure
idx.Directories["/"] = &iteminfo.FileInfo{ //nolint:staticcheck // t.Fatal terminates execution
Path: "/",
ItemInfo: iteminfo.ItemInfo{
Name: "/",
Type: "directory",
},
Folders: []iteminfo.ItemInfo{
{Name: "Test Object", Type: "directory"},
},
}
// Simulate the directory-to-file override by updating the mock data
// Remove the directory from the parent's Folders slice
rootInfo := idx.Directories["/"] //nolint:staticcheck // t.Fatal terminates execution
for i, folder := range rootInfo.Folders {
if folder.Name == "Test Object" {
rootInfo.Folders = append(rootInfo.Folders[:i], rootInfo.Folders[i+1:]...)
break
}
}
// Add the file to the parent's Files slice
rootInfo.Files = append(rootInfo.Files, iteminfo.ExtendedItemInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "Test Object",
Size: 25, // Length of "This is test file content"
},
})
// Verify the directory was replaced with a file in the mock data
rootInfo, exists := idx.GetMetadataInfo("/", true)
if !exists {
t.Fatal("Root metadata not found")
}
// 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")
}
// 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 !foundFile {
t.Error("File 'Test Object' should have been added to Files")
}
}
func TestOverrideFileToDirectory(t *testing.T) {
// Initialize the index in mock mode (no filesystem operations)
indexing.Initialize(&settings.Source{
Name: "test",
Path: "/mock/path",
}, true) // true for mock mode
// Get the index and set up mock data
idx := indexing.GetIndex("test")
if idx == nil { //nolint:staticcheck // t.Fatal terminates execution
t.Fatal("Failed to get test index")
}
// Create mock directory structure with a file
idx.Directories["/"] = &iteminfo.FileInfo{ //nolint:staticcheck // t.Fatal terminates execution
Path: "/",
ItemInfo: iteminfo.ItemInfo{
Name: "/",
Type: "directory",
},
Files: []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "Test Object", Size: 12}}, // Length of "test content"
},
}
// Simulate the file-to-directory override by updating the mock data
// Remove the file from the parent's Files slice
rootInfo := idx.Directories["/"] //nolint:staticcheck // t.Fatal terminates execution
for i, file := range rootInfo.Files {
if file.Name == "Test Object" {
rootInfo.Files = append(rootInfo.Files[:i], rootInfo.Files[i+1:]...)
break
}
}
// 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")
}
// 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")
}
// 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")
}
}
+431 -130
View File
@@ -1,96 +1,135 @@
package files
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"context"
"encoding/base64"
"fmt"
"hash"
"io"
"unicode"
"unicode/utf8"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/dhowden/tag"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"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/ffmpeg"
"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 {
return response, fmt.Errorf("could not get index: %v ", opts.Source)
}
if !strings.HasPrefix(opts.Path, "/") {
opts.Path = "/" + opts.Path
}
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
err = index.RefreshFileInfo(opts)
if err != nil {
if err == errors.ErrNotIndexed && index.Config.DisableIndexing {
info, err = index.GetFsDirInfo(opts.Path)
if err != nil {
return response, err
}
} else if err == errors.ErrNotIndexed {
return response, fmt.Errorf("could not refresh file info: %v", err)
}
} else {
info, exists = index.GetReducedMetadata(opts.Path, opts.IsDir)
if !exists {
return response, fmt.Errorf("could not get metadata for path: %v", opts.Path)
// Check if path is viewable (allows filesystem access without indexing)
isViewable := index.IsViewable(isDir, opts.Path)
// For non-viewable paths, verify they are indexed
// Skip this check if indexing is disabled for the entire source
if !isViewable && !index.Config.DisableIndexing {
err = index.RefreshFileInfo(opts)
if err != nil {
return response, fmt.Errorf("path not accessible: %v", err)
}
}
if isDir {
info, err = index.GetFsDirInfo(opts.Path)
if err != nil {
return response, err
}
} else {
// For files, get info from parent directory to ensure HasPreview is set correctly
info, err = index.GetFsDirInfo(opts.Path)
if err != nil {
return response, err
}
}
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)
// For directories, populate metadata for audio/video files ONLY if explicitly requested
// This avoids expensive ffprobe calls on every directory listing
if isDir && opts.Metadata {
startTime := time.Now()
metadataCount := 0
for i := range response.Files {
fileItem := &response.Files[i]
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
if isItemAudio || isItemVideo {
// Get the real path for this file
itemRealPath, _, _ := index.GetRealPath(opts.Path, fileItem.Name)
// Extract metadata for audio files (without album art for performance)
if isItemAudio {
err := extractAudioMetadata(context.Background(), fileItem, itemRealPath, opts.AlbumArt || opts.Content, opts.Metadata)
if err != nil {
logger.Debugf("failed to extract metadata for file: "+fileItem.Name, err)
} else {
metadataCount++
}
} else if isItemVideo {
// Extract duration for video files
err := extractVideoMetadata(context.Background(), fileItem, itemRealPath)
if err != nil {
logger.Debugf("failed to extract video metadata for file: "+fileItem.Name, err)
} else {
metadataCount++
}
}
}
}
if metadataCount > 0 {
elapsed := time.Since(startTime)
logger.Debugf("Extracted metadata for %d audio/video files in %v (avg: %v per file)",
metadataCount, elapsed, elapsed/time.Duration(metadataCount))
}
}
// Extract content/metadata when explicitly requested OR for single file audio/video requests
isAudioVideo := strings.HasPrefix(info.Type, "audio") || strings.HasPrefix(info.Type, "video")
if opts.Content || opts.Metadata || (!isDir && isAudioVideo) {
processContent(response, index, opts)
}
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
response.OnlyOfficeId = generateOfficeId(realPath)
}
@@ -98,19 +137,58 @@ 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"
if isFolder {
return
}
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())
// Extract duration for video
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
err := extractVideoMetadata(context.Background(), extItem, info.RealPath)
if err != nil {
logger.Debugf("failed to extract video metadata for file: "+info.RealPath, info.Name, err)
} else {
info.Metadata = extItem.Metadata
}
// Handle subtitles if requested
if opts.ExtractEmbeddedSubtitles {
parentPath := filepath.Dir(info.Path)
parentInfo, exists := idx.GetReducedMetadata(parentPath, true)
if exists {
info.DetectSubtitles(parentInfo)
err := info.LoadSubtitleContent()
if err != nil {
logger.Debug("failed to load subtitle content: " + err.Error())
}
}
}
return
}
if isAudio {
// Create an ExtendedItemInfo to hold the metadata
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content)
if err != nil {
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
} else {
// Copy metadata to ExtendedFileInfo
info.Metadata = extItem.Metadata
info.HasPreview = extItem.Metadata != nil && extItem.Metadata.AlbumArt != ""
}
return
}
// Process text content for non-video, non-audio files
if info.Size < 20*1024*1024 { // 20 megabytes in bytes
content, err := getContent(info.RealPath)
if err != nil {
@@ -119,12 +197,12 @@ 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)
}
}
func generateOfficeId(realPath string) string {
key, ok := utils.OnlyOfficeCache.Get(realPath).(string)
key, ok := utils.OnlyOfficeCache.Get(realPath)
if !ok {
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
documentKey := utils.HashSHA256(realPath + timestamp)
@@ -134,57 +212,135 @@ func generateOfficeId(realPath string) string {
return key
}
// Checksum checksums a given File for a given User, using a specific
// algorithm. The checksums data is saved on File object.
func GetChecksum(fullPath, algo string) (map[string]string, error) {
subs := map[string]string{}
reader, err := os.Open(fullPath)
if err != nil {
return subs, err
}
defer reader.Close()
hashFuncs := map[string]hash.Hash{
"md5": md5.New(),
"sha1": sha1.New(),
"sha256": sha256.New(),
"sha512": sha512.New(),
}
h, ok := hashFuncs[algo]
if !ok {
return subs, errors.ErrInvalidOption
}
_, err = io.Copy(h, reader)
if err != nil {
return subs, err
}
subs[algo] = hex.EncodeToString(h.Sum(nil))
return subs, nil
}
func DeleteFiles(source, absPath string, absDirPath string) error {
err := os.RemoveAll(absPath)
// extractAudioMetadata extracts metadata from an audio file using dhowden/tag
// and optionally extracts duration using the ffmpeg service with concurrency control
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool) error {
file, err := os.Open(realPath)
if err != nil {
return err
}
index := indexing.GetIndex(source)
if index == nil {
return fmt.Errorf("could not get index: %v ", source)
defer file.Close()
// Check file size first to prevent reading extremely large files
fileInfo, err := file.Stat()
if err != nil {
return err
}
if index.Config.DisableIndexing {
// Skip files larger than 300MB to prevent memory issues
maxSize := int64(300)
if fileInfo.Size() > maxSize*1024*1024 {
return fmt.Errorf("file with size %d MB exceeds metadata check limit: %d MB", fileInfo.Size()/1024/1024, maxSize)
}
m, err := tag.ReadFrom(file)
if err != nil {
return err
}
item.Metadata = &iteminfo.MediaMetadata{
Title: m.Title(),
Artist: m.Artist(),
Album: m.Album(),
Year: m.Year(),
Genre: m.Genre(),
}
// Extract track number
track, _ := m.Track()
item.Metadata.Track = track
// Extract duration ONLY if explicitly requested using the ffmpeg VideoService
// This respects concurrency limits and gracefully handles missing ffmpeg
if getDuration {
ffmpegService := ffmpeg.NewFFmpegService(5, false, "")
if ffmpegService != nil {
startTime := time.Now()
if duration, err := ffmpegService.GetMediaDuration(ctx, realPath); err == nil {
item.Metadata.Duration = int(duration)
elapsed := time.Since(startTime)
if elapsed > 50*time.Millisecond {
logger.Debugf("Duration extraction took %v for file: %s", elapsed, item.Name)
}
}
}
}
if !getArt {
return nil
}
refreshConfig := iteminfo.FileOptions{Path: index.MakeIndexPath(absDirPath), IsDir: true}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return err
// Extract album art and encode as base64 with strict size limits
if picture := m.Picture(); picture != nil && picture.Data != nil {
// More aggressive size limit to prevent memory issues (max 5MB)
if len(picture.Data) <= 5*1024*1024 {
item.Metadata.AlbumArt = base64.StdEncoding.EncodeToString(picture.Data)
} else {
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, len(picture.Data))
}
}
return nil
}
// extractVideoMetadata extracts duration from video files using ffprobe
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string) error {
// Extract duration using the ffmpeg VideoService with concurrency control
videoService := ffmpeg.NewFFmpegService(10, false, "")
if videoService != nil {
duration, err := videoService.GetMediaDuration(ctx, realPath)
if err != nil {
return err
}
if duration > 0 {
item.Metadata = &iteminfo.MediaMetadata{
Duration: int(duration),
}
}
return nil
}
return nil
}
func RefreshIndex(source string, path string, isDir bool) error {
func DeleteFiles(source, absPath string, absDirPath string, isDir bool) error {
index := indexing.GetIndex(source)
if index == nil {
return fmt.Errorf("could not get index: %v ", source)
}
if !index.Config.DisableIndexing {
indexPath := index.MakeIndexPath(absPath)
// Perform the physical deletion
err := os.RemoveAll(absPath)
if err != nil {
return err
}
// Remove metadata from index
if isDir {
// Recursively remove directory and all subdirectories from index
index.DeleteMetadata(indexPath, true, true)
} else {
// Remove file from parent's file list
index.DeleteMetadata(indexPath, false, false)
}
// Refresh the parent directory to recalculate sizes and update counts
// This will traverse up the tree and update all parent sizes correctly
refreshConfig := utils.FileOptions{
Path: index.MakeIndexPath(absDirPath),
IsDir: true,
}
err = index.RefreshFileInfo(refreshConfig)
return err
}
// Indexing disabled, just delete the file
return os.RemoveAll(absPath)
}
func RefreshIndex(source string, path string, isDir bool, recursive bool) error {
idx := indexing.GetIndex(source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", source)
@@ -192,8 +348,36 @@ func RefreshIndex(source string, path string, isDir 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})
// MakeIndexPath always adds trailing slash, but for files we need to remove it
if !isDir {
path = strings.TrimSuffix(path, "/")
}
// Skip indexing for viewable paths (viewable: true means don't index, just allow FS access)
if idx.IsViewable(isDir, path) {
return nil
}
// For directories, check if the path exists on disk
// If it doesn't exist, remove it from the index
if isDir {
realPath, _, err := idx.GetRealPath(path)
if err == nil {
// Check if the directory exists on disk
if !Exists(realPath) {
// Directory no longer exists, remove it from the index
// This clears both Directories and DirectoriesLedger maps
idx.DeleteMetadata(path, true, false)
return nil
}
}
}
err := idx.RefreshFileInfo(utils.FileOptions{Path: path, IsDir: isDir, Recursive: recursive})
return err
}
// validateMoveDestination checks if a move/rename operation is valid
@@ -225,35 +409,92 @@ func validateMoveDestination(src, dst string, isSrcDir bool) error {
return nil
}
func MoveResource(isSrcDir, isDestDir bool, sourceIndex, destIndex, realsrc, realdst string, s *share.Storage) error {
func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string, s *share.Storage, a *access.Storage) error {
// Check if source and destination are the same file
if realsrc == realdst {
return fmt.Errorf("cannot move a file to itself: %s", realsrc)
}
// Validate the move operation before executing
if err := validateMoveDestination(realsrc, realdst, isSrcDir); err != nil {
return err
}
// Get indexes for deletion and refresh operations
srcIdx := indexing.GetIndex(sourceIndex)
if srcIdx == nil {
return fmt.Errorf("could not get source index: %v", sourceIndex)
}
dstIdx := indexing.GetIndex(destIndex)
if dstIdx == nil {
return fmt.Errorf("could not get destination index: %v", destIndex)
}
// Prepare paths for index operations
srcIndexPath := srcIdx.MakeIndexPath(realsrc)
srcParentPath := filepath.Dir(realsrc)
// Perform the physical move
err := fileutils.MoveFile(realsrc, realdst)
if err != nil {
return err
}
go RefreshIndex(sourceIndex, realsrc, isSrcDir) //nolint:errcheck
go RefreshIndex(destIndex, realdst, isDestDir) //nolint:errcheck
// update shares
idx := indexing.GetIndex(sourceIndex)
if idx == nil {
return fmt.Errorf("could not get index: %v ", sourceIndex)
// Handle SOURCE cleanup (treat as deletion)
if !srcIdx.Config.DisableIndexing {
// Remove metadata from source index
if isSrcDir {
// Recursively remove directory and all subdirectories from source index
srcIdx.DeleteMetadata(srcIndexPath, true, true)
} else {
// Remove file from source parent's file list
srcIdx.DeleteMetadata(srcIndexPath, false, false)
}
// Refresh the source parent directory to recalculate sizes and update counts
go RefreshIndex(sourceIndex, srcParentPath, true, false) //nolint:errcheck
}
idx2 := indexing.GetIndex(destIndex)
if idx2 == nil {
return fmt.Errorf("could not get index: %v ", destIndex)
// Handle DESTINATION indexing
if !dstIdx.Config.DisableIndexing {
if isSrcDir {
go func() {
// When moving a folder, refresh the folder itself recursively FIRST
// Must complete before parent refresh to avoid race condition
RefreshIndex(destIndex, realdst, true, true) //nolint:errcheck
// THEN refresh the parent directory so it sees the newly indexed child
parentDir := filepath.Dir(realdst)
RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
}()
} else {
// If moving a file, just refresh the parent directory
parentDir := filepath.Dir(realdst)
go RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
}
}
// Use backend source paths to match how shares are stored
go s.UpdateShares(idx.Path, idx.MakeIndexPath(realsrc), idx2.Path, idx2.MakeIndexPath(realdst)) //nolint:errcheck
go s.UpdateShares(srcIdx.Path, srcIdx.MakeIndexPath(realsrc), dstIdx.Path, dstIdx.MakeIndexPath(realdst)) //nolint:errcheck
// Update access rules for the moved path
if a != nil {
// If moving within the same source, update the rules
if srcIdx.Path == dstIdx.Path {
go a.UpdateRules(srcIdx.Path, srcIdx.MakeIndexPath(realsrc), dstIdx.MakeIndexPath(realdst)) //nolint:errcheck
}
// Cross-source moves don't preserve access rules (they're source-specific)
}
return nil
}
func CopyResource(isSrcDir, isDestDir bool, sourceIndex, destIndex, realsrc, realdst string) error {
func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string) error {
// Check if source and destination are the same file
if realsrc == realdst {
return fmt.Errorf("cannot copy a file to itself: %s", realsrc)
}
// Validate the copy operation before executing
if err := validateMoveDestination(realsrc, realdst, isSrcDir); err != nil {
return err
@@ -263,39 +504,92 @@ func CopyResource(isSrcDir, isDestDir bool, sourceIndex, destIndex, realsrc, rea
if err != nil {
return err
}
go RefreshIndex(sourceIndex, realsrc, isSrcDir) //nolint:errcheck
go RefreshIndex(destIndex, realdst, isDestDir) //nolint:errcheck
// update shares
// For copy operations:
// 1. Shallow refresh of source (just to update access times if needed)
// 2. Recursively index the destination to capture the entire copied tree
// Refresh source (parent directory if it's a file)
srcRefreshPath := realsrc
srcRefreshIsDir := isSrcDir
if !isSrcDir {
srcRefreshPath = filepath.Dir(realsrc)
srcRefreshIsDir = true
}
go RefreshIndex(sourceIndex, srcRefreshPath, srcRefreshIsDir, false) //nolint:errcheck
// Refresh destination (parent directory if it's a file)
dstRefreshPath := realdst
if !isSrcDir {
// If copying a file (regardless of destination), refresh the parent directory
dstRefreshPath = filepath.Dir(realdst)
}
go RefreshIndex(destIndex, dstRefreshPath, true, 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)
}
realPath, _, _ := idx.GetRealPath(opts.Path)
var stat os.FileInfo
var err error
// Check if the destination exists and is a file
if stat, err = os.Stat(realPath); err == nil && !stat.IsDir() {
// If it's a file and we're trying to create a directory, remove the file first
err = os.Remove(realPath)
if err != nil {
return fmt.Errorf("could not remove existing file to create directory: %v", err)
}
}
// Ensure the parent directories exist
err := os.MkdirAll(realPath, fileutils.PermDir)
err = os.MkdirAll(realPath, fileutils.PermDir)
if err != nil {
return err
}
return RefreshIndex(idx.Name, opts.Path, true)
// 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 {
idx := indexing.GetIndex(opts.Source)
func WriteFile(source, path string, in io.Reader) error {
idx := indexing.GetIndex(source)
if idx == nil {
return fmt.Errorf("could not get index: %v ", opts.Source)
return fmt.Errorf("could not get index: %v ", source)
}
dst, _, _ := idx.GetRealPath(opts.Path)
realPath, _, _ := idx.GetRealPath(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(dst), fileutils.PermDir)
parentDir := filepath.Dir(realPath)
err := os.MkdirAll(parentDir, fileutils.PermDir)
if err != nil {
return err
}
var stat os.FileInfo
// Check if the destination exists and is a directory
if stat, err = os.Stat(realPath); err == nil && stat.IsDir() {
// If it's a directory and we're trying to create a file, remove the directory first
err = os.RemoveAll(realPath)
if err != nil {
return fmt.Errorf("could not remove existing directory to create file: %v", err)
}
}
// Open the file for writing (create if it doesn't exist, truncate if it does)
file, err := os.OpenFile(dst, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
file, err := os.OpenFile(realPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
}
@@ -306,7 +600,14 @@ func WriteFile(opts iteminfo.FileOptions, in io.Reader) error {
if err != nil {
return err
}
return RefreshIndex(opts.Source, opts.Path, false)
// Explicitly set file permissions to bypass umask
err = os.Chmod(realPath, fileutils.PermFile)
if err != nil {
return err
}
return RefreshIndex(source, path, false, false)
}
// getContent reads and returns the file content if it's considered an editable text file.
+3 -2
View File
@@ -590,10 +590,11 @@ var types = map[string]string{
".xpi": "application/x-xpinstall",
".wasm": "application/wasm",
".m4a": "audio/mp4",
".flac": "audio/x-flac",
".flac": "audio/flac",
".amr": "audio/amr",
".aac": "audio/aac",
".opus": "video/ogg",
".opus": "audio/ogg", // https://wiki.xiph.org/MIME_Types_and_File_Extensions
".ogx": "application/ogg",
".m4v": "video/mp4",
".mkv": "video/x-matroska",
".caf": "audio/x-caf",
+13 -13
View File
@@ -6,12 +6,16 @@ import (
"path"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
const PermFile = 0644
const PermDir = 0755
var PermFile os.FileMode
var PermDir os.FileMode
func SetFsPermissions(PermFileOctal os.FileMode, PermDirOctal os.FileMode) {
PermFile = PermFileOctal
PermDir = PermDirOctal
}
// MoveFile moves a file from src to dst.
// By default, the rename system call is used. If src and dst point to different volumes,
@@ -25,14 +29,14 @@ func MoveFile(src, dst string) error {
// fallback
err = CopyFile(src, dst)
if err != nil {
logger.Errorf("CopyFile failed %v %v %v ", src, dst, err)
logger.Errorf("CopyFile failed %v", err)
return err
}
go func() {
err = os.RemoveAll(src)
if err != nil {
logger.Errorf("os.Remove failed %v %v ", src, err)
logger.Errorf("os.Remove failed %v", err)
}
}()
@@ -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
View File
@@ -20,8 +20,6 @@ func init() {
type Auther interface {
// Auth is called to authenticate a request.
Auth(r *http.Request, userStore *users.Storage) (*users.User, error)
// LoginPage indicates if this auther needs a login page.
LoginPage() bool
}
func IsRevokedApiKey(key string) bool {
-5
View File
@@ -65,11 +65,6 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
}
// LoginPage tells that json auth doesn't require a login page.
func (a JSONAuth) LoginPage() bool {
return true
}
const reCaptchaAPI = "/recaptcha/api/siteverify"
// ReCaptcha identifies a recaptcha connection.
-5
View File
@@ -16,8 +16,3 @@ type NoAuth struct{}
func (a NoAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
return usr.Get(uint(1))
}
// LoginPage tells that no auth doesn't require a login page.
func (a NoAuth) LoginPage() bool {
return false
}
-5
View File
@@ -26,8 +26,3 @@ func (a ProxyAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error
return user, err
}
// LoginPage tells that proxy auth doesn't require a login page.
func (a ProxyAuth) LoginPage() bool {
return false
}
+3 -3
View File
@@ -2,12 +2,12 @@ package auth
import (
"encoding/base64"
"log"
"crypto/sha256"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/crypto/pbkdf2"
)
@@ -42,12 +42,12 @@ func NewStorage(back StorageBackend, userStore *users.Storage) (*Storage, error)
key, err := base64.StdEncoding.DecodeString(settings.Config.Auth.TotpSecret)
keyLen := len(key)
if err == nil && (keyLen == 16 || keyLen == 24 || keyLen == 25) {
if err == nil && (keyLen == 16 || keyLen == 24 || keyLen == 32) {
// Use the user-provided key if it's valid
encryptionKey = key
} else {
// Otherwise, derive the key from the provided secret string
log.Println("[WARN] totpSecret is not a valid Base64 encoded key. Deriving a key from it. For better security, generate a secret with 'openssl rand -base64 32'.")
logger.Warningf("totpSecret is not a valid Base64 encoded key. Deriving a key from it. For better security, generate a secret with 'openssl rand -base64 32'.")
salt := []byte{0xda, 0x90, 0x45, 0xc3, 0x06, 0xb5, 0x99, 0x9f, 0xb6, 0xae, 0xfc, 0x14, 0xef, 0x27, 0x6e, 0x6a}
encryptionKey = pbkdf2.Key([]byte(settings.Config.Auth.TotpSecret), salt, 4096, 32, sha256.New)
}
+3 -3
View File
@@ -48,7 +48,7 @@ var (
// (2*2 + 1) * 30s = 150s (2.5 minutes). This is the closest we can get.
TOTPSkew uint = uint(TokenValidTime.Seconds()) / (2 * uint(TOTPPeriod))
encryptionKey []byte
TotpCache = cache.NewCache(5 * time.Minute)
TotpCache = cache.NewCache[string](5 * time.Minute)
)
func GenerateOtpForUser(user *users.User, userStore *users.Storage) (string, error) {
@@ -145,7 +145,7 @@ func decryptSecret(b64Ciphertext, b64Nonce string) (string, error) {
func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) error {
// get data from cache
cachedSecret, found := TotpCache.Get(user.Username).(string)
cachedSecret, found := TotpCache.Get(user.Username)
if !found && user.TOTPSecret == "" {
return fmt.Errorf("OTP token not found in cache, please generate a new one")
}
@@ -191,7 +191,7 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
user.TOTPNonce = totpNonce // The nonce if encrypted, or empty if plaintext
user.OtpEnabled = true // Enable OTP for the user
// save user
if err := userStore.Update(user, true, "TOTPSecret", "TOTPNonce"); err != nil {
if err := userStore.Update(user, true, "TOTPSecret", "TOTPNonce", "OtpEnabled"); err != nil {
logger.Error("error updating user with OTP token:", err)
return fmt.Errorf("error updating user with OTP token: %w", err)
}
+82 -57
View File
@@ -3,81 +3,106 @@
/usr/local/go/bin/go
? github.com/gtsteffaniak/filebrowser/backend [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/diskcache 0.004s
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/diskcache 0.005s
2025/11/07 17:48:07 [INFO] initializing index: [test]
2025/11/07 17:48:07 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /tmp/filebrowser_test3280924123/: no such file or directory
2025/11/07 17:48:07 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:07 [INFO] initializing index: [test]
2025/11/07 17:48:07 [INFO] initializing index: [test]
2025/11/07 17:48:07 [ERROR] Root scanner error: open /tmp/filebrowser_test3280924123/: no such file or directory
2025/11/07 17:48:07 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /tmp/filebrowser_test3280924123/: no such file or directory
2025/11/07 17:48:07 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:07 [DEBUG] Skipping source update event for mock index.
2025/11/07 17:48:07 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
2025/11/07 17:48:07 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:07 [ERROR] Root scanner error: open /mock/path/: no such file or directory
2025/11/07 17:48:07 [ERROR] Root scanner error: open /mock/path/: no such file or directory
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
2025/11/07 17:48:07 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:07 [DEBUG] Skipping source update event for mock index.
2025/11/07 17:48:07 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:07 [DEBUG] Skipping source update event for mock index.
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
2025/11/07 17:48:07 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
PASS
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files 0.006s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils 0.003s
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils 0.002s
? github.com/gtsteffaniak/filebrowser/backend/auth [no test files]
? github.com/gtsteffaniak/filebrowser/backend/cmd [no test files]
? github.com/gtsteffaniak/go-cache/cache [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/cmd 0.014s
? github.com/gtsteffaniak/filebrowser/backend/common/errors [no test files]
? github.com/gtsteffaniak/filebrowser/backend/common/logger [no test files]
2025/05/11 20:42:56 Using OnlyOffice secret from FILEBROWSER_ONLYOFFICE_SECRET environment variable
2025/05/11 20:42:56 Using OnlyOffice secret from FILEBROWSER_ONLYOFFICE_SECRET environment variable
goos: linux
goarch: amd64
pkg: github.com/gtsteffaniak/filebrowser/backend/common/settings
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
10 374600 ns/op 192030 B/op 3237 allocs/op
PASS
ok github.com/gtsteffaniak/filebrowser/backend/common/settings 0.006s
ok github.com/gtsteffaniak/filebrowser/backend/common/settings 0.139s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/common/utils 0.003s
ok github.com/gtsteffaniak/filebrowser/backend/common/utils 0.004s
? github.com/gtsteffaniak/filebrowser/backend/common/version [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/database/access 0.287s
? github.com/gtsteffaniak/filebrowser/backend/database/crud [no test files]
? github.com/gtsteffaniak/filebrowser/backend/database/share [no test files]
? github.com/gtsteffaniak/filebrowser/backend/database/storage [no test files]
? github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/database/users 0.002s
ok github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt 0.842s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/database/users 0.003s
? github.com/gtsteffaniak/filebrowser/backend/docs [no test files]
? github.com/gtsteffaniak/filebrowser/backend/events [no test files]
2025/05/11 20:42:56 [DEBUG] Saving user [admin] changepass: true
2025/05/11 20:42:56 [DEBUG] Saving user [non-admin] changepass: true
goos: linux
goarch: amd64
pkg: github.com/gtsteffaniak/filebrowser/backend/ffmpeg
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
BenchmarkGetOrientationFilter-8 10 63.00 ns/op 0 B/op 0 allocs/op
PASS
ok github.com/gtsteffaniak/filebrowser/backend/http 0.197s
2025/05/11 20:42:57 initializing index: [test]
2025/05/11 20:42:57 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:42:57 [DEBUG] Starting full scan for [test]
2025/05/11 20:42:57 Index assessment : [test] complexity=simple directories=0 files=0
2025/05/11 20:42:57 [DEBUG] Time spent indexing [test]: 0 seconds
2025/05/11 20:42:57 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:42:57 [DEBUG] Next scan in 1m0s
2025/05/11 20:42:58 initializing index: [test]
2025/05/11 20:42:58 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:42:58 [DEBUG] Starting full scan for [test]
2025/05/11 20:42:58 Index assessment : [test] complexity=simple directories=0 files=0
2025/05/11 20:42:58 [DEBUG] Time spent indexing [test]: 0 seconds
2025/05/11 20:42:58 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:42:58 [DEBUG] Next scan in 1m0s
ok github.com/gtsteffaniak/filebrowser/backend/ffmpeg 0.006s
2025/11/07 17:48:08 [DEBUG] Saving user [admin] changepass: true
2025/11/07 17:48:08 [DEBUG] Saving user [non-admin] changepass: true
PASS
ok github.com/gtsteffaniak/filebrowser/backend/http 0.248s
2025/11/07 17:48:08 [INFO] initializing index: [test]
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
2025/11/07 17:48:08 [INFO] initializing index: [test]
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
goos: linux
goarch: amd64
pkg: github.com/gtsteffaniak/filebrowser/backend/indexing
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
BenchmarkFillIndex-8 2025/05/11 20:42:59 initializing index: [test]
2025/05/11 20:42:59 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:42:59 [DEBUG] Starting full scan for [test]
2025/05/11 20:42:59 Index assessment : [test] complexity=simple directories=0 files=0
2025/05/11 20:42:59 [DEBUG] Time spent indexing [test]: 0 seconds
2025/05/11 20:42:59 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:42:59 [DEBUG] Next scan in 1m0s
10 3548759 ns/op 38032 B/op 452 allocs/op
BenchmarkCheckIndexExclude-8 10 172.6 ns/op 0 B/op 0 allocs/op
BenchmarkCheckIndexConditionsInclude-8 10 118.2 ns/op 0 B/op 0 allocs/op
2025/05/11 20:43:01 initializing index: [test]
2025/05/11 20:43:01 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:43:01 [DEBUG] Starting full scan for [test]
2025/05/11 20:43:01 Index assessment : [test] complexity=simple directories=0 files=0
2025/05/11 20:43:01 [DEBUG] Time spent indexing [test]: 0 seconds
2025/05/11 20:43:01 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:43:01 [DEBUG] Next scan in 1m0s
BenchmarkSearchAllIndexes-8 2025/05/11 20:43:02 initializing index: [test]
2025/05/11 20:43:02 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:43:02 [DEBUG] Starting full scan for [test]
2025/05/11 20:43:02 Index assessment : [test] complexity=simple directories=0 files=0
2025/05/11 20:43:02 [DEBUG] Time spent indexing [test]: 0 seconds
2025/05/11 20:43:02 [DEBUG] Skipping source update event for mock index.
2025/05/11 20:43:02 [DEBUG] Next scan in 1m0s
10 781119 ns/op 38515 B/op 900 allocs/op
BenchmarkFillIndex-8 2025/11/07 17:48:08 [INFO] initializing index: [test]
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
10 3611835 ns/op 323676 B/op 1655 allocs/op
2025/11/07 17:48:08 [INFO] initializing index: [test]
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
BenchmarkSearchAllIndexes-8 2025/11/07 17:48:08 [INFO] initializing index: [test]
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
10 784497 ns/op 97700 B/op 1061 allocs/op
PASS
ok github.com/gtsteffaniak/filebrowser/backend/indexing 5.097s
ok github.com/gtsteffaniak/filebrowser/backend/indexing 0.077s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo 0.005s
ok github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo 0.006s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/preview 0.128s
ok github.com/gtsteffaniak/filebrowser/backend/preview 0.130s
? github.com/gtsteffaniak/filebrowser/backend/swagger/docs [no test files]
? github.com/gtsteffaniak/filebrowser/backend/tools [no test files]
+120
View File
@@ -0,0 +1,120 @@
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
}
// 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 + "/"
// 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)
}
}
}
// 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
}
+12 -3
View File
@@ -110,9 +110,9 @@ func runCLI() bool {
} else {
logger.Infof("Creating non-admin user: %s\n", username)
}
newUser.Permissions = settings.Config.UserDefaults.Permissions
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Permissions)
newUser.Permissions.Admin = asAdmin
err = storage.CreateUser(newUser)
err = storage.CreateUser(newUser, newUser.Permissions)
if err != nil {
logger.Errorf("could not create user: %v", err)
}
@@ -128,6 +128,10 @@ func runCLI() bool {
if asAdmin {
user.Permissions.Admin = true
}
// Ensure version is set for existing users being updated
if user.Version == 0 {
user.Version = 1
}
err = store.Users.Save(user, true, false)
if err != nil {
logger.Errorf("could not update user: %v", err)
@@ -349,11 +353,16 @@ func createConfig(configpath string) {
}
func generateYaml() {
if os.Getenv("FILEBROWSER_GENERATE_CONFIG") != "" {
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
if generateConfig || settings.Env.IsDevMode {
logger.Info("Generating config.yaml")
settings.GenerateYaml()
}
if generateConfig {
os.Exit(0)
}
}
func SplitByMultiple(str string) []string {
+19 -4
View File
@@ -15,6 +15,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/preview"
@@ -77,6 +78,14 @@ func StartFilebrowser() {
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
}
// Dev mode enables development features like template hot-reloading
_, err := os.Stat("http/dist")
// In dev mode, always use filesystem assets. Otherwise, check if http/dist exists
if !settings.Env.IsDevMode {
settings.Env.EmbeddedFs = os.IsNotExist(err)
}
sourceList := []string{}
for path, source := range settings.Config.Server.SourceMap {
sourceList = append(sourceList, fmt.Sprintf("%v: %v", source.Name, path))
@@ -86,6 +95,7 @@ func StartFilebrowser() {
logger.Infof("Auth Methods : %v", settings.Config.Auth.AuthMethods)
logger.Info(database)
logger.Infof("Sources : %v", sourceList)
logger.Debugf("Using Embedded FS : %v", settings.Env.EmbeddedFs)
serverConfig := settings.Config.Server
swagInfo := docs.SwaggerInfo
@@ -98,8 +108,10 @@ func StartFilebrowser() {
for _, source := range settings.Config.Server.SourceMap {
go indexing.Initialize(source, false)
}
validateUserInfo()
validateUserInfo(!dbExists)
validateOfficeIntegration()
validateAccessRules()
validateShareInfo()
// Start the rootCMD in a goroutine
go func() {
if err := rootCMD(ctx, store, &serverConfig, shutdownComplete); err != nil {
@@ -115,7 +127,9 @@ func StartFilebrowser() {
case <-done:
logger.Info("Server stopped unexpectedly. Shutting down...")
}
fileutils.ClearCacheDir()
if !*settings.Config.Server.CacheDirCleanup {
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
}
<-shutdownComplete // Ensure we don't exit prematurely
// Wait for the server to stop
@@ -128,9 +142,10 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
}
cacheDir := settings.Config.Server.CacheDir
numWorkers := settings.Config.Server.NumImageProcessors
ffpmpegPath := settings.Config.Integrations.Media.FfmpegPath
ffmpeg.SetFFmpegPaths()
// setup disk cache
err := preview.StartPreviewGenerator(numWorkers, ffpmpegPath, cacheDir)
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
if err != nil {
logger.Fatalf("Error starting preview service: %v", err)
}
+57
View File
@@ -0,0 +1,57 @@
package cmd
import (
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// validateShareInfo migrates share links to add default sidebar links
func validateShareInfo() {
if store.Share == nil {
return
}
// Get all shares
shares, err := store.Share.All()
if err != nil {
logger.Debugf("No shares found or error getting shares: %v", err)
return
}
migratedCount := 0
for _, link := range shares {
// Check if this share needs migration (version not set or 0)
if link.Version == 0 {
// Add default sidebar links
link.SidebarLinks = []users.SidebarLink{
{
Name: "Share QR Code and Info",
Category: "shareInfo",
Target: "#",
Icon: "qr_code",
},
{
Name: "Download",
Category: "download",
Target: "#",
Icon: "download",
},
}
// Set version to 1 to indicate migration is complete
link.Version = 1
// Save the updated share
if err := store.Share.Save(link); err != nil {
logger.Errorf("Failed to migrate share %s: %v", link.Hash, err)
continue
}
migratedCount++
}
}
if migratedCount > 0 {
logger.Infof("Migrated %d share links with default sidebar links", migratedCount)
}
}
+48 -14
View File
@@ -2,6 +2,7 @@ package cmd
import (
"fmt"
"os"
"reflect"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
@@ -10,9 +11,9 @@ import (
"github.com/gtsteffaniak/go-logger/logger"
)
var createBackup = []bool{}
var createBackup = false
func validateUserInfo() {
func validateUserInfo(newDB bool) {
// update source info for users if names/sources/paths might have changed
usersList, err := store.Users.Gets()
if err != nil {
@@ -33,6 +34,9 @@ func validateUserInfo() {
if updateLoginType(user) {
updateUser = true
}
if updateShowFirstLogin(user) {
updateUser = true
}
adminUser := settings.Config.Auth.AdminUsername
adminPass := settings.Config.Auth.AdminPassword
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
@@ -44,7 +48,8 @@ func validateUserInfo() {
changePass = true
}
if updateUser {
if len(createBackup) == 1 {
skipCreateBackup := os.Getenv("FILEBROWSER_DISABLE_AUTOMATIC_BACKUP") == "true" || newDB
if createBackup && !skipCreateBackup {
logger.Warning("Incompatible user settings detected, creating backup of database before converting.")
err = fileutils.CopyFile(settings.Config.Server.Database, fmt.Sprintf("%s.bak", settings.Config.Server.Database))
if err != nil {
@@ -71,27 +76,23 @@ func updateUserScopes(user *users.User) bool {
// Preserve order by using Config.Server.Sources
for _, src := range settings.Config.Server.Sources {
realsource, ok := settings.Config.Server.NameToSource[src.Name]
if !ok {
continue
}
existingScope, ok := existing[realsource.Path]
existingScope, ok := existing[src.Path]
if ok {
// If scope is empty and there's a default, apply default
if existingScope.Scope == "" {
existingScope.Scope = src.Config.DefaultUserScope
}
} else if realsource.Config.DefaultEnabled {
existingScope.Scope = realsource.Config.DefaultUserScope
} else if src.Config.DefaultEnabled {
existingScope.Scope = src.Config.DefaultUserScope
} else {
continue
}
newScopes = append(newScopes, users.SourceScope{
Name: realsource.Path,
Name: src.Path,
Scope: existingScope.Scope,
})
seen[realsource.Path] = struct{}{}
seen[src.Path] = struct{}{}
}
// Preserve user-defined scopes not matching current sources, append to end
@@ -105,9 +106,21 @@ func updateUserScopes(user *users.User) bool {
return changed
}
func updateShowFirstLogin(user *users.User) bool {
if user.ShowFirstLogin && !settings.Env.IsFirstLoad {
user.ShowFirstLogin = false
return true
}
return false
}
// func to convert legacy user with perm key to permissions
func updatePermissions(user *users.User) bool {
updateUser := false
if user.Version == 1 {
return false
}
updateUser := true
user.Permissions.Download = true
// if any keys are true, set the permissions to true
if user.Perm.Api {
user.Permissions.Api = true
@@ -129,8 +142,29 @@ func updatePermissions(user *users.User) bool {
user.Perm.Share = false
updateUser = true
}
if user.Perm.Create {
user.Permissions.Create = true
user.Perm.Create = false
updateUser = true
}
if user.Perm.Create {
user.Permissions.Create = true
user.Perm.Create = false
updateUser = true
}
if user.Perm.Download {
user.Permissions.Download = true
user.Perm.Download = false
updateUser = true
}
if user.Permissions.Modify {
user.Permissions.Create = true
user.Permissions.Delete = true
updateUser = true
}
user.Version = 1
if updateUser {
createBackup = append(createBackup, true)
createBackup = true
}
return updateUser
}
+14 -14
View File
@@ -16,14 +16,14 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
DefaultUserScope: "/defaultA",
}}
sourceB := settings.Source{Path: "/pathB", Name: "sourceB", Config: settings.SourceConfig{DefaultEnabled: true, DefaultUserScope: "/defaultB"}}
settings.Config.Server.Sources = []settings.Source{sourceA, sourceB}
settings.Config.Server.SourceMap = map[string]settings.Source{
"/pathA": sourceA,
"/pathB": sourceB,
settings.Config.Server.Sources = []*settings.Source{&sourceA, &sourceB}
settings.Config.Server.SourceMap = map[string]*settings.Source{
"/pathA": &sourceA,
"/pathB": &sourceB,
}
settings.Config.Server.NameToSource = map[string]settings.Source{
"sourceA": sourceA,
"sourceB": sourceB,
settings.Config.Server.NameToSource = map[string]*settings.Source{
"sourceA": &sourceA,
"sourceB": &sourceB,
}
// ---------------------
@@ -159,14 +159,14 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
CreateUserDir: true,
}}
sourceB = settings.Source{Path: "/pathB", Name: "sourceB", Config: settings.SourceConfig{DefaultEnabled: true, DefaultUserScope: "/defaultB"}}
settings.Config.Server.Sources = []settings.Source{sourceA, sourceB}
settings.Config.Server.SourceMap = map[string]settings.Source{
"/pathA": sourceA,
"/pathB": sourceB,
settings.Config.Server.Sources = []*settings.Source{&sourceA, &sourceB}
settings.Config.Server.SourceMap = map[string]*settings.Source{
"/pathA": &sourceA,
"/pathB": &sourceB,
}
settings.Config.Server.NameToSource = map[string]settings.Source{
"sourceA": sourceA,
"sourceB": sourceB,
settings.Config.Server.NameToSource = map[string]*settings.Source{
"sourceA": &sourceA,
"sourceB": &sourceB,
}
// Run again without resetting user objects to test idempotency + renaming
+3
View File
@@ -15,6 +15,7 @@ var (
ErrInvalidOption = errors.New("invalid option")
ErrInvalidAuthMethod = errors.New("invalid auth method")
ErrPermissionDenied = errors.New("permission denied")
ErrAccessDenied = errors.New("access denied")
ErrInvalidRequestParams = errors.New("invalid request params")
ErrSourceIsParent = errors.New("source is parent")
ErrNoTotpProvided = errors.New("OTP code is required for user")
@@ -22,4 +23,6 @@ var (
ErrUnauthorized = errors.New("user unauthorized")
ErrNotIndexed = errors.New("directory or item excluded from indexing")
ErrWrongLoginMethod = errors.New("user attempted to login with wrong login method")
ErrDownloadNotAllowed = errors.New("downloads are not allowed for this share")
ErrUploadNotAllowed = errors.New("upload permission not allowed for this share")
)
+36 -12
View File
@@ -8,9 +8,27 @@ import (
"net/http"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/go-logger/logger"
)
// userAgentTransport wraps an http.RoundTripper to add a User-Agent header
type userAgentTransport struct {
base http.RoundTripper
userAgent string
}
// RoundTrip implements the http.RoundTripper interface
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone the request to avoid modifying the original
reqCopy := req.Clone(req.Context())
// Set User-Agent header if not already set
if reqCopy.Header.Get("User-Agent") == "" {
reqCopy.Header.Set("User-Agent", t.userAgent)
}
return t.base.RoundTrip(reqCopy)
}
type Auth struct {
TokenExpirationHours int `json:"tokenExpirationHours"` // time in hours each web UI session token is valid for. Default is 2 hours.
Methods LoginMethods `json:"methods"`
@@ -84,22 +102,28 @@ func validateOidcAuth() error {
}
ctx := context.Background()
// If disableVerifyTLS is true, create a custom HTTP client
// and set it in the context for the OIDC provider.
// Create a custom HTTP client with proper User-Agent to avoid being blocked by bot protection
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment, // Respect HTTP_PROXY, HTTPS_PROXY, and NO_PROXY environment variables
}
// If disableVerifyTLS is true, disable TLS verification
if oidcCfg.DisableVerifyTLS {
// Create a custom transport with InsecureSkipVerify set to true
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
customClient := &http.Client{
Transport: transport,
}
// Use oidc.ClientContext to pass the custom client to the OIDC library
ctx = oidc.ClientContext(ctx, customClient)
// Log that TLS verification is disabled (important for security awareness)
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
logger.Warning("OIDC TLS verification is disabled.")
}
// Create custom client with User-Agent header to bypass bot protection (like Cloudflare Bot Fight Mode)
customClient := &http.Client{
Transport: &userAgentTransport{
base: transport,
userAgent: fmt.Sprintf("FileBrowser Quantum - %s (OIDC Client)", version.Version),
},
}
// Use oidc.ClientContext to pass the custom client to the OIDC library
ctx = oidc.ClientContext(ctx, customClient)
provider, err := oidc.NewProvider(ctx, oidcCfg.IssuerUrl)
if err != nil {
return fmt.Errorf("url '%v' failed to create OIDC provider: %w", oidcCfg.IssuerUrl, err)
+427 -204
View File
@@ -1,17 +1,21 @@
package settings
import (
"bytes"
"fmt"
"log"
"os"
"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/utils"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
@@ -30,6 +34,9 @@ func Initialize(configFile string) {
time.Sleep(5 * time.Second) // allow sleep time before exiting to give docker/kubernetes time before restarting
logger.Fatal(err.Error())
}
setupLogging()
// setup logging first to ensure we log any errors
setupEnv()
err = ValidateConfig(Config)
if err != nil {
errmsg := "The provided config file failed validation. "
@@ -40,14 +47,62 @@ func Initialize(configFile string) {
time.Sleep(5 * time.Second) // allow sleep time before exiting to give docker/kubernetes time before restarting
logger.Fatal(err.Error())
}
setupLogging()
setupFs()
setupServer()
setupAuth(false)
setupSources(false)
setupUrls()
setupFrontend(false)
setupMedia()
}
func setupServer() {
if Config.Server.ListenAddress == "" {
Config.Server.ListenAddress = "0.0.0.0"
}
}
func setupEnv() {
Env.IsPlaywright = os.Getenv("FILEBROWSER_PLAYWRIGHT_TEST") == "true"
if Env.IsPlaywright {
logger.Warning("Running in playwright test mode. This is not recommended for production.")
}
Env.IsDevMode = os.Getenv("FILEBROWSER_DEVMODE") == "true"
if Env.IsDevMode {
logger.Warning("Running in dev mode. This is not recommended for production.")
}
}
func setupFs() {
// 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))
// try writing to cache dir
absCacheDir, err := filepath.Abs(Config.Server.CacheDir)
if err != nil {
logger.Errorf("error getting absolute path for 'server.cacheDir: %v': %v", Config.Server.CacheDir, err)
}
cacheDir := filepath.Join(absCacheDir, "init_test")
err = os.MkdirAll(cacheDir, fileutils.PermDir)
if err != nil {
logger.Errorf("Unable to write to 'server.cacheDir: %v', please ensure the cache directory is writable: %v", absCacheDir, err)
}
}
func setupFrontend(generate bool) {
// Load login icon configuration at startup
loadLoginIcon()
if Config.Server.MinSearchLength == 0 {
Config.Server.MinSearchLength = 3
}
@@ -67,9 +122,17 @@ func setupFrontend(generate bool) {
}
Config.Frontend.Styling.LightBackground = FallbackColor(Config.Frontend.Styling.LightBackground, "#f5f5f5")
Config.Frontend.Styling.DarkBackground = FallbackColor(Config.Frontend.Styling.DarkBackground, "#141D24")
Config.Frontend.Styling.CustomCSSRaw = readCustomCSS(Config.Frontend.Styling.CustomCSS)
var err error
if Config.Frontend.Styling.CustomCSS != "" {
Config.Frontend.Styling.CustomCSSRaw, err = readCustomCSS(Config.Frontend.Styling.CustomCSS)
if err != nil {
logger.Warning(err.Error())
}
}
Config.Frontend.Styling.CustomThemeOptions = map[string]CustomTheme{}
Config.Frontend.Styling.CustomThemes = map[string]CustomTheme{}
if Config.Frontend.Styling.CustomThemes == nil {
Config.Frontend.Styling.CustomThemes = map[string]CustomTheme{}
}
for name, theme := range Config.Frontend.Styling.CustomThemes {
addCustomTheme(name, theme.Description, theme.CSS)
}
@@ -91,21 +154,46 @@ func setupFrontend(generate bool) {
if !ok {
addCustomTheme("default", "The default theme", "")
}
// Load custom favicon if configured
loadCustomFavicon()
}
func getRealPath(path string) string {
realPath, err := filepath.Abs(path)
if err != nil {
logger.Fatalf("could not find configured source path: %v", err)
func setupMedia() {
// If VideoPreview is not initialized, initialize with all types enabled
if Config.Integrations.Media.Convert.VideoPreview == nil {
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
for _, t := range AllVideoPreviewTypes {
Config.Integrations.Media.Convert.VideoPreview[t] = true
}
return
}
// check path exists
if _, err = os.Stat(realPath); os.IsNotExist(err) {
logger.Fatalf("configured source path does not exist: %v", realPath)
// 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
}
return realPath
}
func setupSources(generate bool) {
@@ -116,7 +204,14 @@ func setupSources(generate bool) {
if source.Config.Disabled {
continue
}
realPath := getRealPath(source.Path)
realPath, err := filepath.Abs(source.Path)
if err != nil {
logger.Fatalf("error getting real path for source %v: %v", source.Path, err)
}
exists := utils.CheckPathExists(realPath)
if !exists {
logger.Warningf("source path %v is currently not available", realPath)
}
name := filepath.Base(realPath)
if name == "\\" {
name = strings.Split(realPath, ":")[0]
@@ -134,8 +229,8 @@ func setupSources(generate bool) {
source.Name = name
}
}
modifyExcludeInclude(&source)
modifyExcludeInclude(source)
setConditionals(source)
if source.Config.DefaultUserScope == "" {
source.Config.DefaultUserScope = "/"
}
@@ -144,15 +239,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)
absPath := sourcePathOnly.Path
if generate {
realPath = generatorPath // use placeholder path
// When generating, skip path validation and use the already-set path
absPath = sourcePathOnly.Path
}
source, ok := Config.Server.SourceMap[realPath]
source, ok := Config.Server.SourceMap[absPath]
if ok && !slices.Contains(allSourceNames, source.Name) {
sourceList = append(sourceList, source)
if source.Config.DefaultEnabled {
@@ -163,7 +262,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
@@ -241,14 +340,15 @@ func setupLogging() {
}
logConfig := logger.JsonConfig{
Levels: levels,
ApiLevels: logConfig.ApiLevels,
Output: logConfig.Output,
Utc: logConfig.Utc,
NoColors: logConfig.NoColors,
Json: logConfig.Json,
Levels: levels,
ApiLevels: logConfig.ApiLevels,
Output: logConfig.Output,
Utc: logConfig.Utc,
NoColors: logConfig.NoColors,
Json: logConfig.Json,
Structured: false,
}
err := logger.SetupLogger(logConfig)
err := logger.EnableCompatibilityMode(logConfig)
if err != nil {
log.Println("[ERROR] Failed to set up logger:", err)
}
@@ -257,13 +357,13 @@ func setupLogging() {
func loadConfigWithDefaults(configFile string, generate bool) error {
Config = setDefaults(generate)
// Open and read the YAML file
yamlFile, err := os.Open(configFile)
if err != nil {
// Check if config file exists
if _, err := os.Stat(configFile); 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: ".",
},
@@ -271,37 +371,90 @@ func loadConfigWithDefaults(configFile string, generate bool) error {
loadEnvConfig()
return nil
}
defer yamlFile.Close()
stat, err := yamlFile.Stat()
// Try multi-file config first (combine all YAML files in the directory)
combinedYAML, err := combineYAMLFiles(configFile)
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())
return fmt.Errorf("failed to combine YAML files: %v", err)
}
// First pass: Unmarshal into a generic map to resolve all anchors and aliases
// This allows YAML anchors defined in auxiliary files to be properly merged
var rawConfig map[string]interface{}
err = yaml.Unmarshal(combinedYAML, &rawConfig)
if err != nil {
logger.Warningf("Could not load config file '%v', using default settings: %v", configFile, err)
return fmt.Errorf("error parsing YAML data: %v", err)
}
err = yaml.NewDecoder(strings.NewReader(string(yamlData)), yaml.DisallowUnknownField()).Decode(&Config)
// Filter to only keep valid top-level Settings struct fields
// This removes anchor definitions that are just templates (e.g., "test_server: &test_server")
validFields := map[string]bool{
"server": true,
"auth": true,
"integrations": true,
"frontend": true,
"userDefaults": true,
}
filteredConfig := make(map[string]interface{})
for key, value := range rawConfig {
if validFields[key] {
filteredConfig[key] = value
}
}
// Marshal the filtered config back to YAML
filteredYAML, err := yaml.Marshal(filteredConfig)
if err != nil {
return fmt.Errorf("error re-marshaling filtered YAML: %v", err)
}
// Second pass: Decode with strict validation (disallow unknown fields within valid sections)
decoder := yaml.NewDecoder(bytes.NewReader(filteredYAML), yaml.DisallowUnknownField())
err = decoder.Decode(&Config)
if err != nil {
return fmt.Errorf("error unmarshaling YAML data: %v", err)
}
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 {
@@ -361,17 +514,30 @@ func setDefaults(generate bool) Settings {
database := os.Getenv("FILEBROWSER_DATABASE")
if database == "" {
database = "database.db"
} else {
// check if database file exists
if _, err := os.Stat(database); os.IsNotExist(err) {
database = "database.db"
}
}
return Settings{
if _, err := os.Stat(database); os.IsNotExist(err) {
logger.Warning("database file could not be found. If this is unexpected, the default path is `./database.db`, but it can be configured in the config file under `server.database`.")
}
s := Settings{
Server: Server{
Port: 80,
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",
CacheDirCleanup: boolPtr(true),
Filesystem: Filesystem{
CreateFilePermission: "644",
CreateDirectoryPermission: "755",
},
},
Auth: Auth{
AdminUsername: "admin",
@@ -389,20 +555,32 @@ 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)",
Permissions: users.Permissions{
Modify: false,
Share: false,
Admin: false,
Api: false,
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
StickySidebar: true,
LockPassword: false,
ShowHidden: false,
DarkMode: boolPtr(true),
DisableSettings: false,
ViewMode: "normal",
Locale: "en",
GallerySize: 3,
ThemeColor: "var(--blue)",
Permissions: UserDefaultsPermissions{
Modify: false,
Share: false,
Admin: false,
Api: false,
Download: boolPtr(true), // defaults to true
},
Preview: UserDefaultsPreview{
HighQuality: boolPtr(true),
Image: boolPtr(true),
Video: boolPtr(true),
MotionVideoPreview: boolPtr(true),
Office: boolPtr(true),
PopUp: boolPtr(true),
AutoplayMedia: boolPtr(true),
Folder: boolPtr(true),
},
FileLoading: users.FileLoading{
MaxConcurrent: 10,
@@ -410,185 +588,230 @@ func setDefaults(generate bool) Settings {
},
},
}
// Initialize ImagePreview map with all supported types set to false by default
s.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]bool)
for _, t := range AllImagePreviewTypes {
s.Integrations.Media.Convert.ImagePreview[t] = false
}
// Initialize VideoPreview map with all supported types set to true by default
s.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
for _, t := range AllVideoPreviewTypes {
s.Integrations.Media.Convert.VideoPreview[t] = true
}
return s
}
func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, error) {
if len(scopes) == 0 {
return Config.UserDefaults.DefaultScopes, nil
// validateCustomImage validates a custom image file path and returns the absolute path or error
func validateCustomImage(configPath, imageName string, maxSize int64, allowedFormats []string) (absolutePath string, err error) {
// Get absolute path
absolutePath, err = filepath.Abs(configPath)
if err != nil {
return "", fmt.Errorf("could not resolve path: %w", err)
}
newScopes := []users.SourceScope{}
for _, scope := range scopes {
// first check if its already a path name and keep it
source, ok := Config.Server.SourceMap[scope.Name]
if ok {
if scope.Scope == "" {
scope.Scope = source.Config.DefaultUserScope
}
if !strings.HasPrefix(scope.Scope, "/") {
scope.Scope = "/" + scope.Scope
}
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
}
newScopes = append(newScopes, users.SourceScope{
Name: source.Path, // backend name is path
Scope: scope.Scope,
})
continue
}
// Check if file exists
stat, err := os.Stat(absolutePath)
if err != nil {
return "", fmt.Errorf("could not access file: %w", err)
}
// check if its the name of a source and convert it to a path
source, ok = Config.Server.NameToSource[scope.Name]
if !ok {
// source might no longer be configured
continue
}
if scope.Scope == "" {
scope.Scope = source.Config.DefaultUserScope
}
if !strings.HasPrefix(scope.Scope, "/") {
scope.Scope = "/" + scope.Scope
}
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
}
newScopes = append(newScopes, users.SourceScope{
Name: source.Path, // backend name is path
Scope: scope.Scope,
})
// Check file size
if stat.Size() > maxSize {
return "", fmt.Errorf("file too large (%d bytes), maximum allowed is %d bytes", stat.Size(), maxSize)
}
return newScopes, nil
}
func ConvertToFrontendScopes(scopes []users.SourceScope) []users.SourceScope {
newScopes := []users.SourceScope{}
for _, scope := range scopes {
if source, ok := Config.Server.SourceMap[scope.Name]; ok {
// Replace scope.Name with source.Path while keeping the same Scope value
newScopes = append(newScopes, users.SourceScope{
Name: source.Name,
Scope: scope.Scope,
})
// Validate file format
ext := strings.ToLower(filepath.Ext(absolutePath))
validFormat := false
for _, format := range allowedFormats {
if ext == format {
validFormat = true
break
}
}
return newScopes
}
if !validFormat {
return "", fmt.Errorf("unsupported format '%s', supported formats: %v", ext, allowedFormats)
}
func HasSourceByPath(scopes []users.SourceScope, sourcePath string) bool {
for _, scope := range scopes {
if scope.Name == sourcePath {
return true
}
}
return false
}
func GetScopeFromSourceName(scopes []users.SourceScope, sourceName string) (string, error) {
source, ok := Config.Server.NameToSource[sourceName]
if !ok {
logger.Debug("Could not get scope from source name: ", sourceName)
return "", fmt.Errorf("source with name not found %v", sourceName)
}
for _, scope := range scopes {
if scope.Name == source.Path {
return scope.Scope, nil
}
}
logger.Debugf("scope not found for source %v", sourceName)
return "", fmt.Errorf("scope not found for source %v", sourceName)
}
func GetScopeFromSourcePath(scopes []users.SourceScope, sourcePath string) (string, error) {
for _, scope := range scopes {
if scope.Name == sourcePath {
return scope.Scope, nil
}
}
return "", fmt.Errorf("scope not found for source %v", sourcePath)
}
// assumes backend style scopes
func GetSources(u *users.User) []string {
sources := []string{}
for _, scope := range u.Scopes {
source, ok := Config.Server.SourceMap[scope.Name]
if ok {
sources = append(sources, source.Name)
}
}
return sources
return absolutePath, nil
}
func loadCustomFavicon() {
const imageName = "favicon"
const maxSize = 1024 * 1024 // 1MB
allowedFormats := []string{".ico", ".png", ".svg"}
// Set default embedded favicon path
Env.FaviconEmbeddedPath = "img/icons/favicon.ico"
// Check if a custom favicon path is configured
if Config.Frontend.Favicon == "" {
logger.Debug("No custom favicon configured, using default")
Env.FaviconPath = Env.FaviconEmbeddedPath
Env.FaviconIsCustom = false
return
}
// Get absolute path for the favicon
faviconPath, err := filepath.Abs(Config.Frontend.Favicon)
// Validate custom favicon
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, maxSize, allowedFormats)
if err != nil {
logger.Warningf("Could not resolve favicon path '%v': %v", Config.Frontend.Favicon, err)
Config.Frontend.Favicon = "" // Unset invalid path
logger.Warningf("Custom favicon validation failed: %v, using default", err)
Config.Frontend.Favicon = ""
Env.FaviconPath = Env.FaviconEmbeddedPath
Env.FaviconIsCustom = false
return
}
// Check if the favicon file exists and get info
stat, err := os.Stat(faviconPath)
// Update to validated path and mark as custom
Config.Frontend.Favicon = validatedPath
Env.FaviconPath = validatedPath
Env.FaviconIsCustom = true
logger.Infof("Using custom favicon: %s", Env.FaviconPath)
}
func loadLoginIcon() {
const imageName = "login icon"
const maxSize = 1024 * 1024 // 1MB
allowedFormats := []string{".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"}
// Set default embedded icon path based on dark mode preference
isDarkMode := Config.UserDefaults.DarkMode != nil && *Config.UserDefaults.DarkMode
if isDarkMode {
Env.LoginIconEmbeddedPath = "img/icons/favicon.svg" // Dark mode: dark background
} else {
Env.LoginIconEmbeddedPath = "img/icons/favicon-light.svg" // Light mode: light background
}
// Check if a custom login icon path is configured
if Config.Frontend.LoginIcon == "" {
Env.LoginIconPath = Env.LoginIconEmbeddedPath
Env.LoginIconIsCustom = false
return
}
// Validate custom login icon
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, maxSize, allowedFormats)
if err != nil {
logger.Warningf("Could not access custom favicon file '%v': %v", faviconPath, err)
Config.Frontend.Favicon = "" // Unset invalid path
logger.Warningf("Custom login icon validation failed: %v, using default", err)
Env.LoginIconPath = Env.LoginIconEmbeddedPath
Env.LoginIconIsCustom = false
return
}
// Check file size (limit to 1MB for security)
const maxFaviconSize = 1024 * 1024 // 1MB
if stat.Size() > maxFaviconSize {
logger.Warningf("Favicon file '%v' is too large (%d bytes), maximum allowed is %d bytes", faviconPath, stat.Size(), maxFaviconSize)
Config.Frontend.Favicon = "" // Unset invalid path
return
// Update to validated path and mark as custom
Env.LoginIconPath = validatedPath
Env.LoginIconIsCustom = true
logger.Infof("Using custom login icon: %s", Env.LoginIconPath)
}
// setConditionalsMap builds optimized map structures from conditional rules for O(1) lookups
func setConditionals(config *Source) {
// backwards compatibility
if config.Config.Conditionals.Hidden {
logger.Warning("conditionals.hidden is deprecated, use conditionals.ignoreHidden instead")
config.Config.Conditionals.IgnoreHidden = true
}
// Validate file format based on extension
ext := strings.ToLower(filepath.Ext(faviconPath))
switch ext {
case ".ico", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp":
// Valid favicon formats
default:
logger.Warningf("Unsupported favicon format '%v', supported formats: .ico, .png, .jpg, .gif, .svg, .webp", ext)
Config.Frontend.Favicon = "" // Unset invalid path
return
rules := config.Config.Conditionals.ItemRules
// Initialize the maps structure (only exact match maps for Names)
resolved := &ResolvedConditionalsConfig{
FileNames: make(map[string]ConditionalIndexConfig),
FolderNames: make(map[string]ConditionalIndexConfig),
FilePaths: make(map[string]ConditionalIndexConfig),
FolderPaths: make(map[string]ConditionalIndexConfig),
FileEndsWith: make([]ConditionalIndexConfig, 0),
FolderEndsWith: make([]ConditionalIndexConfig, 0),
FileStartsWith: make([]ConditionalIndexConfig, 0),
FolderStartsWith: make([]ConditionalIndexConfig, 0),
NeverWatchPaths: make(map[string]struct{}),
IncludeRootItems: make(map[string]struct{}),
}
// Update to absolute path and mark as valid
Config.Frontend.Favicon = faviconPath
logger.Infof("Successfully validated custom favicon at '%v' (%d bytes, %s)", faviconPath, stat.Size(), ext)
for _, rule := range rules {
if rule.FileEndsWith != "" {
resolved.FileEndsWith = append(resolved.FileEndsWith, rule)
}
if rule.FolderEndsWith != "" {
resolved.FolderEndsWith = append(resolved.FolderEndsWith, rule)
}
if rule.FileStartsWith != "" {
resolved.FileStartsWith = append(resolved.FileStartsWith, rule)
}
if rule.FolderStartsWith != "" {
resolved.FolderStartsWith = append(resolved.FolderStartsWith, rule)
}
if rule.FilePath != "" {
resolved.FilePaths[rule.FilePath] = rule
}
if rule.FolderPath != "" {
resolved.FolderPaths[rule.FolderPath] = rule
}
if rule.NeverWatchPath != "" {
resolved.NeverWatchPaths[rule.NeverWatchPath] = struct{}{}
}
if rule.IncludeRootItem != "" {
resolved.IncludeRootItems[rule.IncludeRootItem] = struct{}{}
}
if rule.FileNames != "" {
resolved.FileNames[rule.FileNames] = rule
}
if rule.FolderNames != "" {
resolved.FolderNames[rule.FolderNames] = rule
}
if rule.FileName != "" {
resolved.FileNames[rule.FileName] = rule
}
if rule.FolderName != "" {
resolved.FolderNames[rule.FolderName] = rule
}
}
config.Config.ResolvedConditionals = resolved
}
func modifyExcludeInclude(config *Source) {
normalize := func(s []string, checkExists bool) {
for i, v := range s {
s[i] = "/" + strings.Trim(v, "/")
// check if file/folder exists
if checkExists {
realPath, err := filepath.Abs(config.Path + s[i])
if err != nil {
logger.Warningf("could not get absolute path for %v: %v", s[i], err)
continue
}
if _, err := os.Stat(realPath); os.IsNotExist(err) {
logger.Warningf("configured exclude/include path %v does not exist", realPath)
}
// Helper to normalize a full path value (FilePaths, FolderPaths)
// These always start with "/" and match against full index paths
normalizeFullPath := func(value string, checkExists bool) string {
if value == "" {
return ""
}
normalized := "/" + strings.Trim(value, "/")
// check if file/folder exists
if checkExists {
realPath, err := filepath.Abs(config.Path + normalized)
if err != nil {
logger.Warningf("could not get absolute path for %v: %v", normalized, err)
return normalized
}
if _, err := os.Stat(realPath); os.IsNotExist(err) {
logger.Warningf("configured exclude/include path %v does not exist", realPath)
}
}
return normalized
}
// Helper to normalize a name-based value (FileNames, FolderNames, StartsWith, EndsWith)
// These match against baseName, so no leading slash
normalizeName := func(value string) string {
// Just trim slashes - names shouldn't have slashes
return strings.Trim(value, "/")
}
// Normalize []ConditionalIndexConfig slices for full paths
for i, rule := range config.Config.Conditionals.ItemRules {
// normalize full paths
config.Config.Conditionals.ItemRules[i].FilePath = normalizeFullPath(rule.FilePath, true)
config.Config.Conditionals.ItemRules[i].FolderPath = normalizeFullPath(rule.FolderPath, true)
config.Config.Conditionals.ItemRules[i].NeverWatchPath = normalizeFullPath(rule.NeverWatchPath, true)
config.Config.Conditionals.ItemRules[i].IncludeRootItem = normalizeFullPath(rule.IncludeRootItem, true)
// normalize names
config.Config.Conditionals.ItemRules[i].FileNames = normalizeName(rule.FileNames)
config.Config.Conditionals.ItemRules[i].FolderNames = normalizeName(rule.FolderNames)
config.Config.Conditionals.ItemRules[i].FileName = normalizeName(rule.FileName)
config.Config.Conditionals.ItemRules[i].FolderName = normalizeName(rule.FolderName)
}
normalize(config.Config.Exclude.FolderPaths, true)
normalize(config.Config.Exclude.FilePaths, true)
normalize(config.Config.Include.RootFolders, true)
normalize(config.Config.Include.RootFiles, true)
normalize(config.Config.NeverWatchPaths, true)
}
@@ -0,0 +1,13 @@
package settings
func CanConvertImage(ext string) bool {
return MediaEnabled() && Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
}
func CanConvertVideo(ext string) bool {
return MediaEnabled() && Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
}
func MediaEnabled() bool {
return Env.FFmpegPath != "" && Env.FFprobePath != ""
}
+54 -6
View File
@@ -2,6 +2,7 @@ package settings
import (
"os"
"path/filepath"
"reflect"
"testing"
@@ -42,8 +43,19 @@ func Test_setDefaults(t *testing.T) {
}
func TestConfigLoadChanged(t *testing.T) {
// Create isolated test directory
testDir := t.TempDir()
validContent, err := os.ReadFile("./validConfig.yaml")
if err != nil {
t.Fatalf("failed to read validConfig.yaml: %v", err)
}
configFile := filepath.Join(testDir, "config.yaml")
if err = os.WriteFile(configFile, validContent, 0644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
defaultConfig := setDefaults(true)
err := loadConfigWithDefaults("./validConfig.yaml", true)
err = loadConfigWithDefaults(configFile, true)
if err != nil {
t.Fatalf("error loading config file: %v", err)
}
@@ -54,11 +66,22 @@ func TestConfigLoadChanged(t *testing.T) {
}
func TestConfigLoadEnvVars(t *testing.T) {
// Create isolated test directory
testDir := t.TempDir()
validContent, err := os.ReadFile("./validConfig.yaml")
if err != nil {
t.Fatalf("failed to read validConfig.yaml: %v", err)
}
configFile := filepath.Join(testDir, "config.yaml")
if err = os.WriteFile(configFile, validContent, 0644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
defaultConfig := setDefaults(true)
expectedKey := "MYKEY"
// mock environment variables
os.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", expectedKey)
err := loadConfigWithDefaults("./validConfig.yaml", true)
err = loadConfigWithDefaults(configFile, true)
if err != nil {
t.Fatalf("error loading config file: %v", err)
}
@@ -72,8 +95,19 @@ func TestConfigLoadEnvVars(t *testing.T) {
}
func TestConfigLoadSpecificValues(t *testing.T) {
// Create isolated test directory
testDir := t.TempDir()
validContent, err := os.ReadFile("./validConfig.yaml")
if err != nil {
t.Fatalf("failed to read validConfig.yaml: %v", err)
}
configFile := filepath.Join(testDir, "config.yaml")
if err = os.WriteFile(configFile, validContent, 0644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
defaultConfig := setDefaults(true)
err := loadConfigWithDefaults("./validConfig.yaml", true)
err = loadConfigWithDefaults(configFile, true)
if err != nil {
t.Fatalf("error loading config file: %v", err)
}
@@ -93,9 +127,23 @@ func TestConfigLoadSpecificValues(t *testing.T) {
}
func TestInvalidConfig(t *testing.T) {
configFile := "./invalidConfig.yaml"
err := loadConfigWithDefaults(configFile, true)
// Create isolated test directory
testDir := t.TempDir()
invalidContent, err := os.ReadFile("./invalidConfig.yaml")
if err != nil {
t.Fatalf("failed to read invalidConfig.yaml: %v", err)
}
configFile := filepath.Join(testDir, "config.yaml")
if err = os.WriteFile(configFile, invalidContent, 0644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
err = loadConfigWithDefaults(configFile, true)
// Config loads successfully but validation should catch missing sources
if err == nil {
t.Fatalf("expected error loading config file %s, got nil", configFile)
err = ValidateConfig(Config)
if err == nil {
t.Fatal("expected validation error for config with missing required sources, got nil")
}
}
}
+166
View File
@@ -0,0 +1,166 @@
package settings
import (
"fmt"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, error) {
if len(scopes) == 0 {
return Config.UserDefaults.DefaultScopes, nil
}
newScopes := []users.SourceScope{}
for _, scope := range scopes {
// first check if its already a path name and keep it
source, ok := Config.Server.SourceMap[scope.Name]
if ok {
if scope.Scope == "" {
scope.Scope = source.Config.DefaultUserScope
}
if !strings.HasPrefix(scope.Scope, "/") {
scope.Scope = "/" + scope.Scope
}
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
}
newScopes = append(newScopes, users.SourceScope{
Name: source.Path, // backend name is path
Scope: scope.Scope,
})
continue
}
// check if its the name of a source and convert it to a path
source, ok = Config.Server.NameToSource[scope.Name]
if !ok {
// source might no longer be configured
continue
}
if scope.Scope == "" {
scope.Scope = source.Config.DefaultUserScope
}
if !strings.HasPrefix(scope.Scope, "/") {
scope.Scope = "/" + scope.Scope
}
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
}
newScopes = append(newScopes, users.SourceScope{
Name: source.Path, // backend name is path
Scope: scope.Scope,
})
}
return newScopes, nil
}
func ConvertToFrontendScopes(scopes []users.SourceScope) []users.SourceScope {
newScopes := []users.SourceScope{}
for _, scope := range scopes {
if source, ok := Config.Server.SourceMap[scope.Name]; ok {
// Replace scope.Name with source.Path while keeping the same Scope value
newScopes = append(newScopes, users.SourceScope{
Name: source.Name,
Scope: scope.Scope,
})
}
}
return newScopes
}
func ConvertToFrontendSidebarLinks(links []users.SidebarLink) []users.SidebarLink {
newLinks := []users.SidebarLink{}
for _, link := range links {
// For source links, validate that the source still exists
if link.Category == "source" {
if link.SourceName == "" {
logger.Warningf("source link missing sourceName: %v", link.Name)
continue
}
// Check if source exists
sourceInfo, ok := Config.Server.SourceMap[link.SourceName]
if !ok {
logger.Warningf("source not found: %v (link name: %v)", link.SourceName, link.Name)
continue
}
link.SourceName = sourceInfo.Name
}
// For share links, just pass through (shares are validated separately)
// For all other links, pass through as-is
newLinks = append(newLinks, link)
}
return newLinks
}
func ConvertToBackendSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, error) {
newLinks := []users.SidebarLink{}
for _, link := range links {
// For source links, validate that the source exists using SourceName
if link.Category == "source" {
if link.SourceName == "" {
return nil, fmt.Errorf("source link missing sourceName (link name: %v)", link.Name)
}
// Validate source exists
sourceInfo, ok := Config.Server.NameToSource[link.SourceName]
if !ok {
return nil, fmt.Errorf("source not found: %v (link name: %v)", link.SourceName, link.Name)
}
link.SourceName = sourceInfo.Path // if name chages keep link alive
}
// Store the link as-is with all fields preserved
newLinks = append(newLinks, link)
}
return newLinks, nil
}
func HasSourceByPath(scopes []users.SourceScope, sourcePath string) bool {
for _, scope := range scopes {
if scope.Name == sourcePath {
return true
}
}
return false
}
func GetScopeFromSourceName(scopes []users.SourceScope, sourceName string) (string, error) {
source, ok := Config.Server.NameToSource[sourceName]
if !ok {
logger.Debug("Could not get scope from source name: ", sourceName)
return "", fmt.Errorf("source with name not found %v", sourceName)
}
for _, scope := range scopes {
if scope.Name == source.Path {
return scope.Scope, nil
}
}
logger.Debugf("scope not found for source %v", sourceName)
return "", fmt.Errorf("scope not found for source %v", sourceName)
}
func GetScopeFromSourcePath(scopes []users.SourceScope, sourcePath string) (string, error) {
for _, scope := range scopes {
if scope.Name == sourcePath {
return scope.Scope, nil
}
}
return "", fmt.Errorf("scope not found for source %v", sourcePath)
}
// assumes backend style scopes
func GetSources(u *users.User) []string {
sources := []string{}
// preserves order of sources
for _, source := range Config.Server.Sources {
_, err := GetScopeFromSourcePath(u.Scopes, source.Path)
if err != nil {
logger.Warningf("could not get scope for source %v: %v", source.Path, err)
continue
}
sources = append(sources, source.Name)
}
return sources
}
+710 -39
View File
@@ -1,3 +1,5 @@
// WARNING: the vast majority of generator.go is ai generated
// troubleshooting is best done with ai agent assistance
package settings
import (
@@ -9,6 +11,7 @@ import (
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"gopkg.in/yaml.v3"
@@ -37,21 +40,89 @@ func CollectComments(srcPath string) (CommentsMap, error) {
// CollectCommentsAndSecrets parses all Go source and returns comments, secrets, and deprecated field mappings
func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
// parse entire package so we capture comments on all types
// Determine directories to parse - include settings and users packages
dir := srcPath
if filepath.IsAbs(srcPath) {
// If it's an absolute path to a file, get the directory
dir = filepath.Dir(srcPath)
}
// List of directories to parse for comments
dirsToparse := []string{
dir, // settings package
}
// Add users package directory
// From common/settings, go up to backend root, then into database/users
var usersDir string
if filepath.IsAbs(dir) {
// If absolute path, calculate relative to it
usersDir = filepath.Join(filepath.Dir(filepath.Dir(dir)), "database/users")
} else {
// If relative path like "common/settings" or ".", calculate from current working directory
usersDir = "database/users"
}
if absUsersDir, err := filepath.Abs(usersDir); err == nil {
dirsToparse = append(dirsToparse, absUsersDir)
}
comments := make(CommentsMap)
secrets := make(SecretFieldsMap)
deprecated := make(DeprecatedFieldsMap)
// Parse each directory
for _, parseDir := range dirsToparse {
dirComments, dirSecrets, dirDeprecated, err := parseDirectoryComments(parseDir)
if err != nil {
// Log error but continue with other directories
fmt.Printf("Warning: failed to parse directory %s: %v\n", parseDir, err)
continue
}
// Merge results
for typeName, fieldMap := range dirComments {
if comments[typeName] == nil {
comments[typeName] = make(map[string]string)
}
for fieldName, comment := range fieldMap {
comments[typeName][fieldName] = comment
}
}
for typeName, fieldMap := range dirSecrets {
if secrets[typeName] == nil {
secrets[typeName] = make(map[string]bool)
}
for fieldName, isSecret := range fieldMap {
secrets[typeName][fieldName] = isSecret
}
}
for typeName, fieldMap := range dirDeprecated {
if deprecated[typeName] == nil {
deprecated[typeName] = make(map[string]bool)
}
for fieldName, isDeprecated := range fieldMap {
deprecated[typeName][fieldName] = isDeprecated
}
}
}
return comments, secrets, deprecated, nil
}
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields
func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments)
if err != nil {
return nil, nil, nil, err
}
comments := make(CommentsMap)
secrets := make(SecretFieldsMap)
deprecated := make(DeprecatedFieldsMap)
for _, pkg := range pkgs {
for _, file := range pkg.Files {
for _, decl := range file.Decls {
@@ -116,6 +187,284 @@ func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, De
return comments, secrets, deprecated, nil
}
// PathToTypeField represents a mapping from YAML path to Go type and field info
type PathToTypeField struct {
TypeName string
FieldName string
}
// buildStructPathMap builds a dynamic mapping from YAML paths to Go struct types/fields using reflection
func buildStructPathMap(config *Settings) map[string]PathToTypeField {
pathMap := make(map[string]PathToTypeField)
// Start with the root Settings struct
buildPathMapRecursive(reflect.TypeOf(config).Elem(), reflect.ValueOf(config).Elem(), "", "Settings", pathMap)
return pathMap
}
// buildPathMapRecursive recursively builds the path mapping using reflection
func buildPathMapRecursive(structType reflect.Type, structValue reflect.Value, currentPath string, typeName string, pathMap map[string]PathToTypeField) {
for i := 0; i < structType.NumField(); i++ {
field := structType.Field(i)
// Skip unexported fields
if field.PkgPath != "" {
continue
}
// Skip fields marked to ignore
if yamlTag := field.Tag.Get("yaml"); yamlTag == "-" {
continue
}
if jsonTag := field.Tag.Get("json"); jsonTag == "-" {
continue
}
// Determine the YAML field name from struct tags
yamlFieldName := getYamlFieldName(field)
// Build the full path
var fullPath string
if currentPath == "" {
fullPath = yamlFieldName
} else {
fullPath = currentPath + "." + yamlFieldName
}
// Add this field to the path map
pathMap[fullPath] = PathToTypeField{
TypeName: typeName,
FieldName: field.Name,
}
// If this field is a struct, recurse into it
fieldValue := structValue.Field(i)
fieldType := field.Type
// Handle pointers
if fieldType.Kind() == reflect.Ptr {
if fieldValue.IsNil() {
// Skip nil pointers
continue
}
fieldValue = fieldValue.Elem()
fieldType = fieldType.Elem()
}
// Recurse into structs (but not slices of structs for now - those are handled differently)
if fieldType.Kind() == reflect.Struct {
// Skip certain types that shouldn't be recursed into
if isSkippableStructType(fieldType) {
continue
}
buildPathMapRecursive(fieldType, fieldValue, fullPath, fieldType.Name(), pathMap)
} else if fieldType.Kind() == reflect.Slice && fieldType.Elem().Kind() == reflect.Struct {
// For slices of structs, we map to the element type
elemType := fieldType.Elem()
if !isSkippableStructType(elemType) {
// Create a zero value of the element type to recurse into
zeroValue := reflect.Zero(elemType)
buildPathMapRecursive(elemType, zeroValue, fullPath, elemType.Name(), pathMap)
}
}
}
}
// getYamlFieldName determines the YAML field name from struct tags
func getYamlFieldName(field reflect.StructField) string {
// Check yaml tag first
if yamlTag := field.Tag.Get("yaml"); yamlTag != "" && yamlTag != "-" {
return strings.Split(yamlTag, ",")[0]
}
// Check json tag second
if jsonTag := field.Tag.Get("json"); jsonTag != "" && jsonTag != "-" {
return strings.Split(jsonTag, ",")[0]
}
// Default to field name
return field.Name
}
// isSkippableStructType checks if a struct type should be skipped during recursion
func isSkippableStructType(t reflect.Type) bool {
// Skip types from other packages that we don't want to recurse into
pkgPath := t.PkgPath()
// Skip external packages
if pkgPath != "" && !strings.Contains(pkgPath, "filebrowser/backend") {
return true
}
return false
}
// CollectCommentsFromEmbeddedYaml parses comments from embedded YAML content and returns CommentsMap, SecretFieldsMap, and DeprecatedFieldsMap
func CollectCommentsFromEmbeddedYaml(yamlContent string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
comments := make(CommentsMap)
secrets := make(SecretFieldsMap)
deprecated := make(DeprecatedFieldsMap)
// Build the dynamic path mapping from the actual Config struct
pathMap := buildStructPathMap(&Config)
lines := strings.Split(yamlContent, "\n")
// 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
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
// Find the key and comment in the line
if !strings.Contains(line, ":") {
continue
}
// Split on the first colon to get key part
colonIndex := strings.Index(line, ":")
keyPart := line[:colonIndex]
restPart := line[colonIndex+1:]
// Calculate indentation
indent := 0
for i, char := range keyPart {
if char == ' ' {
indent++
} else {
keyPart = keyPart[i:]
break
}
}
depth := indent / 2
// Get the key name
key := strings.TrimSpace(keyPart)
if key == "" {
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]
}
// Add current key to path
if depth < len(currentPath) {
currentPath[depth] = key
currentPath = currentPath[:depth+1]
} else {
currentPath = append(currentPath, key)
}
// Look for comment in the rest of the line
var comment string
if strings.Contains(restPart, "#") {
// Find the comment part (not inside quotes)
inQuotes := false
for i, char := range restPart {
if char == '"' && (i == 0 || restPart[i-1] != '\\') {
inQuotes = !inQuotes
} else if char == '#' && !inQuotes {
comment = strings.TrimSpace(restPart[i+1:])
break
}
}
}
// Store comment if it exists
if comment != "" {
// Build the full path string
fullPath := strings.Join(currentPath, ".")
// Look up the type and field name from our dynamic mapping
if pathInfo, exists := pathMap[fullPath]; exists {
typeName := pathInfo.TypeName
fieldName := pathInfo.FieldName
// Initialize maps if they don't exist
if comments[typeName] == nil {
comments[typeName] = make(map[string]string)
}
if secrets[typeName] == nil {
secrets[typeName] = make(map[string]bool)
}
if deprecated[typeName] == nil {
deprecated[typeName] = make(map[string]bool)
}
comments[typeName][fieldName] = comment
// Check for secret and deprecated markers
commentLower := strings.ToLower(comment)
if strings.Contains(commentLower, "secret:") {
secrets[typeName][fieldName] = true
}
if strings.Contains(commentLower, "deprecated:") {
deprecated[typeName][fieldName] = true
}
}
}
}
return comments, secrets, deprecated, nil
}
// parseDefaultsFromEmbeddedYaml creates a default config by parsing the embedded YAML template
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: "."}}
// Parse the embedded YAML to overlay the documented defaults
if err := yaml.Unmarshal([]byte(embeddedYaml), &defaultConfig); err != nil {
return nil, fmt.Errorf("failed to unmarshal embedded YAML into config: %v", err)
}
return &defaultConfig, nil
}
// readEmbeddedYaml attempts to read the embedded config.generated.yaml file
func readEmbeddedYaml() (string, error) {
// Try to read from the relative path that should work when the binary includes embedded files
embeddedContent, err := os.ReadFile("http/dist/config.generated.yaml")
if err != nil {
// Try alternative path
embeddedContent, err = os.ReadFile("backend/http/dist/config.generated.yaml")
if err != nil {
// Try current directory path
embeddedContent, err = os.ReadFile("dist/config.generated.yaml")
if err != nil {
return "", fmt.Errorf("could not read embedded YAML from any path: %v", err)
}
}
}
return string(embeddedContent), nil
}
// BuildNode constructs a yaml.Node for any Go value, injecting comments on struct fields.
func BuildNode(v reflect.Value, comm CommentsMap) (*yaml.Node, error) {
return buildNodeWithDefaults(v, comm, reflect.Value{}, SecretFieldsMap{}, DeprecatedFieldsMap{})
@@ -198,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 {
@@ -215,12 +577,26 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
// Check if this field should be redacted as a secret
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
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
if secrets[typeName][sf.Name] && !generateConfig {
// 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
@@ -239,25 +615,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
}
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() {
@@ -272,11 +643,34 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
return seq, nil
case reflect.Map:
if v.Len() == 0 {
// Return an empty string node for empty maps, which renders as a blank value.
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ""}, nil
}
mapNode := &yaml.Node{Kind: yaml.MappingNode}
for _, key := range v.MapKeys() {
// Handle key
// Special handling for ImagePreview maps - show all supported types
if isImagePreviewMap(v) {
return buildImagePreviewMap(v, comm, secrets, deprecated)
}
// Get and sort map keys alphabetically for consistent output
mapKeys := v.MapKeys()
keyStrings := make([]string, len(mapKeys))
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,
@@ -309,21 +703,44 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
func GenerateYaml() {
_ = loadConfigWithDefaults("", true)
Config.Server.Sources = []Source{
Config.Server.Sources = []*Source{
{
Path: ".",
Config: SourceConfig{
Conditionals: ConditionalFilter{
ItemRules: []ConditionalIndexConfig{
{}, // Add empty sample rule to show all fields in generated YAML
},
},
},
},
}
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()
setupMedia()
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)
@@ -341,9 +758,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 {
@@ -361,6 +778,165 @@ func GenerateConfigYaml(config *Settings, showComments bool, showFull bool, filt
return GenerateConfigYamlWithEmptyMaps(config, showFull)
}
// GenerateConfigYamlWithEmbedded generates YAML from a given config using embedded YAML content as comment source
func GenerateConfigYamlWithEmbedded(config *Settings, showComments bool, showFull bool, filterDeprecated bool, embeddedYaml string) (string, error) {
var comm CommentsMap
var secrets SecretFieldsMap
var deprecated DeprecatedFieldsMap
var err error
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 only if no embedded YAML is available
comm = make(CommentsMap)
secrets = make(SecretFieldsMap)
deprecated = make(DeprecatedFieldsMap)
}
// If not filtering deprecated fields, clear the deprecated map
if !filterDeprecated {
deprecated = make(DeprecatedFieldsMap)
}
var node *yaml.Node
if showFull {
// Show the full current config
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.Value{}, secrets, deprecated)
} else {
// Show only non-default values by comparing with defaults from embedded YAML
defaultConfig, parseErr := parseDefaultsFromEmbeddedYaml(embeddedYaml)
if parseErr != nil {
return "", fmt.Errorf("failed to parse defaults from embedded YAML: %v", parseErr)
}
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.ValueOf(defaultConfig), secrets, deprecated)
}
if err != nil {
return "", err
}
// Create document
doc := &yaml.Node{Kind: yaml.DocumentNode}
doc.Content = []*yaml.Node{node}
// Encode to YAML
var rawBuf bytes.Buffer
enc := yaml.NewEncoder(&rawBuf)
enc.SetIndent(2)
if err := enc.Encode(doc); err != nil {
return "", err
}
// Apply comment alignment if comments are enabled
yamlOutput := rawBuf.String()
if showComments {
yamlOutput = AlignComments(yamlOutput)
}
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
@@ -368,6 +944,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
@@ -376,10 +955,20 @@ func GenerateConfigYamlWithEmptyMaps(config *Settings, showFull bool) (string, e
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.Value{}, secrets, deprecated)
} else {
// Show only non-default values by comparing with defaults during node building
defaultConfig := setDefaults(true)
defaultConfig.Server.Sources = []Source{{Path: "."}}
// Try to use embedded YAML defaults for consistency, fallback to setDefaults
var defaultConfig *Settings
embeddedYaml, readErr := readEmbeddedYaml()
if readErr == nil {
defaultConfig, err = parseDefaultsFromEmbeddedYaml(embeddedYaml)
}
if err != nil || readErr != nil {
// Fallback to setDefaults
defaultConfigValue := setDefaults(true)
defaultConfigValue.Server.Sources = []*Source{{Path: "."}}
defaultConfig = &defaultConfigValue
}
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.ValueOf(&defaultConfig), secrets, deprecated)
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.ValueOf(defaultConfig), secrets, deprecated)
}
if err != nil {
@@ -423,6 +1012,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)
@@ -435,12 +1048,20 @@ func GenerateConfigYamlWithSource(config *Settings, showComments bool, showFull
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.Value{}, secrets, deprecated)
} else {
// Show only non-default values by comparing with defaults during node building
// Create a clean default config (no file loading, just pure defaults)
defaultConfig := setDefaults(true)
// Apply same setup as a fresh instance would have
defaultConfig.Server.Sources = []Source{{Path: "."}}
// Try to use embedded YAML defaults for consistency, fallback to setDefaults
var defaultConfig *Settings
embeddedYaml, readErr := readEmbeddedYaml()
if readErr == nil {
defaultConfig, err = parseDefaultsFromEmbeddedYaml(embeddedYaml)
}
if err != nil || readErr != nil {
// Fallback to setDefaults
defaultConfigValue := setDefaults(true)
defaultConfigValue.Server.Sources = []*Source{{Path: "."}}
defaultConfig = &defaultConfigValue
}
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.ValueOf(&defaultConfig), secrets, deprecated)
node, err = buildNodeWithDefaults(reflect.ValueOf(config), comm, reflect.ValueOf(defaultConfig), secrets, deprecated)
}
if err != nil {
@@ -515,3 +1136,53 @@ func AlignComments(input string) string {
return strings.Join(outputLines, "\n")
}
// isImagePreviewMap checks if the given map is an ImagePreview map
func isImagePreviewMap(v reflect.Value) bool {
if v.Kind() != reflect.Map {
return false
}
// Check if the map key type is ImagePreviewType
keyType := v.Type().Key()
return keyType.Name() == "ImagePreviewType"
}
// buildImagePreviewMap builds a YAML node for ImagePreview maps, showing all supported types
func buildImagePreviewMap(v reflect.Value, comm CommentsMap, secrets SecretFieldsMap, deprecated DeprecatedFieldsMap) (*yaml.Node, error) {
mapNode := &yaml.Node{Kind: yaml.MappingNode}
// Get all supported image preview types from the global slice
for _, imageType := range AllImagePreviewTypes {
typeName := string(imageType)
// Create key node
keyNode := &yaml.Node{
Kind: yaml.ScalarNode,
Value: typeName,
}
// Get the value from the map, defaulting to false if not present
var value bool
if v.Len() > 0 {
// Look for the key in the map
for _, mapKey := range v.MapKeys() {
if mapKey.String() == typeName {
value = v.MapIndex(mapKey).Bool()
break
}
}
}
// Create value node
valNode := &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!bool",
Value: fmt.Sprintf("%t", value),
}
mapNode.Content = append(mapNode.Content, keyNode, valNode)
}
return mapNode, nil
}
@@ -9,10 +9,11 @@ import (
func TestGenerateConfigYaml_Basic(t *testing.T) {
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
// Test using the actual source directory structure
trueVal := true
config := &Settings{
UserDefaults: UserDefaults{
Locale: "es", // Non-default value
DarkMode: true, // Default value
DarkMode: &trueVal, // Default value
DisableOfficePreviewExt: ".docx .xlsx", // This field is deprecated
},
Auth: Auth{
@@ -104,6 +105,11 @@ func TestGenerateConfigYaml_Basic(t *testing.T) {
continue
}
// Check if it's null (should NOT be quoted - it's a YAML literal for nil)
if value == "null" {
continue
}
// Everything else should be a quoted string
if !strings.HasPrefix(value, "\"") || !strings.HasSuffix(value, "\"") {
// Add debug info to see what's wrong
+14 -7
View File
@@ -108,6 +108,11 @@ type Auth struct {
continue
}
// Check if it's null (should NOT be quoted - it's a YAML literal for nil)
if value == "null" {
continue
}
// Everything else should be a quoted string
if !strings.HasPrefix(value, "\"") || !strings.HasSuffix(value, "\"") {
t.Errorf("String value should be quoted: %s (value: '%s')", line, value)
@@ -204,10 +209,11 @@ type UserDefaults struct {
sourcePath := tmpFile[:strings.LastIndex(tmpFile, "/")]
// Create a config with both default and non-default values
trueVal := true
config := &Settings{
UserDefaults: UserDefaults{
DarkMode: true, // This matches default
Locale: "es", // This differs from default ("en")
DarkMode: &trueVal, // This matches default
Locale: "es", // This differs from default ("en")
},
}
@@ -278,10 +284,11 @@ func TestGenerateConfigYaml_CommentsOnOff(t *testing.T) {
func TestGenerateConfigYaml_IntegrationTest(t *testing.T) {
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
// Comprehensive test with all features
trueVal := true
config := &Settings{
UserDefaults: UserDefaults{
Locale: "es", // Non-default
DarkMode: true, // Default
DarkMode: &trueVal, // Default
DisableOfficePreviewExt: ".docx .xlsx", // This is deprecated
},
Auth: Auth{
@@ -400,8 +407,8 @@ func TestGenerateConfigYaml_IntegrationTest(t *testing.T) {
continue
}
// Skip arrays, objects, and empty values
if value == "" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") || strings.Contains(value, ":") {
// Skip arrays, objects, empty values, and null
if value == "" || value == "null" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") || strings.Contains(value, ":") {
continue
}
@@ -433,8 +440,8 @@ func TestGenerateConfigYaml_EdgeCases(t *testing.T) {
name: "only_defaults",
config: &Settings{
UserDefaults: UserDefaults{
DarkMode: true, // default value
Locale: "en", // default value
DarkMode: boolPtr(true), // default value
Locale: "en", // default value
},
},
desc: "Config with only default values",
+233
View File
@@ -0,0 +1,233 @@
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
`
// Use pattern-based naming: main config is "config.yaml", so anchors must be "*-config.yaml"
anchorsPath := filepath.Join(testDir, "definitions-config.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
// Use generate=true to skip filesystem validation of fake paths
err := loadConfigWithDefaults(mainPath, true)
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
// Use generate=true to skip filesystem validation
err := loadConfigWithDefaults(configPath, true)
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
`
// Use pattern-based naming: main config is "config.yaml", so anchors must be "*-config.yaml"
anchorsPath := filepath.Join(testDir, "definitions-config.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
// Use generate=true to skip filesystem validation
err := loadConfigWithDefaults(mainPath, true)
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 == nil || !*Config.UserDefaults.DarkMode {
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)
}
}
+58 -4
View File
@@ -7,6 +7,33 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// boolPtr returns a pointer to a bool value
func boolPtr(b bool) *bool {
return &b
}
// boolValueOrDefault returns the value of a bool pointer, or the default if nil
func boolValueOrDefault(ptr *bool, defaultValue bool) bool {
if ptr != nil {
return *ptr
}
return defaultValue
}
// ConvertPermissionsToUsers converts UserDefaultsPermissions to users.Permissions
func ConvertPermissionsToUsers(p UserDefaultsPermissions) users.Permissions {
return users.Permissions{
Api: p.Api,
Admin: p.Admin,
Modify: p.Modify,
Share: p.Share,
Realtime: p.Realtime,
Delete: p.Delete,
Create: p.Create,
Download: boolValueOrDefault(p.Download, true),
}
}
const DefaultUsersHomeBasePath = "/users"
// AuthMethod describes an authentication method.
@@ -14,7 +41,7 @@ type AuthMethod string
// GenerateKey generates a key of 512 bits.
func GenerateKey() ([]byte, error) {
b := make([]byte, 64) //nolint:gomnd
b := make([]byte, 64)
_, err := rand.Read(b)
// Note that err == nil only if we read len(b) bytes.
if err != nil {
@@ -41,12 +68,36 @@ func AdminPerms() users.Permissions {
func ApplyUserDefaults(u *users.User) {
u.StickySidebar = Config.UserDefaults.StickySidebar
u.DisableSettings = Config.UserDefaults.DisableSettings
u.DarkMode = Config.UserDefaults.DarkMode
// Handle DarkMode with default - dereference pointer from config
u.DarkMode = boolValueOrDefault(Config.UserDefaults.DarkMode, true)
u.Locale = Config.UserDefaults.Locale
u.ViewMode = Config.UserDefaults.ViewMode
u.SingleClick = Config.UserDefaults.SingleClick
u.Permissions = Config.UserDefaults.Permissions
u.Preview = Config.UserDefaults.Preview
// Handle Permissions - convert from pointer-based defaults to regular bools
u.Permissions.Api = Config.UserDefaults.Permissions.Api
u.Permissions.Admin = Config.UserDefaults.Permissions.Admin
u.Permissions.Modify = Config.UserDefaults.Permissions.Modify
u.Permissions.Share = Config.UserDefaults.Permissions.Share
u.Permissions.Realtime = Config.UserDefaults.Permissions.Realtime
u.Permissions.Delete = Config.UserDefaults.Permissions.Delete
u.Permissions.Create = Config.UserDefaults.Permissions.Create
u.Permissions.Download = boolValueOrDefault(Config.UserDefaults.Permissions.Download, true)
// Handle Preview - convert from pointer-based defaults to regular bools
u.Preview.DisableHideSidebar = Config.UserDefaults.Preview.DisableHideSidebar
u.Preview.HighQuality = boolValueOrDefault(Config.UserDefaults.Preview.HighQuality, true)
u.Preview.Image = boolValueOrDefault(Config.UserDefaults.Preview.Image, true)
u.Preview.Video = boolValueOrDefault(Config.UserDefaults.Preview.Video, true)
u.Preview.MotionVideoPreview = boolValueOrDefault(Config.UserDefaults.Preview.MotionVideoPreview, true)
u.Preview.Office = boolValueOrDefault(Config.UserDefaults.Preview.Office, true)
u.Preview.PopUp = boolValueOrDefault(Config.UserDefaults.Preview.PopUp, true)
u.Preview.AutoplayMedia = boolValueOrDefault(Config.UserDefaults.Preview.AutoplayMedia, true)
u.Preview.DefaultMediaPlayer = Config.UserDefaults.Preview.DefaultMediaPlayer
u.Preview.Folder = boolValueOrDefault(Config.UserDefaults.Preview.Folder, true)
u.ShowHidden = Config.UserDefaults.ShowHidden
u.DateFormat = Config.UserDefaults.DateFormat
u.DisableViewingExt = Config.UserDefaults.DisableViewingExt
@@ -54,6 +105,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 {
+245 -88
View File
@@ -6,6 +6,8 @@ import (
type AllowedMethods string
var Env Environment
const (
ProxyAuth AllowedMethods = "proxyAuth"
NoAuth AllowedMethods = "noAuth"
@@ -20,30 +22,52 @@ type Settings struct {
Integrations Integrations `json:"integrations"`
}
type Environment struct {
IsPlaywright bool `json:"-"`
IsDevMode bool `json:"-"`
IsFirstLoad bool `json:"-"` // used internally to track if this is the first load of the application
MuPdfAvailable bool `json:"-"` // used internally if compiled with mupdf support
EmbeddedFs bool `json:"-"` // used internally if compiled with embedded fs support
FFmpegPath string `json:"-"`
FFprobePath string `json:"-"`
LoginIconPath string `json:"-"` // resolved login icon path (filesystem or embedded)
LoginIconIsCustom bool `json:"-"` // true if login icon is from custom filesystem path
LoginIconEmbeddedPath string `json:"-"` // embedded asset path for default icon
FaviconPath string `json:"-"` // resolved favicon path (filesystem or embedded)
FaviconIsCustom bool `json:"-"` // true if favicon is from custom filesystem path
FaviconEmbeddedPath string `json:"-"` // embedded asset path for default favicon
}
type Server struct {
MinSearchLength int `json:"minSearchLength"` // minimum length of search query to begin searching (default: 3)
DisableUpdateCheck bool `json:"disableUpdateCheck"` // disables backend update check service
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
Socket string `json:"socket"` // socket to listen on
TLSKey string `json:"tlsKey"` // path to TLS key
TLSCert string `json:"tlsCert"` // path to TLS cert
DisablePreviews bool `json:"disablePreviews"` // disable all previews thumbnails, simple icons will be used
DisableResize bool `json:"disablePreviewResize"` // disable resizing of previews for faster loading over slow connections
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
Port int `json:"port"` // port to listen on
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
Logging []LogConfig `json:"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"`
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)
MinSearchLength int `json:"minSearchLength" yaml:"minSearchLength"` // minimum length of search query to begin searching (default: 3)
DisableUpdateCheck bool `json:"disableUpdateCheck"` // disables backend update check service
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
Socket string `json:"socket"` // socket to listen on
TLSKey string `json:"tlsKey"` // path to TLS key
TLSCert string `json:"tlsCert"` // path to TLS cert
DisablePreviews bool `json:"disablePreviews"` // disable all previews thumbnails, simple icons will be used
DisableResize bool `json:"disablePreviewResize"` // disable resizing of previews for faster loading over slow connections
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
Port int `json:"port"` // port to listen on
ListenAddress string `json:"listen"` // address to listen on (default: 0.0.0.0)
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
Logging []LogConfig `json:"logging" yaml:"logging"`
Database string `json:"database"` // path to the database file
Sources []*Source `json:"sources" validate:"required,dive"`
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
CacheDirCleanup *bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory (default: true)
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
Filesystem Filesystem `json:"filesystem"` // filesystem settings
// not exposed to config
SourceMap map[string]Source `json:"-" validate:"omitempty"` // uses realpath as key
NameToSource map[string]Source `json:"-" validate:"omitempty"` // uses name as key
MuPdfAvailable bool `json:"-"` // used internally if compiled with mupdf support
SourceMap map[string]*Source `json:"-" validate:"omitempty"` // uses realpath as key
NameToSource map[string]*Source `json:"-" validate:"omitempty"` // uses name as key
}
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 {
@@ -57,19 +81,94 @@ type OnlyOffice struct {
Url string `json:"url" validate:"required"` // The URL to the OnlyOffice Document Server, needs to be accessible to the user.
InternalUrl string `json:"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 string `json:"secret" validate:"required"` // secret: authentication key for OnlyOffice integration
ViewOnly bool `json:"viewOnly"` // view only mode for OnlyOffice
}
type Media struct {
FfmpegPath string `json:"ffmpegPath"` // path to ffmpeg directory with ffmpeg and ffprobe (eg. /usr/local/bin)
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. 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
const (
HEICImagePreview ImagePreviewType = "heic"
//RAWImagePreview ImagePreviewType = "raw"
)
func (i ImagePreviewType) String() string {
return string(i)
}
// AllImagePreviewTypes contains all supported image preview types.
var AllImagePreviewTypes = []ImagePreviewType{
HEICImagePreview,
//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"` // separated list of log levels to enable. (eg. "info|warning|error|debug")
ApiLevels string `json:"apiLevels"` // separated list of log levels to enable for the API. (eg. "info|warning|error")
Output string `json:"output"` // output location. (eg. "stdout" or "path/to/file.log")
NoColors bool `json:"noColors"` // disable colors in the output
Json bool `json:"json"` // output in json format, currently not supported
Utc bool `json:"utc"` // use UTC time in the output instead of local time
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")
Output string `json:"output" yaml:"output"` // output location. (eg. "stdout" or "path/to/file.log")
NoColors bool `json:"noColors" yaml:"noColors"` // disable colors in the output
Json bool `json:"json" yaml:"json"` // output in json format
Utc bool `json:"utc" yaml:"utc"` // use UTC time in the output instead of local time
}
type Source struct {
@@ -79,34 +178,63 @@ type Source struct {
}
type SourceConfig struct {
DenyByDefault bool `json:"denyByDefault,omitempty"` // deny access unless an "allow" access rule was specifically created.
Private bool `json:"private"` // designate as source as private -- currently just means no sharing permitted.
Disabled bool `json:"disabled,omitempty"` // disable the source, this is useful so you don't need to remove it from the config file
IndexingInterval uint32 `json:"indexingIntervalMinutes,omitempty"` // optional manual overide interval in minutes to re-index the source
DisableIndexing bool `json:"disableIndexing,omitempty"` // disable the indexing of this source
MaxWatchers int `json:"maxWatchers"` // number of concurrent watchers to use for this source, currently not supported
NeverWatchPaths []string `json:"neverWatchPaths"` // paths that get initially once. Useful for folders that rarely change contents (without source path prefix)
Exclude ExcludeIndexFilter `json:"exclude"` // exclude files and folders from indexing, if include is not set
Include IncludeIndexFilter `json:"include"` // include files and folders from indexing, if exclude is not set
DefaultUserScope string `json:"defaultUserScope"` // default "/" should match folders under path
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user
DenyByDefault bool `json:"denyByDefault,omitempty"` // deny access unless an "allow" access rule was specifically created.
Private bool `json:"private"` // designate as source as private -- currently just means no sharing permitted.
Disabled bool `json:"disabled,omitempty"` // disable the source, this is useful so you don't need to remove it from the config file
IndexingInterval uint32 `json:"indexingIntervalMinutes,omitempty"` // (optional) not recommended: manual overide interval in minutes to re-index the source
DisableIndexing bool `json:"disableIndexing,omitempty"` // (optional) not recommended: disable the indexing of this source
Conditionals ConditionalFilter `json:"conditionals"` // conditional rules to apply when indexing to include/exclude certain items
DefaultUserScope string `json:"defaultUserScope"` // defaults to root of index "/" should match folders under path
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user under defaultUserScope + username
// hidden but used internally - optimized map lookups for conditional rules
ResolvedConditionals *ResolvedConditionalsConfig `json:"-"`
}
type IncludeIndexFilter struct {
RootFolders []string `json:"rootFolders"` // list of root folders to include, relative to the source path (eg. "folder1")
RootFiles []string `json:"rootFiles"` // list of root files to include, relative to the source path (eg. "file1.txt")
type ConditionalFilter struct {
Hidden bool `json:"hidden"` // deprecated: use ignoreHidden instead to exclude hidden files and folders.
IgnoreHidden bool `json:"ignoreHidden"` // exclude hidden files and folders.
ZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // ignore folders with 0 size
ItemRules []ConditionalIndexConfig `json:"rules"` // list of item rules to apply to specific paths
}
type ExcludeIndexFilter struct {
Hidden bool `json:"hidden"` // exclude hidden files and folders.
ZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // ignore folders with 0 size
FilePaths []string `json:"filePaths"` // list of filepaths Eg. "folder1" or "file1.txt" or "folder1/file1.txt" (without source path prefix)
FolderPaths []string `json:"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 []string `json:"fileNames"` // (global) list of file names to include/exclude. Eg. "a.jpg"
FolderNames []string `json:"folderNames"` // (global) list of folder names to include/exclude. Eg. "@eadir" or ".thumbnails"
FileEndsWith []string `json:"fileEndsWith"` // (global) exclude files that end with these suffixes. Eg. ".jpg" or ".txt"
FolderEndsWith []string `json:"folderEndsWith"` // (global) exclude folders that end with these suffixes. Eg. ".thumbnails" or ".git"
type ConditionalIndexConfig struct {
NeverWatchPath string `json:"neverWatchPath"` // index the folder in the first pass to get included in search, but never re-indexed.
IncludeRootItem string `json:"includeRootItem"` // include only these items at root folder level
FileStartsWith string `json:"fileStartsWith"` // (global) exclude files that start with these prefixes. Eg. "archive-" or "backup-"
FolderStartsWith string `json:"folderStartsWith"` // (global) exclude folders that start with these prefixes. Eg. "archive-" or "backup-"
FileEndsWith string `json:"fileEndsWith"` // (global) exclude files that end with these suffixes. Eg. ".jpg" or ".txt"
FolderEndsWith string `json:"folderEndsWith"` // (global) exclude folders that end with these suffixes. Eg. ".thumbnails" or ".git"
FolderPath string `json:"folderPath"` // (global) exclude folders that match this path. Eg. "/path/to/folder" or "/path/to/folder/subfolder"
FilePath string `json:"filePath"` // (global) exclude files that match this path. Eg. "/path/to/file.txt" or "/path/to/file.txt/subfile.txt"
FileNames string `json:"fileNames"` // deprecated: exclude files that match these names. Eg. "file.txt" or "test.csv"
FolderNames string `json:"folderNames"` // deprecated: exclude folders that match these names. Eg. "folder" or "subfolder"
FileName string `json:"fileName"` // (global) exclude files that match these names. Eg. "file.txt" or "test.csv"
FolderName string `json:"folderName"` // (global) exclude folders that match these names. Eg. "folder" or "subfolder"
Viewable bool `json:"viewable"` // Enable viewing in UI but exclude from indexing
}
// ConditionalMaps provides O(1) lookup performance for conditional rules
// Maps are built from ConditionalFilter during initialization
type ResolvedConditionalsConfig struct {
// Exact match maps - O(1) lookup (only for names, not StartsWith/EndsWith)
FileNames map[string]ConditionalIndexConfig // key: file name
FolderNames map[string]ConditionalIndexConfig // key: folder name
// exact match for paths
FilePaths map[string]ConditionalIndexConfig // key: file path
FolderPaths map[string]ConditionalIndexConfig // key: folder path
FileEndsWith []ConditionalIndexConfig // list of item rules that have been resolved for specific paths
FolderEndsWith []ConditionalIndexConfig // list of item rules that have been resolved for specific paths
FileStartsWith []ConditionalIndexConfig // list of item rules that have been resolved for specific paths
FolderStartsWith []ConditionalIndexConfig // list of item rules that have been resolved for specific paths
// NeverWatch paths map - O(1) lookup for all paths with neverWatch: true
// This replaces the old NeverWatchPaths slice
NeverWatchPaths map[string]struct{} // key: full folder path
IncludeRootItems map[string]struct{} // key: inclusive root item
}
type Frontend struct {
@@ -118,14 +246,16 @@ type Frontend struct {
Styling StylingConfig `json:"styling"`
Favicon string `json:"favicon"` // path to a favicon to use for the frontend
Description string `json:"description"` // description that shows up in html head meta description
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
}
type StylingConfig struct {
CustomCSS string `json:"customCSS"` // if a valid path to a css file is provided, it will be applied for all users. (eg. "reduce-rounded-corners.css")
CustomCSSRaw string `json:"-"` // The css raw content to use for the custom css.
LightBackground string `json:"lightBackground"` // specify a valid CSS color property value to use as the background color in light mode
DarkBackground string `json:"darkBackground"` // Specify a valid CSS color property value to use as the background color in dark mode
CustomThemes map[string]CustomTheme `json:"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.
DisableEventBasedThemes bool `json:"disableEventThemes"` // disable the event based themes,
CustomCSS string `json:"customCSS"` // if a valid path to a css file is provided, it will be applied for all users. (eg. "reduce-rounded-corners.css")
CustomCSSRaw string `json:"-"` // The css raw content to use for the custom css.
LightBackground string `json:"lightBackground"` // specify a valid CSS color property value to use as the background color in light mode
DarkBackground string `json:"darkBackground"` // Specify a valid CSS color property value to use as the background color in dark mode
CustomThemes map[string]CustomTheme `json:"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.
// In-memory (not exposed to config)
CustomThemeOptions map[string]CustomTheme `json:"-"` // not exposed
}
@@ -142,37 +272,64 @@ type ExternalLink struct {
Url string `json:"url" validate:"required"` // the url to link to
}
// UserDefaultsPreview holds preview settings with pointer types for defaults
type UserDefaultsPreview struct {
DisableHideSidebar bool `json:"disableHideSidebar"` // keep sidebar open when previewing files
HighQuality *bool `json:"highQuality"` // use high quality thumbnails
Image *bool `json:"image"` // show thumbnails for image files
Video *bool `json:"video"` // show thumbnails for video files
MotionVideoPreview *bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
Office *bool `json:"office"` // show thumbnails for office files
PopUp *bool `json:"popup"` // show larger popup preview when hovering over thumbnail
AutoplayMedia *bool `json:"autoplayMedia"` // autoplay media files in preview
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable the styled feature-rich media player for browser default
Folder *bool `json:"folder"` // show thumbnails for folders that have previewable contents
}
// UserDefaultsPermissions holds permission settings with pointer types for defaults
type UserDefaultsPermissions struct {
Api bool `json:"api"` // allow api access
Admin bool `json:"admin"` // allow admin access
Modify bool `json:"modify"` // allow modifying files
Share bool `json:"share"` // allow sharing files
Realtime bool `json:"realtime"` // allow realtime updates
Delete bool `json:"delete"` // allow deleting files
Create bool `json:"create"` // allow creating or uploading files
Download *bool `json:"download"` // allow downloading files
}
// UserDefaults is a type that holds the default values
// for some fields on User.
type UserDefaults struct {
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
DarkMode bool `json:"darkMode"` // should dark mode be enabled
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
DisableSettings bool `json:"disableSettings,omitempty"` // disable the user from viewing the settings page
Preview users.Preview `json:"preview"`
DefaultScopes []users.SourceScope `json:"-"`
Permissions users.Permissions `json:"permissions"`
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
FileLoading users.FileLoading `json:"fileLoading"` // upload and download settings
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated: use disablePreviewExt instead
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor for
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
DarkMode *bool `json:"darkMode"` // should dark mode be enabled
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
DisableSettings bool `json:"disableSettings,omitempty"` // disable the user from viewing the settings page
Preview UserDefaultsPreview `json:"preview"`
DefaultScopes []users.SourceScope `json:"-"`
Permissions UserDefaultsPermissions `json:"permissions"`
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
FileLoading users.FileLoading `json:"fileLoading"` // upload and download settings
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated: use disablePreviewExt instead
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor for
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
DefaultLandingPage string `json:"defaultLandingPage"` // deprecated: determined by sidebar link order since 1.1.0
}
+19 -12
View File
@@ -1,6 +1,8 @@
package settings
import (
"errors"
"fmt"
"os"
"regexp"
"strings"
@@ -83,31 +85,36 @@ func addCustomTheme(name, description, cssFilePath string) {
// Load CSS content for runtime use (frontend consumption)
cssContent := ""
if cssFilePath != "" {
cssContent = readCustomCSS(cssFilePath)
var err error
cssContent, err = readCustomCSS(cssFilePath)
if err != nil {
logger.Warning(err.Error())
return
}
}
Config.Frontend.Styling.CustomThemeOptions[name] = CustomTheme{
Description: description,
CSS: cssFilePath,
CssRaw: cssContent, // Store loaded content
}
logger.Debugf("Added custom theme: %s", name)
}
func readCustomCSS(path string) string {
func readCustomCSS(path string) (string, error) {
if path == "" {
return ""
return "", errors.New("path is empty")
}
content, err := os.ReadFile(path)
if err != nil {
// Only log if the file exists but cannot be read (e.g., permission error)
if !os.IsNotExist(err) {
logger.Warningf("Could not read custom CSS file %s: %v", path, err)
return ""
}
return "", fmt.Errorf("could not read custom CSS file: %v", err)
}
if len(content) > 128*1024 {
logger.Warningf("Custom CSS file %s is too large (%d bytes), ignoring", path, len(content))
return ""
return "", fmt.Errorf("custom CSS file is too large (%d bytes)", len(content))
}
logger.Infof("Loaded custom CSS from: %s (%d bytes)", path, len(content))
return string(content)
if len(content) == 0 {
return "", fmt.Errorf("custom CSS file is empty: %s", path)
}
logger.Debugf("Loaded custom CSS from: %s (%d bytes)", path, len(content))
return string(content), nil
}
@@ -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)
}
+111
View File
@@ -0,0 +1,111 @@
package settings
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// combineYAMLFiles combines multiple YAML files from a directory into a single YAML document
// This allows YAML anchors defined in one file to be referenced in another
//
// Pattern matching: If main config is "config.yaml", only files matching "*-config.yaml" are merged
// Examples:
// - config.yaml + server-config.yaml + auth-config.yaml
// - test.yaml + database-test.yaml + frontend-test.yaml
// - myapp.yml + server-myapp.yml
func combineYAMLFiles(configFilePath string) ([]byte, error) {
// Get absolute path and expand tilde
expandedPath := configFilePath
if strings.HasPrefix(configFilePath, "~/") {
homeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("failed to expand home directory: %v", err)
}
expandedPath = filepath.Join(homeDir, configFilePath[2:])
}
absPath, err := filepath.Abs(expandedPath)
if err != nil {
return nil, fmt.Errorf("failed to resolve config file path: %v", err)
}
configDir := filepath.Dir(absPath)
configFileName := filepath.Base(absPath)
// Extract base name pattern for matching related config files
// e.g., "config.yaml" -> match "*-config.yaml"
// "test.yml" -> match "*-test.yml"
ext := filepath.Ext(configFileName)
baseName := strings.TrimSuffix(configFileName, ext)
pattern := "-" + baseName + ext
// Find all YAML files in the directory that match the pattern
entries, err := os.ReadDir(configDir)
if err != nil {
return nil, fmt.Errorf("failed to read config directory: %v", err)
}
var yamlFiles []string
var mainConfigContent []byte
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
// Skip hidden files (dotfiles like .goreleaser.yaml)
if strings.HasPrefix(name, ".") {
continue
}
fileExt := strings.ToLower(filepath.Ext(name))
// Only process .yaml and .yml files
if fileExt != ".yaml" && fileExt != ".yml" {
continue
}
fullPath := filepath.Join(configDir, name)
// Keep track of the main config file to process it last
if name == configFileName {
content, err := os.ReadFile(fullPath)
if err != nil {
return nil, fmt.Errorf("failed to read main config file: %v", err)
}
mainConfigContent = content
} else if strings.HasSuffix(name, pattern) {
// Only include files that match the pattern (e.g., *-config.yaml)
yamlFiles = append(yamlFiles, fullPath)
}
}
// Sort the auxiliary files for consistent ordering
sort.Strings(yamlFiles)
// Combine all YAML files as raw text into a single document
// We do NOT use `---` separators because anchors don't work across document boundaries
var combined strings.Builder
// First, add all auxiliary YAML files (these typically contain anchor definitions)
for _, file := range yamlFiles {
content, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("failed to read YAML file %s: %v", file, err)
}
combined.Write(content)
combined.WriteString("\n") // Add newline between files
}
// Finally, add the main config file (this typically contains the references)
if mainConfigContent != nil {
combined.Write(mainConfigContent)
}
return []byte(combined.String()), nil
}
+5 -6
View File
@@ -7,10 +7,9 @@ import (
)
var (
DiskUsageCache = cache.NewCache(30*time.Second, 24*time.Hour)
RealPathCache = cache.NewCache(48*time.Hour, 72*time.Hour)
SearchResultsCache = cache.NewCache(15*time.Second, 1*time.Hour)
OnlyOfficeCache = cache.NewCache(48*time.Hour, 1*time.Hour)
JwtCache = cache.NewCache(1*time.Hour, 72*time.Hour)
MediaCache = cache.NewCache(24 * time.Hour) // subtitles get cached for 24 hours
DiskUsageCache = cache.NewCache[bool](30*time.Second, 24*time.Hour)
RealPathCache = cache.NewCache[string](48*time.Hour, 72*time.Hour)
SearchResultsCache = cache.NewCache[string](15*time.Second, 1*time.Hour)
OnlyOfficeCache = cache.NewCache[string](48*time.Hour, 1*time.Hour)
JwtCache = cache.NewCache[string](1*time.Hour, 72*time.Hour)
)
+8 -3
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
@@ -30,6 +31,10 @@ func CheckForUpdates() (updateInfo, error) {
repoOwner := "gtsteffaniak"
repoName := "filebrowser"
currentVersion := version.Version
isDevMode := os.Getenv("FILEBROWSER_DEVMODE") == "true"
if currentVersion == "untracked" || currentVersion == "testing" || currentVersion == "" || isDevMode {
return updateInfo{}, nil
}
splitVersion := strings.Split(currentVersion, "-")
versionCategory := "stable"
if len(splitVersion) > 1 {
@@ -51,13 +56,13 @@ func CheckForUpdates() (updateInfo, error) {
if err := json.NewDecoder(resp.Body).Decode(&tags); err != nil {
return updateInfo{}, err
}
// Find the latest beta version greater than the current one
// Find the latest version greater than the current one that matches the version category
var NewVersion string
for _, tag := range tags {
// Check if the version is valid, is a pre-release, and contains "beta"
// Check if the version is valid and the prerelease contains the version category
if semver.IsValid(tag.Name) && strings.Contains(semver.Prerelease(tag.Name), versionCategory) {
// Check if this tag is greater than the current version
// and also greater than any other beta version we've found so far
// and also greater than any other version we've found so far
if semver.Compare(tag.Name, currentVersion) > 0 {
if NewVersion == "" || semver.Compare(tag.Name, NewVersion) > 0 {
NewVersion = tag.Name
+58
View File
@@ -0,0 +1,58 @@
package utils
import (
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"hash"
"io"
"os"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
// GetChecksum calculates the checksum of a file using the specified algorithm.
// Returns the checksum as a hex-encoded string.
func GetChecksum(fullPath, algo string) (string, error) {
reader, err := os.Open(fullPath)
if err != nil {
return "", err
}
defer reader.Close()
hashFuncs := map[string]hash.Hash{
"md5": md5.New(),
"sha1": sha1.New(),
"sha256": sha256.New(),
"sha512": sha512.New(),
}
h, ok := hashFuncs[algo]
if !ok {
return "", errors.ErrInvalidOption
}
_, err = io.Copy(h, reader)
if err != nil {
return "", err
}
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
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
AlbumArt bool // whether to get album art from media files
}
+21
View File
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"fmt"
math "math/rand"
"os"
"path/filepath"
"reflect"
"runtime"
@@ -128,3 +129,23 @@ func Ternary[T any](cond bool, vtrue, vfalse T) T {
}
return vfalse
}
// NormalizeRulePath ensures directory paths have trailing slashes for consistent rule storage
func AddTrailingSlashIfNotExists(indexPath string) string {
// Root path stays as "/"
if indexPath == "/" {
return "/"
}
// For all other paths, ensure they have trailing slashes
if !strings.HasSuffix(indexPath, "/") {
return indexPath + "/"
}
return indexPath
}
func CheckPathExists(realPath string) bool {
if _, err := os.Stat(realPath); os.IsNotExist(err) {
return false
}
return true
}
+41 -12
View File
@@ -6,17 +6,46 @@ server:
sources:
- path: "/srv"
userDefaults:
editorQuickSave: false # show quick save button in editor
hideSidebarFileActions: false # hide the file actions in the sidebar
disableQuickToggles: false # disable the quick toggles in the sidebar
disableSearchOptions: false # disable the search options in the search bar
stickySidebar: true # keep sidebar open when navigating
darkMode: true # should dark mode be enabled (defaults to true if not set)
viewMode: "normal" # view mode to use: eg. normal, list, grid, or compact
singleClick: false # open directory on single click, also enables middle click to open in new tab
showHidden: false # show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
dateFormat: false # when false, the date is relative, when true, the date is an exact timestamp
gallerySize: 3 # 0-9 - the size of the gallery thumbnails
themeColor: "var(--blue)" # theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
quickDownload: false # show icon to download in one click
disablePreviewExt: "" # space separated list of file extensions to disable preview for
disableViewingExt: "" # space separated list of file extensions to disable viewing for
lockPassword: false # disable the user from changing their password
disableSettings: false # disable the user from viewing the settings page
preview:
image: true
popup: true
video: false
office: false
highQuality: false
darkMode: true
disableSettings: false
singleClick: false
disableHideSidebar: false # disable the hide sidebar preview for previews and editors
highQuality: true # generate high quality thumbnail preview images (defaults to true if not set)
image: true # show thumbnail preview image for image files (defaults to true if not set)
video: true # show thumbnail preview image for video files (defaults to true if not set)
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering (defaults to true if not set)
office: true # show thumbnail preview image for office files (defaults to true if not set)
popup: true # show larger popup preview when hovering over thumbnail (defaults to true if not set)
autoplayMedia: true # autoplay media files in preview (defaults to true if not set)
defaultMediaPlayer: false # disable html5 media player and use the default media player
folder: true # show thumbnail preview image for folder files (defaults to true if not set)
permissions:
admin: false
modify: false
share: false
api: false
api: false # allow api access
admin: false # allow admin access
modify: false # allow modifying files
share: false # allow sharing files
realtime: false # allow realtime updates
delete: false # allow deleting files
create: false # allow creating or uploading files
download: true # allow downloading files (defaults to true if not set)
disableUpdateNotifications: false # disable update notifications banner for admin users
deleteWithoutConfirming: false # delete files without confirmation
disableOnlyOfficeExt: ".md .txt .pdf .html .xml" # list of file extensions to disable onlyoffice editor for
customTheme: "" # Name of theme to use chosen from custom themes config.
showSelectMultiple: false # show select multiple files on desktop
debugOffice: false # debug onlyoffice editor
+432 -109
View File
@@ -4,8 +4,10 @@ import (
"encoding/json"
"fmt"
"maps"
"path/filepath"
"slices"
"sort"
"strings"
"sync"
"time"
@@ -18,7 +20,12 @@ import (
"github.com/gtsteffaniak/go-logger/logger"
)
var accessCache = cache.NewCache(1 * time.Minute)
var (
accessCache = cache.NewCache[string](1 * time.Minute) // for accessChangedKey
versionCache = cache.NewCache[int](1 * time.Minute) // for version keys
permissionCache = cache.NewCache[bool](1 * time.Minute) // for permission keys
rulesCache = cache.NewCache[map[string]FrontendAccessRule](1 * time.Minute) // for rules
)
const accessRulesBucket = "access_rules"
const accessRulesKey = "rules"
@@ -57,6 +64,7 @@ type FrontendAccessRule struct {
Deny FrontendRuleSet `json:"deny"`
Allow FrontendRuleSet `json:"allow"`
SourceDenyDefault bool `json:"sourceDenyDefault"`
PathExists bool `json:"pathExists"`
}
// GroupMap maps group names to a set of usernames.
@@ -138,25 +146,75 @@ func NewStorage(db *storm.DB, usersStore *users.Storage) *Storage {
// ClearCache clears the access cache (useful for testing)
func ClearCache() {
// Recreate the cache to clear it
accessCache = cache.NewCache(1 * time.Minute)
// Recreate the caches to clear them
accessCache = cache.NewCache[string](1 * time.Minute)
versionCache = cache.NewCache[int](1 * time.Minute)
permissionCache = cache.NewCache[bool](1 * time.Minute)
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)
}
}
}
// 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 := utils.AddTrailingSlashIfNotExists(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
}
@@ -176,7 +234,8 @@ func (s *Storage) DenyUser(sourcePath, indexPath, username string) error {
}
rule.Deny.Users[username] = struct{}{}
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, false)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return s.SaveToDB()
}
@@ -196,7 +255,8 @@ func (s *Storage) AllowUser(sourcePath, indexPath, username string) error {
}
rule.Allow.Users[username] = struct{}{}
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, false)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return s.SaveToDB()
}
@@ -214,7 +274,8 @@ func (s *Storage) DenyGroup(sourcePath, indexPath, groupname string) error {
}
rule.Deny.Groups[groupname] = struct{}{}
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, false)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return s.SaveToDB()
}
@@ -232,7 +293,8 @@ func (s *Storage) AllowGroup(sourcePath, indexPath, groupname string) error {
}
rule.Allow.Groups[groupname] = struct{}{}
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, false)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return s.SaveToDB()
}
@@ -246,130 +308,132 @@ func (s *Storage) DenyAll(sourcePath, indexPath string) error {
}
rule.DenyAll = true
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, false)
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, "/") {
return false
}
// Get current version for the sourcePath
versionKey := "version:" + sourcePath
version := 0
if v, ok := accessCache.Get(versionKey).(int); ok {
if v, ok := versionCache.Get(versionKey); ok {
version = v
}
// Check cache with versioned key
permKey := fmt.Sprintf("perm:%s:%d:%s:%s", sourcePath, version, indexPath, username)
if p, ok := accessCache.Get(permKey).(bool); ok {
if p, ok := permissionCache.Get(permKey); ok {
return p
}
// Not in cache, compute, then cache it.
result := s.computePermitted(sourcePath, indexPath, username)
accessCache.Set(permKey, result)
permissionCache.Set(permKey, result)
return result
}
func (s *Storage) computePermitted(sourcePath, indexPath, username string) bool {
var rulesFound []*AccessRule
// Walk up the path hierarchy and collect all relevant rules
currentPath := indexPath
pathLevel := 0
for {
permitted, found := s.permittedAtExactPath(sourcePath, indexPath, username)
rule, found := s.getRuleAtExactPath(sourcePath, currentPath)
if found {
return permitted
rulesFound = append(rulesFound, rule)
}
indexPath = utils.GetParentDirectoryPath(indexPath)
if indexPath == "" {
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)
for _, rule := range rulesFound {
permitted, hasSpecificRule := s.evaluateRuleForUser(rule, username)
if hasSpecificRule {
return permitted
}
}
// No rules found anywhere in the path hierarchy
// Check if the source has DenyByDefault configured (acts like a root-level denyAll rule)
// No specific user or group rule found in the hierarchy.
// Check for any DenyAll rule in the path.
for _, rule := range rulesFound {
if rule.DenyAll {
return false
}
}
// No specific rules found anywhere in the path hierarchy.
// Fallback to the source's DenyByDefault setting.
sourceInfo, ok := settings.Config.Server.SourceMap[sourcePath]
if !ok {
logger.Errorf("source %s not found in config during access check", sourcePath)
return false
}
if sourceInfo.Config.DenyByDefault {
return false
}
return true
return !sourceInfo.Config.DenyByDefault
}
// 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) {
// getRuleAtExactPath is a helper to get a rule without the recursive logic.
func (s *Storage) getRuleAtExactPath(sourcePath, indexPath string) (*AccessRule, bool) {
s.mux.RLock()
defer s.mux.RUnlock()
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
s.mux.RUnlock()
return true, false
return nil, false
}
rule, ok := rulesBySource[indexPath]
s.mux.RUnlock()
if !ok {
return true, false
}
return s.permittedRule(rule, username, sourcePath), true
// Normalize the path to ensure consistent rule lookup
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
rule, ok := rulesBySource[normalizedPath]
return rule, ok
}
// 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
// evaluateRuleForUser evaluates a single rule for a user and returns if a specific rule was found.
func (s *Storage) evaluateRuleForUser(rule *AccessRule, username string) (permitted bool, hasSpecificRule bool) {
// Check user deny first
if _, found := rule.Deny.Users[username]; found {
return false
return false, true
}
// Check group deny
for group := range rule.Deny.Groups {
if s.isUserInGroup(username, group) {
return false
return false, true
}
}
// 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
}
}
}
// Check user allow
if _, found := rule.Allow.Users[username]; found {
return true, 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 group allow
for group := range rule.Allow.Groups {
if s.isUserInGroup(username, group) {
return true, 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
// No specific rule for this user in this rule set.
return false, false
}
// isUserInGroup checks if a username is in a group.
@@ -396,8 +460,12 @@ func (s *Storage) GetFrontendRules(sourcePath, indexPath string) (FrontendAccess
sourceDenyDefault = sourceInfo.Config.DenyByDefault
}
// Check if path exists on filesystem
pathExists := utils.CheckPathExists(filepath.Join(sourcePath, indexPath))
frontendRules := FrontendAccessRule{
SourceDenyDefault: sourceDenyDefault,
PathExists: pathExists,
Deny: FrontendRuleSet{
Users: make([]string, 0),
Groups: make([]string, 0),
@@ -421,16 +489,19 @@ func (s *Storage) GetFrontendRules(sourcePath, indexPath string) (FrontendAccess
frontendRules.Deny.Groups = utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups)))
frontendRules.Allow.Users = utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Users)))
frontendRules.Allow.Groups = utils.NonNilSlice(slices.Collect(maps.Keys(rule.Allow.Groups)))
logger.Debugf("Returning frontend rules for source: %s and index: %s", sourcePath, indexPath)
return frontendRules, ok
}
// GetAllRules returns all access rules as a map.
func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule, error) {
value, ok := accessCache.Get(accessChangedKey + sourcePath).(map[string]FrontendAccessRule)
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()
@@ -450,10 +521,18 @@ 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
// Check if path exists on filesystem
pathExists := utils.CheckPathExists(filepath.Join(sourcePath, indexPath))
// Convert AccessRule to FrontendAccessRule
frontendRules[indexPath] = FrontendAccessRule{
frontendRules[frontendPath] = FrontendAccessRule{
DenyAll: rule.DenyAll,
SourceDenyDefault: sourceDenyDefault,
PathExists: pathExists,
Deny: FrontendRuleSet{
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
@@ -465,7 +544,7 @@ func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule,
}
}
// cache responses
accessCache.Set(accessChangedKey+sourcePath, frontendRules)
rulesCache.Set(accessChangedKey+sourcePath, frontendRules)
return frontendRules, nil
}
@@ -571,7 +650,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 := utils.AddTrailingSlashIfNotExists(indexPath)
rule, ok := s.AllRules[sourcePath][normalizedPath]
if !ok {
return false, nil
}
@@ -586,13 +666,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)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return exists, s.SaveToDB()
}
return false, nil
@@ -602,7 +683,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 := utils.AddTrailingSlashIfNotExists(indexPath)
rule, ok := s.AllRules[sourcePath][normalizedPath]
if !ok {
return false, nil
}
@@ -617,13 +699,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)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return exists, s.SaveToDB()
}
return exists, nil
@@ -633,7 +716,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 := utils.AddTrailingSlashIfNotExists(indexPath)
rule, ok := s.AllRules[sourcePath][normalizedPath]
if !ok {
return false, nil
}
@@ -648,13 +732,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)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return exists, s.SaveToDB()
}
return false, nil
@@ -664,7 +749,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 := utils.AddTrailingSlashIfNotExists(indexPath)
rule, ok := s.AllRules[sourcePath][normalizedPath]
if !ok {
return false, nil
}
@@ -679,13 +765,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)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return exists, s.SaveToDB()
}
return exists, nil
@@ -695,7 +782,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 := utils.AddTrailingSlashIfNotExists(indexPath)
rule, ok := s.AllRules[sourcePath][normalizedPath]
if !ok {
return false, nil
}
@@ -707,13 +795,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)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return true, s.SaveToDB()
}
return false, nil
@@ -747,7 +836,8 @@ func (s *Storage) RemoveAllRulesForUser(username string) error {
}
for sp := range changedSourcePaths {
s.incrementSourceVersion(sp)
accessCache.Set(accessChangedKey+sp, false)
accessCache.Set(accessChangedKey+sp, "false")
rulesCache.Delete(accessChangedKey + sp)
}
if changed {
return s.SaveToDB()
@@ -783,7 +873,8 @@ func (s *Storage) RemoveAllRulesForGroup(groupname string) error {
}
for sp := range changedSourcePaths {
s.incrementSourceVersion(sp)
accessCache.Set(accessChangedKey+sp, false)
accessCache.Set(accessChangedKey+sp, "false")
rulesCache.Delete(accessChangedKey + sp)
}
if changed {
return s.SaveToDB()
@@ -904,6 +995,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,
@@ -920,13 +1016,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
@@ -955,6 +1051,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,
@@ -971,25 +1072,247 @@ 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
}
// incrementSourceVersion increments the version of a sourcePath to invalidate caches.
// The caller MUST hold the mutex lock.
func (s *Storage) incrementSourceVersion(sourcePath string) {
key := "version:" + sourcePath
version := 0
if v, ok := accessCache.Get(key).(int); ok {
if v, ok := versionCache.Get(key); ok {
version = v
}
accessCache.Set(key, version+1)
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
}
// RemoveUserCascade removes a user from either the allow or deny list for a given path and all its subpaths.
// This is used for cascade delete operations when deleting user access from a directory tree.
// The allow parameter determines which list to remove from: true for allow list, false for deny list.
func (s *Storage) RemoveUserCascade(sourcePath, indexPath, username string, allow bool) (int, error) {
s.mux.Lock()
defer s.mux.Unlock()
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return 0, nil
}
changed := false
removedCount := 0
// Iterate through all rules for this source
for rulePath, rule := range rulesBySource {
// Check if this rule path matches or is a subpath of the target path
if rulePath == normalizedPath || strings.HasPrefix(rulePath, normalizedPath) {
if allow {
// Remove user from allow list only
if _, exists := rule.Allow.Users[username]; exists {
delete(rule.Allow.Users, username)
changed = true
removedCount++
}
} else {
// Remove user from deny list only
if _, exists := rule.Deny.Users[username]; exists {
delete(rule.Deny.Users, username)
changed = true
removedCount++
}
}
// If rule is now empty, mark it for deletion
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 &&
len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 && !rule.DenyAll {
delete(s.AllRules[sourcePath], rulePath)
}
}
}
// If no rules left for this source, remove the source entry
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
if changed {
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return removedCount, s.SaveToDB()
}
return 0, nil
}
// RemoveGroupCascade removes a group from either the allow or deny list for a given path and all its subpaths.
// This is used for cascade delete operations when deleting group access from a directory tree.
// The allow parameter determines which list to remove from: true for allow list, false for deny list.
func (s *Storage) RemoveGroupCascade(sourcePath, indexPath, groupname string, allow bool) (int, error) {
s.mux.Lock()
defer s.mux.Unlock()
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return 0, nil
}
changed := false
removedCount := 0
// Iterate through all rules for this source
for rulePath, rule := range rulesBySource {
// Check if this rule path matches or is a subpath of the target path
if rulePath == normalizedPath || strings.HasPrefix(rulePath, normalizedPath) {
if allow {
// Remove group from allow list only
if _, exists := rule.Allow.Groups[groupname]; exists {
delete(rule.Allow.Groups, groupname)
changed = true
removedCount++
}
} else {
// Remove group from deny list only
if _, exists := rule.Deny.Groups[groupname]; exists {
delete(rule.Deny.Groups, groupname)
changed = true
removedCount++
}
}
// If rule is now empty, mark it for deletion
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 &&
len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 && !rule.DenyAll {
delete(s.AllRules[sourcePath], rulePath)
}
}
}
// If no rules left for this source, remove the source entry
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
if changed {
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return removedCount, s.SaveToDB()
}
return 0, nil
}
// UpdateRules updates all access rules that match oldPath to point to newPath.
// Handles both exact matches and subdirectories. Similar to share.Storage.UpdateShares.
func (s *Storage) UpdateRules(sourcePath, oldPath, newPath string) (int, error) {
s.mux.Lock()
defer s.mux.Unlock()
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return 0, nil // No rules for this source, not an error
}
// Normalize paths (always add trailing slash for access rules)
oldPath = utils.AddTrailingSlashIfNotExists(oldPath)
newPath = utils.AddTrailingSlashIfNotExists(newPath)
updated := 0
rulesToUpdate := make(map[string]string) // old path -> new path
// Find all rules that need to be updated
for rulePath := range rulesBySource {
if rulePath == oldPath {
// Exact match
rulesToUpdate[rulePath] = newPath
} else if strings.HasPrefix(rulePath, oldPath) {
// Subdirectory - replace prefix
newRulePath := newPath + strings.TrimPrefix(rulePath, oldPath)
rulesToUpdate[rulePath] = newRulePath
}
}
// Update all matched rules
for oldRulePath, newRulePath := range rulesToUpdate {
rule := rulesBySource[oldRulePath]
delete(rulesBySource, oldRulePath)
rulesBySource[newRulePath] = rule
logger.Info("access rule updated", "source", sourcePath, "fromPath", oldRulePath, "toPath", newRulePath)
updated++
}
if updated > 0 {
// Invalidate caches
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
if err := s.SaveToDB(); err != nil {
return updated, err
}
}
return updated, nil
}
// UpdateRulePath updates the path for a specific access rule (used by PATCH API endpoint).
func (s *Storage) UpdateRulePath(sourcePath, oldPath, newPath string) error {
s.mux.Lock()
defer s.mux.Unlock()
// Normalize paths
oldPath = utils.AddTrailingSlashIfNotExists(oldPath)
newPath = utils.AddTrailingSlashIfNotExists(newPath)
rulesBySource, ok := s.AllRules[sourcePath]
if !ok {
return fmt.Errorf("no rules found for source: %s", sourcePath)
}
rule, ok := rulesBySource[oldPath]
if !ok {
return fmt.Errorf("no rule found for path: %s", oldPath)
}
// Remove the old rule and add it with the new path
delete(rulesBySource, oldPath)
rulesBySource[newPath] = rule
// Invalidate caches
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
logger.Debugf("access rule path updated: source=%s, fromPath=%s, toPath=%s", sourcePath, oldPath, newPath)
return s.SaveToDB()
}
File diff suppressed because it is too large. Load diff
+59
View File
@@ -0,0 +1,59 @@
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
}
// Save original folders and files before filtering
originalFolders := response.Folders
originalFiles := response.Files
// Filter and return only the items the user has access to
response.Folders = make([]iteminfo.ItemInfo, 0)
response.Files = make([]iteminfo.ExtendedItemInfo, 0)
// Check each subfolder for access permissions
for _, folder := range originalFolders {
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 originalFiles {
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
}
+153
View File
@@ -0,0 +1,153 @@
package share
import (
"fmt"
"os"
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
// IsSingleFileShare determines if this share is for a single file (not a directory).
// It checks both the file extension and filesystem to determine if the path points to a file.
func (l *Link) IsSingleFileShare() bool {
if l.Path == "" {
return false
}
// First, check if the path has a file extension (common indicator of a file)
ext := filepath.Ext(l.Path)
if ext != "" {
// If it has an extension, it's likely a file, but let's verify with filesystem
return l.isFileOnFilesystem()
}
// If no extension, check if it's a directory by looking at the filesystem
return !l.isDirectoryOnFilesystem()
}
// isFileOnFilesystem checks if the path exists and is a file on the filesystem
func (l *Link) isFileOnFilesystem() bool {
// Construct the full path using Source and Path
fullPath := l.Path
if l.Source != "" {
// If Source is provided, it might be a relative path from the source
fullPath = filepath.Join(l.Source, l.Path)
}
info, err := os.Stat(fullPath)
if err != nil {
// If we can't stat the file, fall back to extension check
return filepath.Ext(l.Path) != ""
}
return !info.IsDir()
}
// isDirectoryOnFilesystem checks if the path exists and is a directory on the filesystem
func (l *Link) isDirectoryOnFilesystem() bool {
// Construct the full path using Source and Path
fullPath := l.Path
if l.Source != "" {
// If Source is provided, it might be a relative path from the source
fullPath = filepath.Join(l.Source, l.Path)
}
info, err := os.Stat(fullPath)
if err != nil {
// If we can't stat the file, assume it's a file if it has an extension
return filepath.Ext(l.Path) == ""
}
return info.IsDir()
}
// IsExpired checks if the share has expired based on the Expire timestamp
func (l *Link) IsExpired() bool {
if l.Expire == 0 {
return false // No expiration set
}
// This would need time.Now().Unix() but we avoid importing time here
// The actual expiration check is handled in the storage layer
return false // Placeholder - actual implementation would check current time
}
// HasPassword checks if the share is password protected
func (l *Link) HasPassword() bool {
return l.PasswordHash != ""
}
// IsPermanent checks if the share is permanent (no expiration)
func (l *Link) IsPermanent() bool {
return l.Expire == 0
}
// GetFileExtension returns the file extension of the shared file
func (l *Link) GetFileExtension() string {
if l.Path == "" {
return ""
}
return filepath.Ext(l.Path)
}
// GetFileName returns just the filename (without path) of the shared item
func (l *Link) GetFileName() string {
if l.Path == "" {
return ""
}
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
}
func (l *Link) GetSourceName() (string, error) {
sourceInfo, ok := settings.Config.Server.SourceMap[l.Source]
if !ok {
return "", fmt.Errorf("source not found")
}
return sourceInfo.Name, nil
}
+47 -33
View File
@@ -1,34 +1,45 @@
package share
import "sync"
import (
"sync"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
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"`
ViewMode string `json:"viewMode,omitempty"`
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"
EnableOnlyOffice bool `json:"enableOnlyOffice,omitempty"`
EnableOnlyOfficeEditing bool `json:"enableOnlyOfficeEditing,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"`
ShareType string `json:"shareType"` // type of share: normal, upload, max
PerUserDownloadLimit bool `json:"perUserDownloadLimit,omitempty"`
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles,omitempty"` // can be io intensive for large files and take 10-30 seconds.
AllowDelete bool `json:"allowDelete,omitempty"`
AllowCreate bool `json:"allowCreate,omitempty"` // allow creating files
AllowModify bool `json:"allowModify,omitempty"` // allow modifying files
DisableFileViewer bool `json:"disableFileViewer,omitempty"` // don't allow viewing files
DisableDownload bool `json:"disableDownload,omitempty"` // don't allow downloading files
AllowReplacements bool `json:"allowReplacements,omitempty"` // allow replacements of files
SidebarLinks []users.SidebarLink `json:"sidebarLinks"` // customizable sidebar links
HasPassword bool `json:"hasPassword,omitempty"`
}
type CreateBody struct {
CommonShare
@@ -41,14 +52,17 @@ 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"`
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"`
// 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.
Token string `json:"token,omitempty"`
Mu sync.Mutex `json:"-"`
UserDownloads map[string]int `json:"userDownloads,omitempty"` // Track downloads per username
Version int `json:"version,omitempty"`
}
+52 -9
View File
@@ -6,6 +6,7 @@ import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/crud"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
@@ -16,6 +17,7 @@ type StorageBackend interface {
All() ([]*Link, error)
FindByUserID(id uint) ([]*Link, error)
GetByHash(hash string) (*Link, error)
GetCommonShareByHash(hash string) (*CommonShare, error)
GetPermanent(path, source string, id uint) (*Link, error)
GetBySourcePath(path, source string) ([]*Link, error)
Gets(path, source string, id uint) ([]*Link, error)
@@ -148,8 +150,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
@@ -217,51 +222,84 @@ func (s *Storage) GetBySourcePath(path, source string) ([]*Link, error) {
return filtered, nil
}
// UpdateShares updates all shares that exactly match oldSource and oldPath
// to point to newSource and newPath. Returns the number of updated shares.
// UpdateShares updates all shares that match oldSource and oldPath to point to newSource and newPath.
// Handles both exact matches and subdirectories, regardless of trailing slashes.
func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (int, error) {
links, err := s.back.All()
if err != nil && err != errors.ErrNotExist {
logger.Error("failed to list shares", "error", err)
return 0, err
}
// Normalize paths for comparison (remove trailing slashes)
oldPath = utils.AddTrailingSlashIfNotExists(oldPath)
newPath = utils.AddTrailingSlashIfNotExists(newPath)
updated := 0
for _, l := range links {
if l == nil {
continue
}
if l.Source != oldSource {
if l == nil || l.Source != oldSource {
continue
}
l.Path = utils.AddTrailingSlashIfNotExists(l.Path)
pos := strings.Index(l.Path, oldPath)
if pos < 0 {
continue
}
oldFullPath := l.Path
l.Source = newSource
l.Path = oldFullPath[:pos] + newPath + oldFullPath[pos+len(oldPath):]
l.Path = newPath
if err := s.back.Save(l); err != nil {
logger.Error("failed to save updated share", "hash", l.Hash, "error", err)
return updated, err
}
s.mu.Lock()
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
// update existing cached pointer fields
existing.Source = l.Source
existing.Path = l.Path
} else {
s.shareCache[l.Hash] = l
}
s.mu.Unlock()
logger.Info("share updated", "hash", l.Hash, "fromPath", oldFullPath, "toPath", l.Path)
updated++
}
if updated == 0 {
logger.Warning("no matching shares to update for provided source/path")
}
return updated, nil
}
// UpdateSharePath updates the path for a specific share identified by hash
func (s *Storage) UpdateSharePath(hash, newPath string) error {
link, err := s.GetByHash(hash)
if err != nil {
return err
}
oldPath := link.Path
link.Path = newPath
if err := s.back.Save(link); err != nil {
logger.Error("failed to save updated share", "hash", hash, "error", err)
return err
}
s.mu.Lock()
if existing, ok := s.shareCache[hash]; ok && existing != nil {
existing.Path = newPath
}
s.mu.Unlock()
logger.Debug("share path updated", "hash", hash, "fromPath", oldPath, "toPath", newPath)
return nil
}
// Save wraps StorageBackend.Save
func (s *Storage) Save(l *Link) error {
if err := s.back.Save(l); err != nil {
@@ -318,3 +356,8 @@ func (s *Storage) filterExpired(links []*Link) ([]*Link, error) {
}
return filtered, nil
}
// GetCommonShareByHash retrieves only the CommonShare attributes for a given hash
func (s *Storage) GetCommonShareByHash(hash string) (*CommonShare, error) {
return s.back.GetCommonShareByHash(hash)
}
+17
View File
@@ -45,6 +45,23 @@ func (s shareBackend) GetByHash(hash string) (*share.Link, error) {
return &v, err
}
func (s shareBackend) GetCommonShareByHash(hash string) (*share.CommonShare, error) {
var v share.Link
err := s.db.One("Hash", hash, &v)
if err == storm.ErrNotFound {
return nil, errors.ErrNotExist
}
if err != nil {
return nil, err
}
v.Source = ""
v.Path = ""
v.AllowedUsernames = nil
v.DownloadsLimit = 0
v.CommonShare.HasPassword = v.HasPassword()
return &v.CommonShare, nil
}
func (s shareBackend) GetPermanent(path, source string, id uint) (*share.Link, error) {
var v share.Link
// TODO remove legacy and return notfound errors
+64 -27
View File
@@ -74,29 +74,34 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
if err != nil {
return err
}
passwordUser := existingUser.LoginMethod == users.LoginMethodPassword
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
if passwordUser && enforcedOtp && !user.OtpEnabled {
return errors.ErrNoTotpConfigured
}
if user.LoginMethod == "" {
user.LoginMethod = existingUser.LoginMethod
}
fields, err = parseFields(user, fields, actorIsAdmin)
if err != nil {
return err
}
if user.LoginMethod == "" {
user.LoginMethod = existingUser.LoginMethod
}
if !slices.Contains(fields, "Password") {
user.Password = existingUser.Password
} else {
if existingUser.LockPassword {
if existingUser.LockPassword && !actorIsAdmin {
return fmt.Errorf("password cannot be changed when lock password is enabled")
}
}
if !actorIsAdmin {
err := checkRestrictedFields(existingUser, fields)
if err != nil {
return err
}
fields = filterRestrictedFields(fields)
}
if len(fields) == 0 {
@@ -115,6 +120,14 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
return err
}
}
// converting scopes to map of paths intead of names (names can change)
if slices.Contains(fields, "SidebarLinks") {
adjustedLinks, err := settings.ConvertToBackendSidebarLinks(user.SidebarLinks)
if err != nil {
return err
}
user.SidebarLinks = adjustedLinks
}
// Use reflection to access struct fields
userFields := reflect.ValueOf(user).Elem() // Get struct value
for _, field := range fields {
@@ -133,11 +146,11 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
val := fieldValue.Interface()
if field == "OtpEnabled" {
otpEnabled, _ := val.(bool)
if otpEnabled {
continue
if !otpEnabled {
field = "TOTPSecret" // if otp is disabled, we also want to clear the TOTPSecret
val = "" // clear the TOTPSecret
}
field = "TOTPSecret" // if otp is disabled, we also want to clear the TOTPSecret
val = "" // clear the TOTPSecret
// If otpEnabled is true, continue with normal field update
}
// Update the database
if err := st.db.UpdateField(existingUser, field, val); err != nil {
@@ -204,17 +217,19 @@ func (st usersBackend) DeleteByUsername(username string) error {
return st.db.DeleteStruct(user)
}
// Define a function to check for restricted fields
func checkRestrictedFields(existingUser *users.User, fields []string) error {
// Define a function to filter out restricted fields for non-admin users
func filterRestrictedFields(fields []string) []string {
// Get a list of allowed fields from NonAdminEditable
allowed := getNonAdminEditableFieldNames()
var filteredFields []string
for _, field := range fields {
if !slices.Contains(allowed, field) {
return fmt.Errorf("non-admins cannot modify field: %s", field)
if slices.Contains(allowed, field) {
filteredFields = append(filteredFields, field)
}
}
return nil
return filteredFields
}
// Helper to return list of field names from NonAdminEditable struct
@@ -242,12 +257,27 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
field := t.Field(i)
// which=all can't update password
switch strings.ToLower(field.Name) {
case "id", "username", "password", "apikeys", "totpenabled", "totpsecret", "totpnonce":
case "id", "username", "password", "apikeys", "totpsecret", "totpnonce":
// Skip these fields
continue
}
fields = append(fields, field.Name)
// Handle embedded structs (like NonAdminEditable)
if field.Anonymous {
// Get the embedded struct type
embeddedType := field.Type
if embeddedType.Kind() == reflect.Ptr {
embeddedType = embeddedType.Elem()
}
// Add all fields from the embedded struct
for j := 0; j < embeddedType.NumField(); j++ {
embeddedField := embeddedType.Field(j)
fields = append(fields, embeddedField.Name)
}
} else {
fields = append(fields, field.Name)
}
}
}
newfields := []string{}
@@ -259,18 +289,25 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
}
}
if capitalField == "Password" {
if user.LoginMethod != users.LoginMethodPassword {
return nil, fmt.Errorf("password cannot be changed when login method is not password")
// Only process password if it's actually being updated (not empty)
if user.Password != "" {
logger.Debugf("Updating password for user [%s] loginMethod: %s", user.Username, user.LoginMethod)
if user.LoginMethod != users.LoginMethodPassword {
return nil, fmt.Errorf("password cannot be changed when login method is not password")
}
err := checkPassword(user.Password)
if err != nil {
return nil, fmt.Errorf("password does not meet complexity requirements")
}
value, err := users.HashPwd(user.Password)
if err != nil {
logger.Error(err.Error())
}
user.Password = value
} else {
// Skip password field if it's empty
continue
}
err := checkPassword(user.Password)
if err != nil {
return nil, fmt.Errorf("password does not meet complexity requirements")
}
value, err := users.HashPwd(user.Password)
if err != nil {
logger.Error(err.Error())
}
user.Password = value
}
newfields = append(newfields, capitalField)
}
+503
View File
@@ -0,0 +1,503 @@
package bolt
import (
"path/filepath"
"testing"
"github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
func createTestUsersBackend(t *testing.T) users.StorageBackend {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
db, err := storm.Open(dbPath)
if err != nil {
t.Fatalf("failed to open storm db: %v", err)
}
return NewUsersBackend(db)
}
func createTestUser(t *testing.T, backend users.StorageBackend, username string, isAdmin bool) *users.User {
user := &users.User{
Username: username,
NonAdminEditable: users.NonAdminEditable{
Password: "testpass123",
DarkMode: false,
OtpEnabled: true,
Locale: "en",
ViewMode: "normal",
SingleClick: false,
},
Permissions: users.Permissions{
Admin: isAdmin,
},
LoginMethod: users.LoginMethodPassword,
}
err := backend.Save(user, true, false)
if err != nil {
t.Fatalf("failed to create user %s: %v", username, err)
}
return user
}
func TestUpdate_AdminModifyOtherUser(t *testing.T) {
backend := createTestUsersBackend(t)
// Create admin user
_ = createTestUser(t, backend, "admin", true)
// Create regular user
regularUser := createTestUser(t, backend, "regular", false)
// Admin modifies regular user
updateData := &users.User{
ID: regularUser.ID,
Username: regularUser.Username,
NonAdminEditable: users.NonAdminEditable{
DarkMode: true,
OtpEnabled: false,
Locale: "fr",
},
Permissions: users.Permissions{
Admin: false, // Admin can modify permissions
},
}
// Test with which=all
err := backend.Update(updateData, true, "all")
if err != nil {
t.Errorf("Admin should be able to modify other user with which=all: %v", err)
}
// Verify changes were applied
updatedUser, err := backend.GetBy(regularUser.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if !updatedUser.DarkMode {
t.Error("DarkMode should have been updated to true")
}
if updatedUser.OtpEnabled {
t.Error("OtpEnabled should have been updated to false")
}
if updatedUser.Locale != "fr" {
t.Errorf("Locale should have been updated to 'fr', got '%s'", updatedUser.Locale)
}
// Test with specific fields
updateData2 := &users.User{
ID: regularUser.ID,
Username: regularUser.Username,
NonAdminEditable: users.NonAdminEditable{
ViewMode: "list",
},
}
err = backend.Update(updateData2, true, "ViewMode")
if err != nil {
t.Errorf("Admin should be able to modify specific field: %v", err)
}
updatedUser2, err := backend.GetBy(regularUser.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if updatedUser2.ViewMode != "list" {
t.Errorf("ViewMode should have been updated to 'list', got '%s'", updatedUser2.ViewMode)
}
}
func TestUpdate_AdminModifySelf(t *testing.T) {
backend := createTestUsersBackend(t)
// Create admin user
admin := createTestUser(t, backend, "admin", true)
// Admin modifies themselves
updateData := &users.User{
ID: admin.ID,
Username: admin.Username,
NonAdminEditable: users.NonAdminEditable{
DarkMode: true,
OtpEnabled: false,
Locale: "de",
},
}
// Test with which=all
err := backend.Update(updateData, true, "all")
if err != nil {
t.Errorf("Admin should be able to modify themselves with which=all: %v", err)
}
// Verify changes were applied
updatedUser, err := backend.GetBy(admin.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if !updatedUser.DarkMode {
t.Error("DarkMode should have been updated to true")
}
if updatedUser.OtpEnabled {
t.Error("OtpEnabled should have been updated to false")
}
if updatedUser.Locale != "de" {
t.Errorf("Locale should have been updated to 'de', got '%s'", updatedUser.Locale)
}
}
func TestUpdate_NonAdminModifySelf(t *testing.T) {
backend := createTestUsersBackend(t)
// Create regular user
regularUser := createTestUser(t, backend, "regular", false)
// Non-admin modifies themselves
updateData := &users.User{
ID: regularUser.ID,
Username: regularUser.Username,
NonAdminEditable: users.NonAdminEditable{
DarkMode: true,
OtpEnabled: false, // This should be allowed now
Locale: "es",
ViewMode: "grid",
},
}
// Test with which=all
err := backend.Update(updateData, false, "all")
if err != nil {
t.Errorf("Non-admin should be able to modify themselves with which=all: %v", err)
}
// Verify changes were applied
updatedUser, err := backend.GetBy(regularUser.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if !updatedUser.DarkMode {
t.Error("DarkMode should have been updated to true")
}
if updatedUser.OtpEnabled {
t.Error("OtpEnabled should have been updated to false")
}
if updatedUser.Locale != "es" {
t.Errorf("Locale should have been updated to 'es', got '%s'", updatedUser.Locale)
}
if updatedUser.ViewMode != "grid" {
t.Errorf("ViewMode should have been updated to 'grid', got '%s'", updatedUser.ViewMode)
}
// Test with specific fields
updateData2 := &users.User{
ID: regularUser.ID,
Username: regularUser.Username,
NonAdminEditable: users.NonAdminEditable{
SingleClick: true,
},
}
err = backend.Update(updateData2, false, "SingleClick")
if err != nil {
t.Errorf("Non-admin should be able to modify specific allowed field: %v", err)
}
updatedUser2, err := backend.GetBy(regularUser.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if !updatedUser2.SingleClick {
t.Error("SingleClick should have been updated to true")
}
}
func TestUpdate_NonAdminModifySelfRestrictedFields(t *testing.T) {
backend := createTestUsersBackend(t)
// Create regular user
regularUser := createTestUser(t, backend, "regular", false)
// Non-admin tries to modify restricted fields (should be filtered out, not error)
updateData := &users.User{
ID: regularUser.ID,
Username: regularUser.Username,
NonAdminEditable: users.NonAdminEditable{
DarkMode: true, // This should be allowed
},
Permissions: users.Permissions{
Admin: true, // This should be filtered out
},
Scopes: []users.SourceScope{ // This should be filtered out
{Name: "test", Scope: "/test"},
},
}
// Test with which=all - should not error, but should filter out restricted fields
err := backend.Update(updateData, false, "all")
if err != nil {
t.Errorf("Non-admin should not error when trying to modify restricted fields (should be filtered): %v", err)
}
// Verify only allowed fields were updated
updatedUser, err := backend.GetBy(regularUser.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
// Allowed field should be updated
if !updatedUser.DarkMode {
t.Error("DarkMode should have been updated to true")
}
// Restricted fields should NOT be updated
if updatedUser.Permissions.Admin {
t.Error("Permissions.Admin should not have been updated (restricted field)")
}
if len(updatedUser.Scopes) != 0 {
t.Error("Scopes should not have been updated (restricted field)")
}
// Test with specific restricted fields - should be filtered out
updateData2 := &users.User{
ID: regularUser.ID,
Username: regularUser.Username,
Permissions: users.Permissions{
Admin: true,
},
}
err = backend.Update(updateData2, false, "Permissions")
if err == nil {
t.Error("Non-admin should get error when trying to modify only restricted fields (all fields filtered out)")
}
if err.Error() != "no fields to update" {
t.Errorf("Expected 'no fields to update' error, got: %v", err)
}
// Verify restricted field was not updated
updatedUser2, err := backend.GetBy(regularUser.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if updatedUser2.Permissions.Admin {
t.Error("Permissions.Admin should not have been updated (restricted field)")
}
}
func TestUpdate_NonAdminModifyOtherUser(t *testing.T) {
backend := createTestUsersBackend(t)
// Create two regular users
_ = createTestUser(t, backend, "user1", false)
user2 := createTestUser(t, backend, "user2", false)
// Non-admin tries to modify another user (this should be handled at a higher level)
// But if it gets to the backend, it should work the same as modifying self
updateData := &users.User{
ID: user2.ID,
Username: user2.Username,
NonAdminEditable: users.NonAdminEditable{
DarkMode: true,
},
}
// This should work (filtering restricted fields) but the higher-level auth should prevent this
err := backend.Update(updateData, false, "DarkMode")
if err != nil {
t.Errorf("Backend should handle non-admin modifying other user (filtering restricted fields): %v", err)
}
// Verify the change was applied
updatedUser, err := backend.GetBy(user2.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if !updatedUser.DarkMode {
t.Error("DarkMode should have been updated to true")
}
}
func TestUpdate_NonAdminModifyOtherUserOtp(t *testing.T) {
backend := createTestUsersBackend(t)
// Create two regular users
_ = createTestUser(t, backend, "user1", false)
user2 := createTestUser(t, backend, "user2", false)
// Non-admin tries to modify another user's OTP
updateData := &users.User{
ID: user2.ID,
Username: user2.Username,
NonAdminEditable: users.NonAdminEditable{
OtpEnabled: false,
},
}
// This should work (OtpEnabled is now in NonAdminEditable)
err := backend.Update(updateData, false, "OtpEnabled")
if err != nil {
t.Errorf("Non-admin should be able to modify OTP field: %v", err)
}
// Verify the change was applied
updatedUser, err := backend.GetBy(user2.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if updatedUser.OtpEnabled {
t.Error("OtpEnabled should have been updated to false")
}
}
func TestUpdate_WhichAllVsSpecific(t *testing.T) {
backend := createTestUsersBackend(t)
// Create regular user
regularUser := createTestUser(t, backend, "regular", false)
// Test which=all
updateDataAll := &users.User{
ID: regularUser.ID,
Username: regularUser.Username,
NonAdminEditable: users.NonAdminEditable{
DarkMode: true,
OtpEnabled: false,
Locale: "fr",
ViewMode: "list",
},
}
err := backend.Update(updateDataAll, false, "all")
if err != nil {
t.Errorf("which=all should work: %v", err)
}
// Verify changes
updatedUser, err := backend.GetBy(regularUser.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if !updatedUser.DarkMode || updatedUser.OtpEnabled || updatedUser.Locale != "fr" || updatedUser.ViewMode != "list" {
t.Error("which=all should update all allowed fields")
}
// Reset user
regularUser = createTestUser(t, backend, "regular2", false)
// Test specific fields
updateDataSpecific := &users.User{
ID: regularUser.ID,
Username: regularUser.Username,
NonAdminEditable: users.NonAdminEditable{
DarkMode: true,
Locale: "de",
},
}
err = backend.Update(updateDataSpecific, false, "DarkMode", "Locale")
if err != nil {
t.Errorf("specific fields should work: %v", err)
}
// Verify only specified fields were updated
updatedUser2, err := backend.GetBy(regularUser.ID)
if err != nil {
t.Fatalf("Failed to get updated user: %v", err)
}
if !updatedUser2.DarkMode {
t.Error("DarkMode should have been updated")
}
if updatedUser2.Locale != "de" {
t.Error("Locale should have been updated")
}
if updatedUser2.ViewMode != "normal" { // Should remain default
t.Error("ViewMode should not have been updated")
}
}
func TestUpdate_EmptyFieldsAfterFiltering(t *testing.T) {
backend := createTestUsersBackend(t)
// Create regular user
regularUser := createTestUser(t, backend, "regular", false)
// Non-admin tries to update only restricted fields
updateData := &users.User{
ID: regularUser.ID,
Username: regularUser.Username,
Permissions: users.Permissions{
Admin: true, // This will be filtered out
},
Scopes: []users.SourceScope{ // This will be filtered out
{Name: "test", Scope: "/test"},
},
}
// This should result in "no fields to update" error
err := backend.Update(updateData, false, "Permissions", "Scopes")
if err == nil {
t.Error("Should get error when all fields are filtered out")
}
if err.Error() != "no fields to update" {
t.Errorf("Expected 'no fields to update' error, got: %v", err)
}
}
func TestFilterRestrictedFields(t *testing.T) {
// Test the filterRestrictedFields function directly
testCases := []struct {
name string
fields []string
expected []string
}{
{
name: "all allowed fields",
fields: []string{"DarkMode", "OtpEnabled", "Locale", "ViewMode"},
expected: []string{"DarkMode", "OtpEnabled", "Locale", "ViewMode"},
},
{
name: "mixed allowed and restricted",
fields: []string{"DarkMode", "Permissions", "Scopes", "OtpEnabled"},
expected: []string{"DarkMode", "OtpEnabled"},
},
{
name: "all restricted fields",
fields: []string{"Permissions", "Scopes", "Username", "ID"},
expected: []string{},
},
{
name: "empty fields",
fields: []string{},
expected: []string{},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := filterRestrictedFields(tc.fields)
if len(result) != len(tc.expected) {
t.Errorf("Expected %d fields, got %d", len(tc.expected), len(result))
}
for i, field := range result {
if field != tc.expected[i] {
t.Errorf("Expected field %s at position %d, got %s", tc.expected[i], i, field)
}
}
})
}
}
+12 -2
View File
@@ -7,6 +7,7 @@ import (
"strings"
storm "github.com/asdine/storm/v3"
"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/database/storage/bolt"
@@ -41,6 +42,11 @@ func InitializeDb(path string) (*bolt.BoltStore, bool, error) {
return nil, exists, err
}
if !exists {
if settings.Env.IsPlaywright || settings.Env.IsDevMode {
settings.Env.IsFirstLoad = false
} else {
settings.Env.IsFirstLoad = true
}
quickSetup(store)
}
@@ -57,7 +63,7 @@ func dbExists(path string) (bool, error) {
d := filepath.Dir(path)
_, err = os.Stat(d)
if os.IsNotExist(err) {
if err := os.MkdirAll(d, 0700); err != nil { //nolint:govet,gomnd
if err = os.MkdirAll(d, fileutils.PermDir); err != nil {
return false, err
}
return false, nil
@@ -93,6 +99,7 @@ func quickSetup(store *bolt.BoltStore) {
}
user.LockPassword = false
user.Permissions = settings.AdminPerms()
user.ShowFirstLogin = settings.Env.IsFirstLoad && user.Permissions.Admin
logger.Debugf("Creating user as admin: %v %v", user.Username, user.Password)
err = store.Users.Save(user, true, true)
utils.CheckErr("store.Users.Save", err)
@@ -100,8 +107,9 @@ func quickSetup(store *bolt.BoltStore) {
}
// create new user
func CreateUser(userInfo users.User) error {
func CreateUser(userInfo users.User, permissions users.Permissions) error {
newUser := &userInfo
newUser.ShowFirstLogin = settings.Env.IsFirstLoad && newUser.Permissions.Admin
if userInfo.LoginMethod == "password" {
if userInfo.Password == "" {
return fmt.Errorf("password is required to create a password login user")
@@ -117,6 +125,8 @@ func CreateUser(userInfo users.User) error {
if userInfo.Username == "" {
return fmt.Errorf("username is required to create a user")
}
settings.ApplyUserDefaults(newUser)
newUser.Permissions = permissions
logger.Debugf("Creating user: %v %v", userInfo.Username, userInfo.Scopes)
// create new home directories
err := userStore.Save(newUser, true, false)
+72
View File
@@ -0,0 +1,72 @@
package users
import (
"reflect"
"testing"
)
func TestNonAdminEditableContainsOtpEnabled(t *testing.T) {
// Test that OtpEnabled is included in NonAdminEditable struct
structType := reflect.TypeOf(NonAdminEditable{})
// Check if OtpEnabled field exists
otpEnabledField, exists := structType.FieldByName("OtpEnabled")
if !exists {
t.Fatal("OtpEnabled field should exist in NonAdminEditable struct")
}
// Check the JSON tag
expectedTag := "otpEnabled"
if otpEnabledField.Tag.Get("json") != expectedTag {
t.Errorf("Expected JSON tag '%s', got '%s'", expectedTag, otpEnabledField.Tag.Get("json"))
}
// Check the field type
if otpEnabledField.Type.Kind() != reflect.Bool {
t.Errorf("Expected OtpEnabled to be bool, got %s", otpEnabledField.Type.Kind())
}
}
func TestGetNonAdminEditableFieldNames(t *testing.T) {
// This is a helper function that should be tested
// We'll test it indirectly by checking if OtpEnabled is in the list
names := getNonAdminEditableFieldNames()
// Check that OtpEnabled is in the list
found := false
for _, name := range names {
if name == "OtpEnabled" {
found = true
break
}
}
if !found {
t.Error("OtpEnabled should be in the list of non-admin editable field names")
}
// Check that some other expected fields are present
expectedFields := []string{"DarkMode", "Locale", "ViewMode", "SingleClick"}
for _, expected := range expectedFields {
found := false
for _, name := range names {
if name == expected {
found = true
break
}
}
if !found {
t.Errorf("Expected field %s should be in non-admin editable fields", expected)
}
}
}
// Helper function to get field names (copied from bolt/users.go for testing)
func getNonAdminEditableFieldNames() []string {
var names []string
t := reflect.TypeOf(NonAdminEditable{})
for i := 0; i < t.NumField(); i++ {
names = append(names, t.Field(i).Name)
}
return names
}
+60 -41
View File
@@ -27,11 +27,14 @@ type AuthToken struct {
}
type Permissions struct {
Api bool `json:"api"`
Admin bool `json:"admin"`
Modify bool `json:"modify"`
Share bool `json:"share"`
Realtime bool `json:"realtime"`
Api bool `json:"api"` // allow api access
Admin bool `json:"admin"` // allow admin access
Modify bool `json:"modify"` // allow modifying files
Share bool `json:"share"` // allow sharing files
Realtime bool `json:"realtime"` // allow realtime updates
Delete bool `json:"delete"` // allow deleting files
Create bool `json:"create"` // allow creating or uploading files
Download bool `json:"download"` // allow downloading files
}
// SortingSettings represents the sorting settings.
@@ -42,14 +45,15 @@ type Sorting struct {
type Preview struct {
DisableHideSidebar bool `json:"disableHideSidebar"` // disable the hide sidebar preview for previews and editors
HighQuality bool `json:"highQuality"` // generate high quality preview images
Image bool `json:"image"` // show real image as icon instead of generic photo icon
Video bool `json:"video"` // show preview image for video files
MotionVideoPreview bool `json:"motionVideoPreview"` // show multiple frames for videos in preview when hovering
Office bool `json:"office"` // show preview image for office files
PopUp bool `json:"popup"` // show larger popup preview when hovering
HighQuality bool `json:"highQuality"` // generate high quality thumbnail preview images
Image bool `json:"image"` // show thumbnail preview image for image files
Video bool `json:"video"` // show thumbnail preview image for video files
MotionVideoPreview bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
Office bool `json:"office"` // show thumbnail preview image for office files
PopUp bool `json:"popup"` // show larger popup preview when hovering over thumbnail
AutoplayMedia bool `json:"autoplayMedia"` // autoplay media files in preview
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable html5 media player and use the default media player
Folder bool `json:"folder"` // show thumbnail preview image for folder files
}
// User describes a user.
@@ -68,7 +72,9 @@ type User struct {
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"` // true if TOTP is enabled, false otherwise
// legacy for migration purposes... og filebrowser has perm attribute
Perm Permissions `json:"perm,omitzero"`
Perm Permissions `json:"perm,omitzero"`
Version int `json:"version"`
ShowFirstLogin bool `json:"showFirstLogin"`
}
type SourceScope struct {
@@ -78,38 +84,51 @@ type SourceScope struct {
// json tags must match variable name with smaller case first letter
type NonAdminEditable struct {
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
Preview Preview `json:"preview"`
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
DarkMode bool `json:"darkMode"` // should dark mode be enabled
Password string `json:"password,omitempty"`
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
Sorting Sorting `json:"sorting"`
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications
FileLoading FileLoading `json:"fileLoading"` // upload and download settings
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // deprecated
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
Preview Preview `json:"preview"`
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
DarkMode bool `json:"darkMode"` // should dark mode be enabled
Password string `json:"password,omitempty"`
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
Sorting Sorting `json:"sorting"`
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications
FileLoading FileLoading `json:"fileLoading"` // upload and download settings
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // deprecated
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
DefaultLandingPage string `json:"defaultLandingPage"` // deprecated: determined by sidebar link order instead
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
}
type FileLoading struct {
MaxConcurrent int `json:"maxConcurrentUpload"`
ChunkSize int `json:"uploadChunkSizeMb"`
MaxConcurrent int `json:"maxConcurrentUpload"`
ChunkSize int `json:"uploadChunkSizeMb"`
ClearAll bool `json:"clearAll"`
}
// SidebarLink represents a customizable link in the sidebar.
type SidebarLink struct {
Name string `json:"name"` // Display name of the link
Category string `json:"category"` // Category type: "source", "share", "tool", "custom", etc.
Target string `json:"target"` // Target path/URL for the link (relative for source/share)
Icon string `json:"icon"` // Material icon name
SourceName string `json:"sourceName,omitempty"` // Source identifier for source-type links
}
func CleanUsername(s string) string {
+4646
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+18 -6
View File
@@ -75,7 +75,12 @@ func Register(username string, sources []string) chan EventMessage {
func Unregister(username string, ch chan EventMessage) {
userClientsMu.Lock()
conns := userClients[username]
defer userClientsMu.Unlock()
conns, ok := userClients[username]
if !ok {
// User already cleaned up by Shutdown, nothing to do.
return
}
for i, c := range conns {
if c == ch {
userClients[username] = append(conns[:i], conns[i+1:]...)
@@ -85,18 +90,15 @@ func Unregister(username string, ch chan EventMessage) {
if len(userClients[username]) == 0 {
delete(userClients, username)
}
userClientsMu.Unlock()
sourceClientsMu.Lock()
defer sourceClientsMu.Unlock()
for source, clients := range sourceClients {
delete(clients, ch)
if len(clients) == 0 {
delete(sourceClients, source)
}
}
sourceClientsMu.Unlock()
close(ch)
}
func SendToUsers(eventType, message string, users []string) {
@@ -142,10 +144,20 @@ func handleSourceUpdates() {
func Shutdown() {
userClientsMu.Lock()
defer userClientsMu.Unlock()
sourceClientsMu.Lock()
defer sourceClientsMu.Unlock()
for _, clientChannels := range userClients {
for username, clientChannels := range userClients {
for _, ch := range clientChannels {
// Clean up source clients
for source, clients := range sourceClients {
delete(clients, ch)
if len(clients) == 0 {
delete(sourceClients, source)
}
}
close(ch)
}
delete(userClients, username)
}
}
+13
View File
@@ -0,0 +1,13 @@
package ffmpeg
import (
"time"
"github.com/gtsteffaniak/go-cache/cache"
)
var (
MediaCache = cache.NewCache[[]SubtitleTrack](24 * time.Hour) // subtitles get cached for 24 hours
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // subtitle content gets cached for 24 hours
MetadataCache = cache.NewCache[float64](24 * time.Hour) // media duration gets cached for 24 hours
)
+380
View File
@@ -0,0 +1,380 @@
package ffmpeg
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
)
// GetImageOrientation extracts the EXIF orientation from an image file using exiftool
func (s *FFmpegService) GetImageOrientation(imagePath string) (string, error) {
// Use exiftool to get orientation information
cmd := exec.Command("exiftool", "-Orientation", "-s3", imagePath)
var out bytes.Buffer
var errOut bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errOut
if err := cmd.Run(); err != nil {
return "Horizontal (normal)", nil // Default to normal orientation
}
orientation := strings.TrimSpace(out.String())
if orientation == "" {
orientation = "Horizontal (normal)" // Default if no orientation found
}
return orientation, nil
}
// GetOrientationFilter converts EXIF orientation to FFmpeg filter
func (s *FFmpegService) GetOrientationFilter(orientation string) string {
switch orientation {
// Text-based EXIF orientation values (from exiftool)
case "Rotate 90 CW", "Right-top":
return ",transpose=1" // 90° clockwise
case "Rotate 180", "Bottom-right":
return ",transpose=1,transpose=1" // 180° (two 90° rotations)
case "Rotate 270 CW", "Left-bottom":
return ",transpose=2" // 90° counter-clockwise
case "Mirror horizontal", "Top-right":
return ",hflip" // Horizontal flip
case "Mirror vertical", "Bottom-left":
return ",vflip" // Vertical flip (upside down)
case "Mirror horizontal and rotate 270 CW", "Right-bottom":
return ",transpose=0" // 90° counter-clockwise + horizontal flip
case "Mirror horizontal and rotate 90 CW", "Left-top":
return ",transpose=3" // 90° clockwise + horizontal flip
case "Horizontal (normal)", "Top-left":
return "" // No rotation needed
// Numeric EXIF orientation values (standard EXIF specification)
case "1":
return "" // Top-left (normal)
case "2":
return ",hflip" // Top-right (flip horizontal)
case "3":
return ",transpose=1,transpose=1" // Bottom-right (rotate 180)
case "4":
return ",vflip" // Bottom-left (flip vertical)
case "5":
return ",transpose=3" // Left-top (transpose: 90° CW + horizontal flip)
case "6":
return ",transpose=1" // Right-top (rotate 90 CW)
case "7":
return ",transpose=0" // Right-bottom (transverse: 90° CCW + horizontal flip)
case "8":
return ",transpose=2" // Left-bottom (rotate 270 CW / 90° CCW)
default:
return "" // Default to no rotation for unknown orientations
}
}
// GetImageDimensions extracts the dimensions of an image file using ffprobe
func (s *FFmpegService) GetImageDimensions(imagePath string) (width, height int, err error) {
// Get HEIC dimensions (fallback to individual stream dimensions for nowe re
probeCmd := exec.Command(
s.ffprobePath,
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "csv=p=0:s=x",
imagePath,
)
var probeOut bytes.Buffer
var probeErr bytes.Buffer
probeCmd.Stdout = &probeOut
probeCmd.Stderr = &probeErr
if err = probeCmd.Run(); err != nil {
return 0, 0, fmt.Errorf("ffprobe command failed on image file '%s': %w", imagePath, err)
}
dimensions := strings.TrimSpace(probeOut.String())
// Handle cases where ffprobe returns multiple lines or extra characters
// Take the first line and clean it up
lines := strings.Split(dimensions, "\n")
cleanDimensions := strings.TrimSpace(lines[0])
// Remove any trailing 'x' characters that might appear
cleanDimensions = strings.TrimSuffix(cleanDimensions, "x")
parts := strings.Split(cleanDimensions, "x")
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid dimensions format: %s", cleanDimensions)
}
width, err = strconv.Atoi(parts[0])
if err != nil {
return 0, 0, fmt.Errorf("invalid width: %w", err)
}
height, err = strconv.Atoi(parts[1])
if err != nil {
return 0, 0, fmt.Errorf("invalid height: %w", err)
}
return width, height, nil
}
// ConvertHEICToJPEGDirect converts a HEIC file to JPEG using direct FFmpeg conversion (fast method)
func (s *FFmpegService) ConvertHEICToJPEGDirect(ctx context.Context, heicPath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
// Check if context is cancelled before starting
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Get EXIF orientation and create appropriate filter
orientation, err := s.GetImageOrientation(heicPath)
if err != nil {
orientation = "Horizontal (normal)"
}
orientationFilter := s.GetOrientationFilter(orientation)
// Create temporary output file
outputDir := s.cacheDir
err = os.MkdirAll(outputDir, fileutils.PermDir)
if err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
outputFile := filepath.Join(outputDir, fmt.Sprintf("heic_direct_%d.jpg", os.Getpid()))
defer os.Remove(outputFile)
// Build FFmpeg command for direct conversion
args := []string{"-i", heicPath}
// Build video filter chain with scaling and orientation
var filterChain string
if targetWidth > 0 && targetHeight > 0 {
filterChain = fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease%s", targetWidth, targetHeight, orientationFilter)
} else {
// For original size, only apply orientation if needed
if orientationFilter != "" {
filterChain = orientationFilter[1:] // Remove leading comma
}
}
// Add video filter if we have one
if filterChain != "" {
args = append(args, "-vf", filterChain)
}
// Add quality and output settings
args = append(args, "-q:v", quality, "-pix_fmt", "yuvj420p", "-y", outputFile)
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)
}
// Read the converted file
jpegBytes, err := os.ReadFile(outputFile)
if err != nil {
return nil, fmt.Errorf("failed to read converted JPEG: %w", err)
}
return jpegBytes, nil
}
// ConvertHEICToJPEG converts a HEIC file to JPEG with specified dimensions and quality using proper tile extraction
func (s *FFmpegService) ConvertHEICToJPEG(ctx context.Context, heicPath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
// Check if context is cancelled before starting
if ctx.Err() != nil {
return nil, ctx.Err()
}
// Create temporary directory for tile processing
tempDir, err := os.MkdirTemp(s.cacheDir, "heic_convert")
if err != nil {
return nil, fmt.Errorf("failed to create temp directory: %w", err)
}
defer os.RemoveAll(tempDir)
// Step 1: Get grid info using trace output
// Use ffprobe with trace level to get grid information (with time limit for speed)
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
gridCmd.Stderr = &gridErr
_ = gridCmd.Run() // Don't check error, we're just extracting info
traceOutput := gridErr.String()
// Parse grid dimensions from trace output
var gridCols, gridRows = 8, 6 // Default fallback
var val int
// Look for grid_row and grid_col in trace output
lines := strings.Split(traceOutput, "\n")
for _, line := range lines {
if strings.Contains(line, "grid_row") {
// Try to extract grid_row value
if parts := strings.Split(line, "grid_row"); len(parts) > 1 {
if matches := strings.Fields(parts[1]); len(matches) > 0 {
if val, err = strconv.Atoi(strings.Trim(matches[0], " :=")); err == nil {
gridRows = val + 1 // HEIC grid_row is 0-based
}
}
}
}
if strings.Contains(line, "grid_col") {
// Try to extract grid_col value
if parts := strings.Split(line, "grid_col"); len(parts) > 1 {
if matches := strings.Fields(parts[1]); len(matches) > 0 {
if val, err = strconv.Atoi(strings.Trim(matches[0], " :=")); err == nil {
gridCols = val + 1 // HEIC grid_col is 0-based
}
}
}
}
}
// Get EXIF orientation and create appropriate filter
orientation, err := s.GetImageOrientation(heicPath)
if err != nil {
orientation = "Horizontal (normal)"
}
orientationFilter := s.GetOrientationFilter(orientation)
// 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.CommandContext(ctx,
s.ffmpegPath,
"-i", heicPath,
"-map", "0", // Map all streams
"-q:v", "3", // Medium quality for intermediate tiles
"-y",
tilesPattern,
)
var extractOut bytes.Buffer
var extractErr bytes.Buffer
extractCmd.Stdout = &extractOut
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)
}
// Step 3: Filter tiles (skip output_0.png which is compatibility image)
files, err := filepath.Glob(filepath.Join(tempDir, "output_*.jpg"))
if err != nil || len(files) == 0 {
return nil, fmt.Errorf("no tiles extracted, found %d files", len(files))
}
// Create filtered tiles, skipping output_0.jpg (compatibility image)
// and rename them to sequential tile_001.jpg, tile_002.jpg, etc.
tileIndex := 1
expectedTiles := gridCols * gridRows
for i := 1; i <= len(files); i++ { // Start from 1 to skip output_0.jpg
sourceFile := filepath.Join(tempDir, fmt.Sprintf("output_%d.jpg", i))
var info os.FileInfo
if info, err = os.Stat(sourceFile); err == nil && info.Size() > 500 {
// This is a valid tile, rename it to sequential format
targetFile := filepath.Join(tempDir, fmt.Sprintf("tile_%03d.jpg", tileIndex))
if err = os.Rename(sourceFile, targetFile); err != nil {
continue
}
tileIndex++
// Stop if we have enough tiles
if tileIndex > expectedTiles {
break
}
}
}
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")
// Build the video filter chain
inputPattern := filepath.Join(tempDir, "tile_%03d.jpg")
// Create filter chain: tile assembly + proper scaling
var filterChain string
// For original size (targetWidth=0, targetHeight=0), don't scale
if targetWidth == 0 && targetHeight == 0 {
// This is original size - no scaling, maximum quality
// Use padding=0:margin=0 to eliminate black borders between tiles
filterChain = fmt.Sprintf("tile=%dx%d:nb_frames=%d:padding=0:margin=0%s",
gridCols, gridRows, actualTiles, orientationFilter)
} else {
// This is a resize request - apply scaling with proper aspect handling
filterChain = fmt.Sprintf("tile=%dx%d:nb_frames=%d:padding=0:margin=0%s,scale=%d:%d:force_original_aspect_ratio=decrease:eval=frame",
gridCols, gridRows, actualTiles, orientationFilter, targetWidth, targetHeight)
}
mergeCmd := exec.CommandContext(ctx,
s.ffmpegPath,
"-i", inputPattern,
"-vf", filterChain,
"-frames:v", "1",
"-q:v", quality, // Use the quality setting passed in
"-pix_fmt", "yuvj420p", // Use full-range YUV for better JPEG compatibility
"-y",
outputFile,
)
var mergeOut bytes.Buffer
var mergeErr bytes.Buffer
mergeCmd.Stdout = &mergeOut
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)
}
// Step 5: Read final output
jpegBytes, err := os.ReadFile(outputFile)
if err != nil {
return nil, fmt.Errorf("failed to read merged JPEG: %w", err)
}
return jpegBytes, nil
}
+205
View File
@@ -0,0 +1,205 @@
package ffmpeg
import (
"os"
"path/filepath"
"testing"
)
func TestGetOrientationFilter(t *testing.T) {
service := &FFmpegService{}
tests := []struct {
name string
orientation string
expected string
}{
{
name: "Normal orientation",
orientation: "Horizontal (normal)",
expected: "",
},
{
name: "Normal orientation alternative",
orientation: "Top-left",
expected: "",
},
{
name: "Rotate 90 CW",
orientation: "Rotate 90 CW",
expected: ",transpose=1",
},
{
name: "Rotate 90 CW alternative",
orientation: "Right-top",
expected: ",transpose=1",
},
{
name: "Rotate 180",
orientation: "Rotate 180",
expected: ",transpose=1,transpose=1",
},
{
name: "Rotate 180 alternative",
orientation: "Bottom-right",
expected: ",transpose=1,transpose=1",
},
{
name: "Rotate 270 CW",
orientation: "Rotate 270 CW",
expected: ",transpose=2",
},
{
name: "Rotate 270 CW alternative",
orientation: "Left-bottom",
expected: ",transpose=2",
},
{
name: "Mirror horizontal",
orientation: "Mirror horizontal",
expected: ",hflip",
},
{
name: "Mirror horizontal alternative",
orientation: "Top-right",
expected: ",hflip",
},
{
name: "Mirror vertical",
orientation: "Mirror vertical",
expected: ",vflip",
},
{
name: "Mirror vertical alternative",
orientation: "Bottom-left",
expected: ",vflip",
},
{
name: "Mirror horizontal and rotate 270 CW",
orientation: "Mirror horizontal and rotate 270 CW",
expected: ",transpose=0",
},
{
name: "Mirror horizontal and rotate 270 CW alternative",
orientation: "Right-bottom",
expected: ",transpose=0",
},
{
name: "Mirror horizontal and rotate 90 CW",
orientation: "Mirror horizontal and rotate 90 CW",
expected: ",transpose=3",
},
{
name: "Mirror horizontal and rotate 90 CW alternative",
orientation: "Left-top",
expected: ",transpose=3",
},
{
name: "Unknown orientation",
orientation: "Unknown Orientation",
expected: "",
},
{
name: "Empty orientation",
orientation: "",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := service.GetOrientationFilter(tt.orientation)
if result != tt.expected {
t.Errorf("GetOrientationFilter(%q) = %q, want %q", tt.orientation, result, tt.expected)
}
})
}
}
func TestGetImageOrientation(t *testing.T) {
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "ffmpeg_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
service := NewFFmpegService(2, false, "/tmp/test_cache")
t.Run("Non-existent file", func(t *testing.T) {
nonExistentFile := filepath.Join(tempDir, "non_existent.heic")
orientation, err := service.GetImageOrientation(nonExistentFile)
// Should return default orientation when exiftool fails
if err != nil {
t.Errorf("Expected no error for non-existent file, got: %v", err)
}
if orientation != "Horizontal (normal)" {
t.Errorf("Expected default orientation 'Horizontal (normal)', got: %q", orientation)
}
})
t.Run("Empty file path", func(t *testing.T) {
orientation, err := service.GetImageOrientation("")
// Should return default orientation when exiftool fails
if err != nil {
t.Errorf("Expected no error for empty file path, got: %v", err)
}
if orientation != "Horizontal (normal)" {
t.Errorf("Expected default orientation 'Horizontal (normal)', got: %q", orientation)
}
})
}
// Benchmark the orientation filter generation
func BenchmarkGetOrientationFilter(b *testing.B) {
service := &FFmpegService{}
orientations := []string{
"Horizontal (normal)",
"Rotate 90 CW",
"Rotate 180",
"Mirror vertical",
"Unknown Orientation",
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
orientation := orientations[i%len(orientations)]
_ = service.GetOrientationFilter(orientation)
}
}
// Test table for all EXIF orientation values
func TestGetOrientationFilter_AllEXIFValues(t *testing.T) {
service := &FFmpegService{}
// Test all possible EXIF orientation values according to EXIF spec
exifTests := []struct {
exifValue string
description string
expectsTransformation bool
}{
{"1", "Top-left (normal)", false},
{"2", "Top-right (flip horizontal)", true},
{"3", "Bottom-right (rotate 180)", true},
{"4", "Bottom-left (flip vertical)", true},
{"5", "Left-top (transpose)", true},
{"6", "Right-top (rotate 90 CW)", true},
{"7", "Right-bottom (transverse)", true},
{"8", "Left-bottom (rotate 270 CW)", true},
}
for _, tt := range exifTests {
t.Run(tt.description, func(t *testing.T) {
filter := service.GetOrientationFilter(tt.exifValue)
if tt.expectsTransformation && filter == "" {
t.Errorf("Expected transformation for EXIF value %s (%s), but got empty filter", tt.exifValue, tt.description)
}
if !tt.expectsTransformation && filter != "" {
t.Errorf("Expected no transformation for EXIF value %s (%s), but got filter: %s", tt.exifValue, tt.description, filter)
}
})
}
}
+68
View File
@@ -0,0 +1,68 @@
package ffmpeg
import (
"context"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
)
// GetMediaDuration extracts the duration of a media file (audio or video) using ffprobe
// This method respects the concurrency limits, uses caching, and gracefully handles errors
// Cache key is based on file path + mod time for automatic cache invalidation on file changes
func (s *FFmpegService) GetMediaDuration(ctx context.Context, mediaPath string) (float64, error) {
// Get file info for cache key (path + mod time)
fileInfo, err := os.Stat(mediaPath)
if err != nil {
return 0, fmt.Errorf("failed to stat file: %w", err)
}
// Create cache key from path and modification time
cacheKey := fmt.Sprintf("%s:%d", mediaPath, fileInfo.ModTime().Unix())
// Check cache first
if duration, found := MetadataCache.Get(cacheKey); found {
return duration, nil
}
// Acquire semaphore slot for concurrency control
if err = s.Acquire(ctx); err != nil {
return 0, err
}
defer s.Release()
// Check if context is cancelled before starting
if ctx.Err() != nil {
return 0, ctx.Err()
}
cmd := exec.CommandContext(ctx,
s.ffprobePath,
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
mediaPath,
)
output, err := cmd.Output()
if err != nil {
MetadataCache.Set(cacheKey, 0)
return 0, fmt.Errorf("ffprobe failed: %w", err)
}
durationStr := strings.TrimSpace(string(output))
if durationStr == "" || durationStr == "N/A" {
MetadataCache.Set(cacheKey, 0)
return 0, fmt.Errorf("could not determine media duration")
}
duration, err := strconv.ParseFloat(durationStr, 64)
if err != nil {
MetadataCache.Set(cacheKey, 0)
return 0, fmt.Errorf("invalid duration: %v", err)
}
// Cache successful result
MetadataCache.Set(cacheKey, duration)
return duration, nil
}
+104
View File
@@ -0,0 +1,104 @@
package ffmpeg
import (
"context"
"os/exec"
"path/filepath"
"runtime"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/go-logger/logger"
)
// VideoService handles video preview operations with ffmpeg
type FFmpegService struct {
ffmpegPath string
ffprobePath string
debug bool
semaphore chan struct{}
cacheDir string
maxConcurrent int // For logging purposes
}
// NewVideoService creates a new video service instance
func NewFFmpegService(maxConcurrent int, debug bool, cacheDir string) *FFmpegService {
if settings.Env.FFmpegPath == "" || settings.Env.FFprobePath == "" {
return nil
}
return &FFmpegService{
ffmpegPath: settings.Env.FFmpegPath,
ffprobePath: settings.Env.FFprobePath,
debug: debug,
semaphore: make(chan struct{}, maxConcurrent),
maxConcurrent: maxConcurrent,
cacheDir: cacheDir,
}
}
func (s *FFmpegService) Acquire(ctx context.Context) error {
select {
case s.semaphore <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *FFmpegService) Release() {
<-s.semaphore
}
// CheckValidFFmpeg checks for a valid ffmpeg executable.
// If a path is provided, it looks there. Otherwise, it searches the system's PATH.
func CheckValidFFmpeg(path string) (string, error) {
return checkExecutable(path, "ffmpeg")
}
// CheckValidFFprobe checks for a valid ffprobe executable.
// If a path is provided, it looks there. Otherwise, it searches the system's PATH.
func CheckValidFFprobe(path string) (string, error) {
return checkExecutable(path, "ffprobe")
}
// checkExecutable is an internal helper function to find and validate an executable.
// It checks a specific path if provided, otherwise falls back to searching the system PATH.
func checkExecutable(providedPath, execName string) (string, error) {
// Add .exe extension for Windows systems
if runtime.GOOS == "windows" {
execName += ".exe"
}
var finalPath string
var err error
if providedPath != "" {
// A path was provided, so we'll use it.
finalPath = filepath.Join(providedPath, execName)
} else {
// No path was provided, so search the system's PATH for the executable.
finalPath, err = exec.LookPath(execName)
if err != nil {
// The executable was not found in the system's PATH.
return "", err
}
}
// Verify the executable is valid by running the "-version" command.
cmd := exec.Command(finalPath, "-version")
err = cmd.Run()
return finalPath, err
}
func SetFFmpegPaths() {
ffmpegMainPath, err := CheckValidFFmpeg(settings.Env.FFmpegPath)
if err != nil && settings.Env.FFmpegPath != "" {
logger.Warningf("the configured ffmpeg path does not contain a valid ffmpeg binary %s, err: %v", settings.Env.FFmpegPath, err)
}
ffprobePath, errprobe := CheckValidFFprobe(settings.Env.FFprobePath)
if errprobe != nil && settings.Env.FFprobePath != "" {
logger.Warningf("the configured ffmpeg path is not a valid ffprobe binary %s, err: %v", settings.Env.FFprobePath, err)
}
settings.Env.FFmpegPath = ffmpegMainPath
settings.Env.FFprobePath = ffprobePath
}
+5 -5
View File
@@ -10,7 +10,6 @@ import (
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -41,7 +40,7 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
key := "all_subtitles:" + videoPath + ":" + modtime.Format(time.RFC3339)
// Check cache first
if cached, ok := utils.MediaCache.Get(key).([]SubtitleTrack); ok {
if cached, ok := MediaCache.Get(key); ok {
return cached
}
@@ -56,12 +55,13 @@ func DetectAllSubtitles(videoPath string, parentDir string, modtime time.Time) [
allSubtitles = append(allSubtitles, externalSubs...)
// Cache the complete list
utils.MediaCache.Set(key, allSubtitles)
MediaCache.Set(key, allSubtitles)
return allSubtitles
}
// 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",
@@ -252,7 +252,7 @@ func LoadAllSubtitleContent(videoPath string, subtitles []SubtitleTrack, modtime
// Check if content is already cached
contentKey := fmt.Sprintf("subtitle_content:%s:%d:%s", videoPath, idx, modtime.Format(time.RFC3339))
if cached, ok := utils.MediaCache.Get(contentKey).(string); ok {
if cached, ok := SubtitleContentCache.Get(contentKey); ok {
subtitle.Content = cached
continue
}
@@ -283,7 +283,7 @@ func LoadAllSubtitleContent(videoPath string, subtitles []SubtitleTrack, modtime
subtitle.Content = content
// Cache the content for future requests
utils.MediaCache.Set(contentKey, content)
SubtitleContentCache.Set(contentKey, content)
}
return nil
}
+52 -85
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"strconv"
@@ -12,52 +13,28 @@ import (
"github.com/gtsteffaniak/go-logger/logger"
)
// VideoService handles video preview operations with ffmpeg
type VideoService struct {
ffmpegPath string
ffprobePath string
debug bool
semaphore chan struct{}
}
// 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),
// 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 *FFmpegService) GenerateVideoPreviewStreaming(ctx context.Context, videoPath string, percentageSeek int, writer io.Writer) error {
if err := s.Acquire(ctx); err != nil {
return err
}
}
defer s.Release()
func (s *VideoService) acquire(ctx context.Context) error {
select {
case s.semaphore <- struct{}{}:
return nil
case <-ctx.Done():
// Check if context is cancelled before starting
if ctx.Err() != nil {
return ctx.Err()
}
}
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").
// seekTime: how many seconds into the video to seek before capturing the frame.
func (s *VideoService) GenerateVideoPreview(videoPath, outputPath string, percentageSeek int) ([]byte, error) {
if err := s.acquire(context.Background()); err != nil {
return nil, err
// Validate percentage parameter
if percentageSeek < 0 || percentageSeek > 100 {
percentageSeek = 10 // Default to 10% if invalid
}
defer s.release()
// Step 1: Get video duration from the container format
probeCmd := exec.Command(
probeCmd := exec.CommandContext(ctx,
s.ffprobePath,
"-v", "error",
// Use format=duration for better compatibility
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
videoPath,
@@ -69,79 +46,69 @@ func (s *VideoService) GenerateVideoPreview(videoPath, outputPath string, percen
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)
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)
}
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")
return fmt.Errorf("could not determine video duration")
}
durationFloat, err := strconv.ParseFloat(durationStr, 64)
if err != nil {
// The original error you saw would be caught here if "N/A" was still the output
return nil, fmt.Errorf("invalid duration: %v", err)
return fmt.Errorf("invalid duration: %v", err)
}
if durationFloat <= 0 {
return nil, fmt.Errorf("video duration must be positive")
return fmt.Errorf("video duration must be positive")
}
// Step 2: Get the duration of the video in whole seconds
duration := int(durationFloat)
// 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: Calculate seek time based on percentageSeek (percentage value)
seekSeconds := duration * percentageSeek / 100
// Step 4: Convert seekSeconds to string for ffmpeg command
seekTime := strconv.Itoa(seekSeconds)
// Step 5: Extract frame at seek time
cmd := exec.Command(
// Step 3: Extract frame and stream directly to writer
cmd := exec.CommandContext(ctx,
s.ffmpegPath,
"-ss", seekTime,
"-ss", seekTimeStr, // Use precise seek time
"-i", videoPath,
"-frames:v", "1",
"-q:v", "10",
"-y", // overwrite output
outputPath,
"-q:v", "10", // Good quality/size balance
"-f", "image2", // Explicitly specify image format
"-vcodec", "mjpeg", // Use MJPEG codec for better compression
"-", // Output to stdout
)
cmd.Stdout = writer
var stderrBuf bytes.Buffer
if s.debug {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
} else {
// Capture stderr for error logging
cmd.Stderr = &stderrBuf
}
err = cmd.Run()
if err != nil {
return nil, fmt.Errorf("ffmpeg command failed on file '%v' : %w", videoPath, err)
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 os.ReadFile(outputPath)
}
// GetVideoDuration extracts the duration of a video file using ffprobe
func GetVideoDuration(ffprobePath string, videoPath string) (float64, error) {
cmd := exec.Command(ffprobePath,
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
videoPath)
output, err := cmd.Output()
if err != nil {
return 0, fmt.Errorf("ffprobe failed: %w", err)
}
durationStr := strings.TrimSpace(string(output))
if durationStr == "" || durationStr == "N/A" {
return 0, fmt.Errorf("could not determine video duration")
}
duration, err := strconv.ParseFloat(durationStr, 64)
if err != nil {
return 0, fmt.Errorf("invalid duration: %v", err)
}
return duration, nil
return nil
}
+87 -76
View File
@@ -4,27 +4,28 @@ go 1.25.0
require (
github.com/asdine/storm/v3 v3.2.1
github.com/coreos/go-oidc/v3 v3.15.0
github.com/coreos/go-oidc/v3 v3.16.0
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/gen2brain/go-fitz v1.24.15
github.com/go-playground/validator/v10 v10.27.0
github.com/go-playground/validator/v10 v10.28.0
github.com/goccy/go-yaml v1.18.0
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/gtsteffaniak/go-cache v0.0.0-20250521142451-edc77dfcb063
github.com/gtsteffaniak/go-logger v0.1.2
github.com/kovidgoyal/imaging v1.6.4
github.com/gtsteffaniak/go-cache v1.0.0
github.com/gtsteffaniak/go-logger v0.2.3
github.com/kovidgoyal/imaging v1.7.2
github.com/pquerna/otp v1.5.0
github.com/spf13/afero v1.14.0
github.com/stretchr/testify v1.10.0
github.com/spf13/afero v1.15.0
github.com/stretchr/testify v1.11.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
golang.org/x/crypto v0.41.0
golang.org/x/image v0.30.0
golang.org/x/mod v0.27.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sys v0.35.0
golang.org/x/time v0.12.0
golang.org/x/crypto v0.45.0
golang.org/x/image v0.33.0
golang.org/x/mod v0.30.0
golang.org/x/oauth2 v0.33.0
golang.org/x/sys v0.38.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -36,17 +37,19 @@ require (
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
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/Abirdcfly/dupword v0.1.7 // 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/Masterminds/semver/v3 v3.4.0 // indirect
github.com/MirrexOne/unqueryvet v1.2.1 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.62.0 // indirect
github.com/air-verse/air v1.63.1 // indirect
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
@@ -54,8 +57,8 @@ require (
github.com/alfatraining/structtag v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/ashanbrown/forbidigo/v2 v2.1.0 // indirect
github.com/ashanbrown/makezero/v2 v2.0.1 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.0 // indirect
github.com/ashanbrown/makezero/v2 v2.1.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/godartsass/v2 v2.5.0 // indirect
@@ -63,16 +66,16 @@ 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.3.0 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
github.com/butuzov/ireturn v0.4.0 // indirect
github.com/butuzov/mirror v1.3.0 // indirect
github.com/catenacyber/perfsprint v0.9.1 // indirect
github.com/catenacyber/perfsprint v0.10.0 // indirect
github.com/ccojocar/zxcvbn-go v1.0.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.10 // indirect
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
@@ -80,7 +83,6 @@ require (
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.7 // indirect
github.com/dave/dst v0.27.3 // indirect
@@ -89,22 +91,28 @@ 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.1 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/ghostiam/protogetter v0.3.15 // indirect
github.com/go-critic/go-critic v0.13.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/ghostiam/protogetter v0.3.17 // indirect
github.com/go-critic/go-critic v0.14.2 // 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.21.2 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/jsonpointer v0.22.3 // indirect
github.com/go-openapi/jsonreference v0.21.3 // indirect
github.com/go-openapi/spec v0.22.1 // indirect
github.com/go-openapi/swag/conv v0.25.3 // indirect
github.com/go-openapi/swag/jsonname v0.25.3 // indirect
github.com/go-openapi/swag/jsonutils v0.25.3 // indirect
github.com/go-openapi/swag/loading v0.25.3 // indirect
github.com/go-openapi/swag/stringutils v0.25.3 // indirect
github.com/go-openapi/swag/typeutils v0.25.3 // indirect
github.com/go-openapi/swag/yamlutils v0.25.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
@@ -117,25 +125,27 @@ 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/gofrs/flock v0.12.1 // indirect
github.com/gohugoio/hugo v0.147.6 // indirect
github.com/golang/geo v0.0.0-20250825151631-54d70cc7cb31 // indirect
github.com/godoc-lint/godoc-lint v0.10.1 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gohugoio/hugo v0.149.1 // indirect
github.com/golang/geo v0.0.0-20251117194806-05dcfdd28b33 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.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.6.2 // indirect
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 // indirect
github.com/golangci/misspell v0.7.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
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
github.com/gostaticanalysis/nilerr v0.1.1 // indirect
github.com/gostaticanalysis/nilerr v0.1.2 // indirect
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
@@ -145,36 +155,36 @@ require (
github.com/jgautheron/goconst v1.8.2 // indirect
github.com/jingyugao/rowserrcheck v1.1.1 // indirect
github.com/jjti/go-spancheck v0.6.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/jupiterrider/ffi v0.5.1 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.1 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.9.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kulti/thelper v0.6.3 // indirect
github.com/kunwardeep/paralleltest v1.0.14 // indirect
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.15 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
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/exptostd v0.4.5 // indirect
github.com/ldez/gomoddirectives 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
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/macabu/inamedparam v0.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/manuelarte/embeddedstructfieldcheck v0.3.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
github.com/manuelarte/funcorder v0.5.0 // indirect
github.com/maratori/testableexamples v1.0.0 // indirect
github.com/maratori/testpackage v1.1.1 // indirect
github.com/maratori/testableexamples v1.0.1 // indirect
github.com/maratori/testpackage v1.1.2 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
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
@@ -182,7 +192,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.2 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -191,8 +201,8 @@ require (
github.com/prometheus/client_model v0.2.0 // indirect
github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect
github.com/quasilyte/go-ruleguard v0.4.4 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.22 // indirect
github.com/quasilyte/go-ruleguard v0.4.5 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
@@ -200,30 +210,30 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
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.10 // 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.8.0 // indirect
github.com/spf13/cobra v1.9.1 // indirect
github.com/spf13/cast v1.9.2 // 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
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/tdakkota/asciicheck v0.4.1 // indirect
github.com/tdewolff/parse/v2 v2.8.1 // indirect
github.com/tetafro/godot v1.5.1 // indirect
github.com/tdewolff/parse/v2 v2.8.3 // 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
@@ -239,25 +249,26 @@ 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/arangolint v0.3.1 // indirect
go.augendre.info/fatcontext v0.9.0 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/tools v0.36.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/tools v0.39.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.6.1 // indirect
mvdan.cc/gofumpt v0.8.0 // indirect
mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
+220 -181
View File
@@ -45,16 +45,18 @@ dev.gaijin.team/go/golib v0.6.0/go.mod h1:uY1mShx8Z/aNHWDyAkZTkX+uCi5PdX7KsG1eDQ
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
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/Abirdcfly/dupword v0.1.7 h1:2j8sInznrje4I0CMisSL6ipEBkeJUJAmK1/lfoNGWrQ=
github.com/Abirdcfly/dupword v0.1.7/go.mod h1:K0DkBeOebJ4VyOICFdppB23Q0YMOgVafM0zYW0n9lF4=
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/nilnil v1.1.0 h1:jGxJxjgYS3VUUtOTNk8Z1icwT5ESpLH/426fjmQG+ng=
github.com/Antonboom/nilnil v1.1.0/go.mod h1:b7sAlogQjFa1wV8jUW3o4PMzDVFLbTux+xnQdvzdcIE=
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/errname v1.1.1 h1:bllB7mlIbTVzO9jmSWVWLjxTEbGBVQ1Ff/ClQgtPw9Q=
github.com/Antonboom/errname v1.1.1/go.mod h1:gjhe24xoxXp0ScLtHzjiXp0Exi1RFLKJb0bVBtWKCWQ=
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.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=
@@ -63,18 +65,20 @@ github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
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/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/MirrexOne/unqueryvet v1.2.1 h1:M+zdXMq84g+E1YOLa7g7ExN3dWfZQrdDSTCM7gC+m/A=
github.com/MirrexOne/unqueryvet v1.2.1/go.mod h1:IWwCwMQlSWjAIteW0t+28Q5vouyktfujzYznSIWiuOg=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/air-verse/air v1.62.0 h1:6CoXL4MAX9dc4xAzLfjMcDfbBoGmW5VjuuTV/1+bI+M=
github.com/air-verse/air v1.62.0/go.mod h1:EO+jWuetL10tS9raffwg8WEV0t0KUeucRRaf9ii86dA=
github.com/air-verse/air v1.63.1 h1:N6kD5niKKVx0wF2mW0mgK6LNfJqP5/lCAqm3WWl9vlw=
github.com/air-verse/air v1.63.1/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
@@ -102,12 +106,14 @@ github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c h1:651/eoCRnQ7YtS
github.com/armon/go-radix v1.0.1-0.20221118154546-54df44f2176c/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/asdine/storm/v3 v3.2.1 h1:I5AqhkPK6nBZ/qJXySdI7ot5BlXSZ7qvDY1zAn5ZJac=
github.com/asdine/storm/v3 v3.2.1/go.mod h1:LEpXwGt4pIqrE/XcTvCnZHT5MgZCV6Ub9q7yQzOFWr0=
github.com/ashanbrown/forbidigo/v2 v2.1.0 h1:NAxZrWqNUQiDz19FKScQ/xvwzmij6BiOw3S0+QUQ+Hs=
github.com/ashanbrown/forbidigo/v2 v2.1.0/go.mod h1:0zZfdNAuZIL7rSComLGthgc/9/n2FqspBOH90xlCHdA=
github.com/ashanbrown/makezero/v2 v2.0.1 h1:r8GtKetWOgoJ4sLyUx97UTwyt2dO7WkGFHizn/Lo8TY=
github.com/ashanbrown/makezero/v2 v2.0.1/go.mod h1:kKU4IMxmYW1M4fiEHMb2vc5SFoPzXvgbMR9gIp5pjSw=
github.com/ashanbrown/forbidigo/v2 v2.3.0 h1:OZZDOchCgsX5gvToVtEBoV2UWbFfI6RKQTir2UZzSxo=
github.com/ashanbrown/forbidigo/v2 v2.3.0/go.mod h1:5p6VmsG5/1xx3E785W9fouMxIOkvY2rRV9nMdWadd6c=
github.com/ashanbrown/makezero/v2 v2.1.0 h1:snuKYMbqosNokUKm+R6/+vOPs8yVAi46La7Ck6QYSaE=
github.com/ashanbrown/makezero/v2 v2.1.0/go.mod h1:aEGT/9q3S8DHeE57C88z2a6xydvgx8J5hgXIGWgo0MY=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -116,8 +122,8 @@ github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps=
github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bep/gitmap v1.6.0 h1:sDuQMm9HoTL0LtlrfxjbjgAg2wHQd4nkMup2FInYzhA=
github.com/bep/gitmap v1.6.0/go.mod h1:n+3W1f/rot2hynsqEGxGMErPRgT41n9CkGuzPvz9cIw=
github.com/bep/gitmap v1.9.0 h1:2pyb1ex+cdwF6c4tsrhEgEKfyNfxE34d5K+s2sa9byc=
github.com/bep/gitmap v1.9.0/go.mod h1:Juq6e1qqCRvc1W7nzgadPGI9IGV13ZncEebg5atj4Vo=
github.com/bep/goat v0.5.0 h1:S8jLXHCVy/EHIoCY+btKkmcxcXFd34a0Q63/0D4TKeA=
github.com/bep/goat v0.5.0/go.mod h1:Md9x7gRxiWKs85yHlVTvHQw9rg86Bm+Y4SuYE8CTH7c=
github.com/bep/godartsass/v2 v2.5.0 h1:tKRvwVdyjCIr48qgtLa4gHEdtRkPF8H1OeEhJAEv7xg=
@@ -128,6 +134,8 @@ github.com/bep/goportabletext v0.1.0 h1:8dqym2So1cEqVZiBa4ZnMM1R9l/DnC1h4ONg4J5k
github.com/bep/goportabletext v0.1.0/go.mod h1:6lzSTsSue75bbcyvVc0zqd1CdApuT+xkZQ6Re5DzZFg=
github.com/bep/gowebp v0.4.0 h1:QihuVnvIKbRoeBNQkN0JPMM8ClLmD6V2jMftTFwSK3Q=
github.com/bep/gowebp v0.4.0/go.mod h1:95gtYkAA8iIn1t3HkAPurRCVGV/6NhgaHJ1urz0iIwc=
github.com/bep/helpers v0.6.0 h1:qtqMCK8XPFNM9hp5Ztu9piPjxNNkk8PIyUVjg6v8Bsw=
github.com/bep/helpers v0.6.0/go.mod h1:IOZlgx5PM/R/2wgyCatfsgg5qQ6rNZJNDpWGXqDR044=
github.com/bep/imagemeta v0.12.0 h1:ARf+igs5B7pf079LrqRnwzQ/wEB8Q9v4NSDRZO1/F5k=
github.com/bep/imagemeta v0.12.0/go.mod h1:23AF6O+4fUi9avjiydpKLStUNtJr5hJB4rarG18JpN8=
github.com/bep/lazycache v0.8.0 h1:lE5frnRjxaOFbkPZ1YL6nijzOPPz6zeXasJq8WpG4L8=
@@ -144,8 +152,8 @@ github.com/blizzy78/varnamelen v0.8.0 h1:oqSblyuQvFsW1hbBHh1zfwrKe3kcSj0rnXkKzsQ
github.com/blizzy78/varnamelen v0.8.0/go.mod h1:V9TzQZ4fLJ1DSrjVDfl89H7aMnTvKkApdHeyESmyR7k=
github.com/bombsimon/wsl/v4 v4.7.0 h1:1Ilm9JBPRczjyUs6hvOPKvd7VL1Q++PL8M0SXBDf+jQ=
github.com/bombsimon/wsl/v4 v4.7.0/go.mod h1:uV/+6BkffuzSAVYD+yGyld1AChO7/EuLrCF/8xTiapg=
github.com/bombsimon/wsl/v5 v5.1.1 h1:cQg5KJf9FlctAH4cpL9vLKnziYknoCMCdqXl0wjl72Q=
github.com/bombsimon/wsl/v5 v5.1.1/go.mod h1:Gp8lD04z27wm3FANIUPZycXp+8huVsn0oxc+n4qfV9I=
github.com/bombsimon/wsl/v5 v5.3.0 h1:nZWREJFL6U3vgW/B1lfDOigl+tEF6qgs6dGGbFeR0UM=
github.com/bombsimon/wsl/v5 v5.3.0/go.mod h1:Gp8lD04z27wm3FANIUPZycXp+8huVsn0oxc+n4qfV9I=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo=
github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -157,8 +165,8 @@ github.com/butuzov/ireturn v0.4.0 h1:+s76bF/PfeKEdbG8b54aCocxXmi0wvYdOVsWxVO7n8E
github.com/butuzov/ireturn v0.4.0/go.mod h1:ghI0FrCmap8pDWZwfPisFD1vEc56VKH4NpQUxDHta70=
github.com/butuzov/mirror v1.3.0 h1:HdWCXzmwlQHdVhwvsfBb2Au0r3HyINry3bDWLYXiKoc=
github.com/butuzov/mirror v1.3.0/go.mod h1:AEij0Z8YMALaq4yQj9CPPVYOyJQyiexpQEQgihajRfI=
github.com/catenacyber/perfsprint v0.9.1 h1:5LlTp4RwTooQjJCvGEFV6XksZvWE7wCOUvjD2z0vls0=
github.com/catenacyber/perfsprint v0.9.1/go.mod h1:q//VWC2fWbcdSLEY1R3l8n0zQCDPdE4IjZwyY1HMunM=
github.com/catenacyber/perfsprint v0.10.0 h1:AZj1mYyxbxLRqmnYOeguZXEQwWOgQGm2wzLI5d7Hl/0=
github.com/catenacyber/perfsprint v0.10.0/go.mod h1:DJTGsi/Zufpuus6XPGJyKOTMELe347o6akPvWG9Zcsc=
github.com/ccojocar/zxcvbn-go v1.0.4 h1:FWnCIRMXPj43ukfX000kvBZvV6raSxakYr1nzyNrUcc=
github.com/ccojocar/zxcvbn-go v1.0.4/go.mod h1:3GxGX+rHmueTUMvm5ium7irpyjmm7ikxYFOSJB21Das=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -166,8 +174,8 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charithe/durationcheck v0.0.10 h1:wgw73BiocdBDQPik+zcEoBG/ob8uyBHf2iyoHGPf5w4=
github.com/charithe/durationcheck v0.0.10/go.mod h1:bCWXb7gYRysD1CU3C+u4ceO49LoGOY1C1L6uouGNreQ=
github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1DidBcABctk=
github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
@@ -187,13 +195,11 @@ github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyM
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow=
github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+fbBAhrQPs=
github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88=
github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ=
@@ -207,6 +213,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denis-tingaikin/go-header v0.5.0 h1:SRdnP5ZKvcO9KKRP1KJrhFR3RrlGuD+42t4429eC9k8=
github.com/denis-tingaikin/go-header v0.5.0/go.mod h1:mMenU5bWrok6Wl2UsZjy+1okegmwQ3UgWl4V1D8gjlY=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 h1:OtSeLS5y0Uy01jaKK4mA/WVIYtpzVm63vLVAPzJXigg=
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E=
github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
@@ -228,16 +236,16 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LV
github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/ebitengine/purego v0.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.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q=
github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A=
github.com/evanw/esbuild v0.25.3 h1:4JKyUsm/nHDhpxis4IyWXAi8GiyTwG1WdEp6OhGVE8U=
github.com/evanw/esbuild v0.25.3/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/evanw/esbuild v0.25.9 h1:aU7GVC4lxJGC1AyaPwySWjSIaNLAdVEEuq3chD0Khxs=
github.com/evanw/esbuild v0.25.9/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
@@ -251,18 +259,18 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gen2brain/go-fitz v1.24.15 h1:sJNB1MOWkqnzzENPHggFpgxTwW0+S5WF/rM5wUBpJWo=
github.com/gen2brain/go-fitz v1.24.15/go.mod h1:SftkiVbTHqF141DuiLwBBM65zP7ig6AVDQpf2WlHamo=
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/ghostiam/protogetter v0.3.15 h1:1KF5sXel0HE48zh1/vn0Loiw25A9ApyseLzQuif1mLY=
github.com/ghostiam/protogetter v0.3.15/go.mod h1:WZ0nw9pfzsgxuRsPOFQomgDVSWtDLJRfQJEhsGbmQMA=
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/ghostiam/protogetter v0.3.17 h1:sjGPErP9o7i2Ym+z3LsQzBdLCNaqbYy2iJQPxGXg04Q=
github.com/ghostiam/protogetter v0.3.17/go.mod h1:AivIX1eKA/TcUmzZdzbl+Tb8tjIe8FcyG6JFyemQAH4=
github.com/go-critic/go-critic v0.14.2 h1:PMvP5f+LdR8p6B29npvChUXbD1vrNlKDf60NJtgMBOo=
github.com/go-critic/go-critic v0.14.2/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
@@ -272,8 +280,8 @@ github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
@@ -282,22 +290,41 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.21.2 h1:AqQaNADVwq/VnkCmQg6ogE+M3FOsKTytwges0JdwVuA=
github.com/go-openapi/jsonpointer v0.21.2/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
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/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8=
github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo=
github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc=
github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4=
github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k=
github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag/conv v0.25.3 h1:PcB18wwfba7MN5BVlBIV+VxvUUeC2kEuCEyJ2/t2X7E=
github.com/go-openapi/swag/conv v0.25.3/go.mod h1:n4Ibfwhn8NJnPXNRhBO5Cqb9ez7alBR40JS4rbASUPU=
github.com/go-openapi/swag/jsonname v0.25.3 h1:U20VKDS74HiPaLV7UZkztpyVOw3JNVsit+w+gTXRj0A=
github.com/go-openapi/swag/jsonname v0.25.3/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.3 h1:kV7wer79KXUM4Ea4tBdAVTU842Rg6tWstX3QbM4fGdw=
github.com/go-openapi/swag/jsonutils v0.25.3/go.mod h1:ILcKqe4HC1VEZmJx51cVuZQ6MF8QvdfXsQfiaCs0z9o=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3 h1:/i3E9hBujtXfHy91rjtwJ7Fgv5TuDHgnSrYjhFxwxOw=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.3/go.mod h1:8kYfCR2rHyOj25HVvxL5Nm8wkfzggddgjZm6RgjT8Ao=
github.com/go-openapi/swag/loading v0.25.3 h1:Nn65Zlzf4854MY6Ft0JdNrtnHh2bdcS/tXckpSnOb2Y=
github.com/go-openapi/swag/loading v0.25.3/go.mod h1:xajJ5P4Ang+cwM5gKFrHBgkEDWfLcsAKepIuzTmOb/c=
github.com/go-openapi/swag/stringutils v0.25.3 h1:nAmWq1fUTWl/XiaEPwALjp/8BPZJun70iDHRNq/sH6w=
github.com/go-openapi/swag/stringutils v0.25.3/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.3 h1:2w4mEEo7DQt3V4veWMZw0yTPQibiL3ri2fdDV4t2TQc=
github.com/go-openapi/swag/typeutils v0.25.3/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.3 h1:LKTJjCn/W1ZfMec0XDL4Vxh8kyAnv1orH5F2OREDUrg=
github.com/go-openapi/swag/yamlutils v0.25.3/go.mod h1:Y7QN6Wc5DOBXK14/xeo1cQlq0EA0wvLoSv13gDQoCao=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -332,8 +359,10 @@ 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/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
github.com/godoc-lint/godoc-lint v0.10.1 h1:ZPUVzlDtJfA+P688JfPJPkI/SuzcBr/753yGIk5bOPA=
github.com/godoc-lint/godoc-lint v0.10.1/go.mod h1:KleLcHu/CGSvkjUH2RvZyoK1MBC7pDQg4NxMYLcBBsw=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e h1:QArsSubW7eDh8APMXkByjQWvuljwPGAGQpJEFn0F0wY=
github.com/gohugoio/go-i18n/v2 v2.1.3-0.20230805085216-e63c13218d0e/go.mod h1:3Ltoo9Banwq0gOtcOwxuHG6omk+AwsQPADyw2vQYOJQ=
@@ -341,10 +370,10 @@ github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp4
github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec=
github.com/gohugoio/httpcache v0.7.0 h1:ukPnn04Rgvx48JIinZvZetBfHaWE7I01JR2Q2RrQ3Vs=
github.com/gohugoio/httpcache v0.7.0/go.mod h1:fMlPrdY/vVJhAriLZnrF5QpN3BNAcoBClgAyQd+lGFI=
github.com/gohugoio/hugo v0.147.6 h1:rL4rnus/5qzj4+FoA+JMzsVvFJ2YZdVIH6pbuCB2P84=
github.com/gohugoio/hugo v0.147.6/go.mod h1:Sb2COQPDPYG+tRSpePtzKytiuVDqkBivEhgIew1QbNo=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.3.0 h1:gj49kTR5Z4Hnm0ZaQrgPVazL3DUkppw+x6XhHCmh+Wk=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.3.0/go.mod h1:IMMj7xiUbLt1YNJ6m7AM4cnsX4cFnnfkleO/lBHGzUg=
github.com/gohugoio/hugo v0.149.1 h1:uWOc8Ve4h4e48FyYhBquRoHCJviyxA5yGrFJLT48yio=
github.com/gohugoio/hugo v0.149.1/go.mod h1:HS6BP6e8FGxungP4CHC3zeLDvhBLnTJIjHJZWTZjs7o=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0 h1:dco+7YiOryRoPOMXwwaf+kktZSCtlFtreNdiJbETvYE=
github.com/gohugoio/hugo-goldmark-extensions/extras v0.5.0/go.mod h1:CRrxQTKeM3imw+UoS4EHKyrqB7Zp6sAJiqHit+aMGTE=
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1 h1:nUzXfRTszLliZuN0JTKeunXTRaiFX6ksaWP0puLLYAY=
github.com/gohugoio/hugo-goldmark-extensions/passthrough v0.3.1/go.mod h1:Wy8ThAA8p2/w1DY05vEzq6EIeI2mzDjvHsu7ULBVwog=
github.com/gohugoio/locales v0.14.0 h1:Q0gpsZwfv7ATHMbcTNepFd59H7GoykzWJIxi113XGDc=
@@ -356,8 +385,8 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20250825151631-54d70cc7cb31 h1:226lwSa0uZO7sd7QU88n43nDIqGoc+bOh0vSO3Q/byU=
github.com/golang/geo v0.0.0-20250825151631-54d70cc7cb31/go.mod h1:AN0OjM34c3PbjAsX+QNma1nYtJtRxl+s9MZNV7S+efw=
github.com/golang/geo v0.0.0-20251117194806-05dcfdd28b33 h1:1iCpF+lKihFpyN/Ujqvy9cWVlB516bGZItvb3l9HOSo=
github.com/golang/geo v0.0.0-20251117194806-05dcfdd28b33/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -389,15 +418,16 @@ 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.6.2 h1:jkMSVv36JmyTENcEertckvimvjPcD5qxNM7W7qhECvI=
github.com/golangci/golangci-lint/v2 v2.6.2/go.mod h1:fSIMDiBt9kzdpnvvV7GO6iWzyv5uaeZ+iPor+2uRczE=
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95 h1:AkK+w9FZBXlU/xUmBtSJN1+tAI4FIvy5WtnUnY8e4p8=
github.com/golangci/golines v0.0.0-20250217134842-442fd0091d95/go.mod h1:k9mmcyWKSTMcPPvQUCfRWWQ9VHJ1U9Dc0R7kaXAgtnQ=
github.com/golangci/misspell v0.7.0 h1:4GOHr/T1lTW0hhR4tgaaV1WS/lJ+ncvYCoFKmqJsj0c=
@@ -435,30 +465,31 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18=
github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6 h1:EEHtgt9IwisQ2AZ4pIsMjahcegHh6rmhqxzIRQIyepY=
github.com/google/pprof v0.0.0-20250820193118-f64d9cf942d6/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
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=
github.com/gostaticanalysis/analysisutil v0.7.1/go.mod h1:v21E3hY37WKMGSnbsw2S/ojApNWb6C1//mXO48CXbVc=
github.com/gostaticanalysis/comment v1.4.1/go.mod h1:ih6ZxzTHLdadaiSnF5WY3dxUoXfXAlTaRzuaNDlSado=
github.com/gostaticanalysis/comment v1.4.2/go.mod h1:KLUTGDv6HOCotCH8h2erHKmpci2ZoR8VPu34YA2uzdM=
github.com/gostaticanalysis/comment v1.5.0 h1:X82FLl+TswsUMpMh17srGRuKaaXprTaytmEpgnKIDu8=
github.com/gostaticanalysis/comment v1.5.0/go.mod h1:V6eb3gpCv9GNVqb6amXzEUX3jXLVK/AdA+IrAMSqvEc=
github.com/gostaticanalysis/forcetypeassert v0.2.0 h1:uSnWrrUEYDr86OCxWa4/Tp2jeYDlogZiZHzGkWFefTk=
github.com/gostaticanalysis/forcetypeassert v0.2.0/go.mod h1:M5iPavzE9pPqWyeiVXSFghQjljW1+l/Uke3PXHS6ILY=
github.com/gostaticanalysis/nilerr v0.1.1 h1:ThE+hJP0fEp4zWLkWHWcRyI2Od0p7DlgYG3Uqrmrcpk=
github.com/gostaticanalysis/nilerr v0.1.1/go.mod h1:wZYb6YI5YAxxq0i1+VJbY0s2YONW0HU0GPE3+5PWN4A=
github.com/gostaticanalysis/nilerr v0.1.2 h1:S6nk8a9N8g062nsx63kUkF6AzbHGw7zzyHMcpu52xQU=
github.com/gostaticanalysis/nilerr v0.1.2/go.mod h1:A19UHhoY3y8ahoL7YKz6sdjDtduwTSI4CsymaC2htPA=
github.com/gostaticanalysis/testutil v0.3.1-0.20210208050101-bfb5c8eec0e4/go.mod h1:D+FIZ+7OahH3ePw/izIEeH5I06eKs1IKI4Xr64/Am3M=
github.com/gostaticanalysis/testutil v0.5.0 h1:Dq4wT1DdTwTGCQQv3rl3IvD5Ld0E6HiY+3Zh0sUGqw8=
github.com/gostaticanalysis/testutil v0.5.0/go.mod h1:OLQSbuM6zw2EvCcXTz1lVq5unyoNft372msDY0nY5Hs=
github.com/gtsteffaniak/go-cache v0.0.0-20250521142451-edc77dfcb063 h1:CoLbeqTmM7348kulYBXYN82Bowx7Iu9PHe0shqQ2QR8=
github.com/gtsteffaniak/go-cache v0.0.0-20250521142451-edc77dfcb063/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
github.com/gtsteffaniak/go-logger v0.1.2 h1:0g6UxxNwU+Q+7IVRw+Yn7AuYsIK0ukiTgbzjTLm+Uy4=
github.com/gtsteffaniak/go-logger v0.1.2/go.mod h1:U3ZkdAclcxXNMvoQLq+zDkqcTmdqhX3hTf+6AuTo6v0=
github.com/gtsteffaniak/go-cache v1.0.0 h1:CuZw6eI/be6Oi3oR94Jb5LhcZShMCs6+1wvS7WGN8oQ=
github.com/gtsteffaniak/go-cache v1.0.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
github.com/gtsteffaniak/go-logger v0.2.3 h1:8nSC5xSMnMYQI61tsBS1tARpnkv/rOh5lzEXtAG72Nw=
github.com/gtsteffaniak/go-logger v0.2.3/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=
@@ -504,8 +535,8 @@ github.com/julz/importas v0.2.0 h1:y+MJN/UdL63QbFJHws9BVC5RpA2iq0kpjrFajTGivjQ=
github.com/julz/importas v0.2.0/go.mod h1:pThlt589EnCYtMnmhmRYY/qn9lCf/frPOK+WMx3xiJY=
github.com/jupiterrider/ffi v0.5.1 h1:l7ANXU+Ex33LilVa283HNaf/sTzCrrht7D05k6T6nlc=
github.com/jupiterrider/ffi v0.5.1/go.mod h1:x7xdNKo8h0AmLuXfswDUBxUsd2OqUP4ekC8sCnsmbvo=
github.com/karamaru-alpha/copyloopvar v1.2.1 h1:wmZaZYIjnJ0b5UoKDjUHrikcV0zuPyyxI4SVplLd2CI=
github.com/karamaru-alpha/copyloopvar v1.2.1/go.mod h1:nFmMlFNlClC2BPvNaHMdkirmTJxVCY0lhxBtlfOypMM=
github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhExWKxD/fP6q0=
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
@@ -513,8 +544,10 @@ github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/tt
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kovidgoyal/imaging v1.6.4 h1:K0idhRPXnRrJBKnBYcTfI1HTWSNDeAn7hYDvf9I0dCk=
github.com/kovidgoyal/imaging v1.6.4/go.mod h1:bEIgsaZmXlvFfkv/CUxr9rJook6AQkJnpB5EPosRfRY=
github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811uiUXyI7ok=
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
github.com/kovidgoyal/imaging v1.7.2 h1:mmT6k6Az3mC6dbqdZ6Q9KQCdZFWTAQ+q97NyGZgJ/2c=
github.com/kovidgoyal/imaging v1.7.2/go.mod h1:GdkCORjfZMMGFY0Pb7TDmRhj7PDhxF/QShKukSCj0VU=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -523,22 +556,22 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
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/kunwardeep/paralleltest v1.0.14 h1:wAkMoMeGX/kGfhQBPODT/BL8XhK23ol/nuQ3SwFaUw8=
github.com/kunwardeep/paralleltest v1.0.14/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk=
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.15 h1:ZMk4Qt306tHIgKISHWFJAO1IDQJLc6uDyJMLyncOb6w=
github.com/kunwardeep/paralleltest v1.0.15/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk=
github.com/kyokomi/emoji/v2 v2.2.13 h1:GhTfQa67venUUvmleTNFnb+bi7S3aocF7ZCXU9fSO7U=
github.com/kyokomi/emoji/v2 v2.2.13/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE=
github.com/lasiar/canonicalheader v1.1.2 h1:vZ5uqwvDbyJCnMhmFYimgMZnJMjwljN5VGY0VKbMXb4=
github.com/lasiar/canonicalheader v1.1.2/go.mod h1:qJCeLFS0G/QlLQ506T+Fk/fWMa2VmBUiEI2cuMK4djI=
github.com/ldez/exptostd v0.4.4 h1:58AtQjnLcT/tI5W/1KU7xE/O7zW9RAWB6c/ScQAnfus=
github.com/ldez/exptostd v0.4.4/go.mod h1:QfdzPw6oHjFVdNV7ILoPu5sw3OZ3OG1JS0I5JN3J4Js=
github.com/ldez/gomoddirectives v0.7.0 h1:EOx8Dd56BZYSez11LVgdj025lKwlP0/E5OLSl9HDwsY=
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/tagliatelle v0.7.1 h1:bTgKjjc2sQcsgPiT902+aadvMjCeMHrY7ly2XKFORIk=
github.com/ldez/tagliatelle v0.7.1/go.mod h1:3zjxUpsNB2aEZScWiZTHrAXOl1x25t3cRmzfK1mlo2I=
github.com/ldez/exptostd v0.4.5 h1:kv2ZGUVI6VwRfp/+bcQ6Nbx0ghFWcGIKInkG/oFn1aQ=
github.com/ldez/exptostd v0.4.5/go.mod h1:QRjHRMXJrCTIm9WxVNH6VW7oN7KrGSht69bIRwvdFsM=
github.com/ldez/gomoddirectives v0.7.1 h1:FaULkvUIG36hj6chpwa+FdCNGZBsD7/fO+p7CCsM6pE=
github.com/ldez/gomoddirectives v0.7.1/go.mod h1:auDNtakWJR1rC+YX7ar+HmveqXATBAyEK1KYpsIRW/8=
github.com/ldez/grignotin v0.10.1 h1:keYi9rYsgbvqAZGI1liek5c+jv9UUjbvdj3Tbn5fn4o=
github.com/ldez/grignotin v0.10.1/go.mod h1:UlDbXFCARrXbWGNGP3S5vsysNXAPhnSuBufpTEbwOas=
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=
@@ -551,18 +584,18 @@ github.com/macabu/inamedparam v0.2.0 h1:VyPYpOc10nkhI2qeNUdh3Zket4fcZjEWe35poddB
github.com/macabu/inamedparam v0.2.0/go.mod h1:+Pee9/YfGe5LJ62pYXqB89lJ+0k5bsR8Wgz/C0Zlq3U=
github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/makeworld-the-better-one/dither/v2 v2.4.0 h1:Az/dYXiTcwcRSe59Hzw4RI1rSnAZns+1msaCXetrMFE=
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=
github.com/maratori/testableexamples v1.0.0/go.mod h1:4rhjL1n20TUTT4vdh3RDqSizKLyXp7K2u6HgraZCGzE=
github.com/maratori/testpackage v1.1.1 h1:S58XVV5AD7HADMmD0fNnziNHqKvSdDuEKdPD1rNTU04=
github.com/maratori/testpackage v1.1.1/go.mod h1:s4gRK/ym6AMrqpOa/kEbQTV4Q4jb7WeLZzVhVVVOQMc=
github.com/maratori/testableexamples v1.0.1 h1:HfOQXs+XgfeRBJ+Wz0XfH+FHnoY9TVqL6Fcevpzy4q8=
github.com/maratori/testableexamples v1.0.1/go.mod h1:XE2F/nQs7B9N08JgyRmdGjYVGqxWwClLPCGSQhXQSrQ=
github.com/maratori/testpackage v1.1.2 h1:ffDSh+AgqluCLMXhM19f/cpvQAKygKAJXFl9aUjmbqs=
github.com/maratori/testpackage v1.1.2/go.mod h1:8F24GdVDFW5Ew43Et02jamrVMNXLUNaOynhDssITGfc=
github.com/marekm4/color-extractor v1.2.1 h1:3Zb2tQsn6bITZ8MBVhc33Qn1k5/SEuZ18mrXGUqIwn0=
github.com/marekm4/color-extractor v1.2.1/go.mod h1:90VjmiHI6M8ez9eYUaXLdcKnS+BAOp7w+NpwBdkJmpA=
github.com/matoous/godox v1.1.0 h1:W5mqwbyWrwZv6OQ5Z1a/DHGMOvXYCBP3+Ht7KMoJhq4=
@@ -577,8 +610,10 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgechev/revive v1.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=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE=
@@ -600,24 +635,28 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nakabonne/nestif v0.3.1 h1:wm28nZjhQY5HyYPx+weN3Q65k6ilSBxDb8v5S81B81U=
github.com/nakabonne/nestif v0.3.1/go.mod h1:9EtoZochLn5iUprVDmDjqGKPofoUEBL8U4Ngq6aY7OE=
github.com/niklasfasching/go-org v1.7.0 h1:vyMdcMWWTe/XmANk19F4k8XGBYg0GQ/gJGMimOjGMek=
github.com/niklasfasching/go-org v1.7.0/go.mod h1:WuVm4d45oePiE0eX25GqTDQIt/qPW1T9DGkRscqLW5o=
github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48=
github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhKRf3Swg=
github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
github.com/nunnatsa/ginkgolinter v0.20.0 h1:OmWLkAFO2HUTYcU6mprnKud1Ey5pVdiVNYGO5HVicx8=
github.com/nunnatsa/ginkgolinter v0.20.0/go.mod h1:dCIuFlTPfQerXgGUju3VygfAFPdC5aE1mdacCDKDJcQ=
github.com/nunnatsa/ginkgolinter v0.21.2 h1:khzWfm2/Br8ZemX8QM1pl72LwM+rMeW6VUbQ4rzh0Po=
github.com/nunnatsa/ginkgolinter v0.21.2/go.mod h1:GItSI5fw7mCGLPmkvGYrr1kEetZe7B593jcyOpyabsY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
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/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
github.com/onsi/ginkgo/v2 v2.26.0 h1:1J4Wut1IlYZNEAWIV3ALrT9NfiaGW2cDCJQSFQMs/gE=
github.com/onsi/ginkgo/v2 v2.26.0/go.mod h1:qhEywmzWTBUY88kfO0BRvX4py7scov9yR+Az2oavUzw=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw=
github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
@@ -667,10 +706,10 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/quasilyte/go-ruleguard v0.4.4 h1:53DncefIeLX3qEpjzlS1lyUmQoUEeOWPFWqaTJq9eAQ=
github.com/quasilyte/go-ruleguard v0.4.4/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE=
github.com/quasilyte/go-ruleguard/dsl v0.3.22 h1:wd8zkOhSNr+I+8Qeciml08ivDt1pSXe60+5DqOpCjPE=
github.com/quasilyte/go-ruleguard/dsl v0.3.22/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU=
github.com/quasilyte/go-ruleguard v0.4.5 h1:AGY0tiOT5hJX9BTdx/xBdoCubQUAE2grkqY2lSwvZcA=
github.com/quasilyte/go-ruleguard v0.4.5/go.mod h1:Vl05zJ538vcEEwu16V/Hdu7IYZWyKSwIy4c88Ro1kRE=
github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY=
github.com/quasilyte/go-ruleguard/dsl v0.3.23/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU=
github.com/quasilyte/gogrep v0.5.0 h1:eTKODPXbI8ffJMN+W2aE0+oL0z/nh8/5eNdiO34SOAo=
github.com/quasilyte/gogrep v0.5.0/go.mod h1:Cm9lpz9NZjEoL1tgZ2OgeUKPIxL1meE7eo60Z6Sk+Ng=
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 h1:TCg2WBOl980XxGFEZSS6KlBGIV0diGdySzxATTWoqaU=
@@ -688,6 +727,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb7Elr+g=
github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I=
github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9fJfSfdyCU=
@@ -700,8 +741,8 @@ github.com/sashamelentyev/interfacebloat v1.1.0 h1:xdRdJp0irL086OyW1H/RTZTr1h/tM
github.com/sashamelentyev/interfacebloat v1.1.0/go.mod h1:+Y9yU5YdTkrNvoX0xHc84dxiN1iBi9+G8zZIhPVoNjQ=
github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iMf7Knkq057v4XOQ=
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
github.com/securego/gosec/v2 v2.22.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.10 h1:ntbBqdWXnu46DUOXn+R2SvPo3PiJCDugTCgTW2g4tQg=
github.com/securego/gosec/v2 v2.22.10/go.mod h1:9UNjK3tLpv/w2b0+7r82byV43wCJDNtEDQMeS+H/g2w=
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=
@@ -718,18 +759,18 @@ github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o=
github.com/sonatard/noctx v0.4.0/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0=
github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/cast v1.8.0 h1:gEN9K4b8Xws4EX0+a0reLmhq8moKn7ntRlQYgjPeCDk=
github.com/spf13/cast v1.8.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/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=
@@ -744,8 +785,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs=
github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
@@ -754,20 +795,18 @@ github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tdakkota/asciicheck v0.4.1 h1:bm0tbcmi0jezRA2b5kg4ozmMuGAFotKI3RZfrhfovg8=
github.com/tdakkota/asciicheck v0.4.1/go.mod h1:0k7M3rCfRXb0Z6bwgvkEIMleKH3kXNz9UqJ9Xuqopr8=
github.com/tdewolff/minify/v2 v2.23.5 h1:/P548KcpTkIOUvNg22zN83/GiaYSOIrbqtoue4I7kYM=
github.com/tdewolff/minify/v2 v2.23.5/go.mod h1:2RI9tiIrzJU1Z5EasXEPaI1MqobRyxKHOOgrRkq5oEw=
github.com/tdewolff/parse/v2 v2.8.1 h1:J5GSHru6o3jF1uLlEKVXkDxxcVx6yzOlIVIotK4w2po=
github.com/tdewolff/parse/v2 v2.8.1/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/minify/v2 v2.24.2 h1:vnY3nTulEAbCAAlxTxPPDkzG24rsq31SOzp63yT+7mo=
github.com/tdewolff/minify/v2 v2.24.2/go.mod h1:1JrCtoZXaDbqioQZfk3Jdmr0GPJKiU7c1Apmb+7tCeE=
github.com/tdewolff/parse/v2 v2.8.3 h1:5VbvtJ83cfb289A1HzRA9sf02iT8YyUwN84ezjkdY1I=
github.com/tdewolff/parse/v2 v2.8.3/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11 h1:FdLbwQVHxqG16SlkGveC0JVyrJN62COWTRyUFzfbtBE=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tenntenn/modver v1.0.1 h1:2klLppGhDgzJrScMpkj9Ujy3rXPUspSjAcev9tSEBgA=
github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0=
github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3 h1:f+jULpRQGxTSkNYKJ51yaw6ChIqO+Je8UqsTKN/cDag=
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=
@@ -790,6 +829,8 @@ github.com/uudashr/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU=
github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0=
github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds=
github.com/xen0n/gosmopolitan v1.3.0 h1:zAZI1zefvo7gcpbCOrPSHJZJYA9ZgLfJqtKzZ5pHqQM=
github.com/xen0n/gosmopolitan v1.3.0/go.mod h1:rckfr5T6o4lBtM1ga7mLGKZmLxswUoH1zxHgNXOsEt4=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
@@ -807,22 +848,22 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.11 h1:ZCxLyDMtz0nT2HFfsYG8WZ47Trip2+JyLysKcMYE5bo=
github.com/yuin/goldmark v1.7.11/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo=
gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8=
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/arangolint v0.3.1 h1:n2E6p8f+zfXSFLa2e2WqFPp4bfvcuRdd50y6cT65pSo=
go.augendre.info/arangolint v0.3.1/go.mod h1:6ZKzEzIZuBQwoSvlKT+qpUfIbBfFCE5gbAoTg0/117g=
go.augendre.info/fatcontext v0.9.0 h1:Gt5jGD4Zcj8CDMVzjOJITlSb9cEch54hjRRlN3qDojE=
go.augendre.info/fatcontext v0.9.0/go.mod h1:L94brOAT1OOUNue6ph/2HnwxoNlds9aXDF2FcUntbNw=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
@@ -839,6 +880,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=
@@ -848,8 +891,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -860,16 +903,16 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b h1:QoALfVG9rhQ/M7vYDScfPdWjGL9dlsVVM5VGh7aKoAA=
golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0=
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-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-20251023183803-a4bb9ffd2546 h1:HDjDiATsGqvuqvkDvgJjD1IgPrVekcSXVVE21JwvzGE=
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -895,8 +938,8 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -939,16 +982,16 @@ golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -964,8 +1007,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1019,8 +1062,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -1037,13 +1080,13 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -1076,7 +1119,6 @@ golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapK
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200324003944-a576cf524670/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200329025819-fd4102a86c65/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
@@ -1086,20 +1128,17 @@ golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20200724022722-7017fd6b1305/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200820010801-b793a1359eac/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20201023174141-c8cfbd0f21e6/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.1-0.20210205202024-ef80cdb6ec6d/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU=
golang.org/x/tools v0.1.1-0.20210302220138-2ac05c832e1a/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -1185,8 +1224,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -1217,10 +1256,10 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
mvdan.cc/gofumpt v0.8.0 h1:nZUCeC2ViFaerTcYKstMmfysj6uhQrA2vJe+2vwGU6k=
mvdan.cc/gofumpt v0.8.0/go.mod h1:vEYnSzyGPmjvFkqJWtXkh79UwPWP9/HMxQdGEXZHjpg=
mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 h1:WjUu4yQoT5BHT1w8Zu56SP8367OuBV5jvo+4Ulppyf8=
mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4/go.mod h1:rthT7OuvRbaGcd5ginj6dA2oLE7YNlta9qhBNNdCaLE=
mvdan.cc/gofumpt v0.9.2 h1:zsEMWL8SVKGHNztrx6uZrXdp7AX8r421Vvp23sz7ik4=
mvdan.cc/gofumpt v0.9.2/go.mod h1:iB7Hn+ai8lPvofHd9ZFGVg2GOr8sBUw1QUWjNbmIL/s=
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 h1:ssMzja7PDPJV8FStj7hq9IKiuiKhgz9ErWw+m68e7DI=
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15/go.mod h1:4M5MMXl2kW6fivUT6yRGpLLPNfuGtU2Z0cPvFquGDYU=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
Loaded 100 of 409 files, more files were not shown because too many files have changed in this diff. Show more