Compare commits
15
Commits
v0.8.9-beta
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53411da695
|
||
|
|
e3bbadc419
|
||
|
|
37596b1e23 | ||
|
|
d35fb56587 | ||
|
|
5dab4c7b30 | ||
|
|
a33dbfc464 | ||
|
|
18738b3273 | ||
|
|
1d8c74dbd9 | ||
|
|
0ab22e3a89 | ||
|
|
8b1bf3cebf | ||
|
|
6d11e01054 | ||
|
|
3450149d24 | ||
|
|
54658308f2 | ||
|
|
e34b65918c | ||
|
|
eca9086a62 |
No files matched your search
+4
-1
@@ -2,6 +2,9 @@ backend/**.db
|
||||
backend/tmp
|
||||
backend/dist
|
||||
backend/backend*
|
||||
backend/http/dist/**
|
||||
backend/vendor/**
|
||||
|
||||
frontend/dist/**
|
||||
frontend/node_modules/**
|
||||
frontend/node_modules/**
|
||||
frontend/pkg/**
|
||||
@@ -0,0 +1 @@
|
||||
.gitea/CODEOWNERS linguist-detectable=false
|
||||
@@ -0,0 +1,3 @@
|
||||
# Pull requests request review from the eligible staff team(s).
|
||||
# One approval from either an Admin or Developer satisfies branch protection.
|
||||
* @trueog/Admins @trueog/Developers
|
||||
@@ -7,6 +7,7 @@ on:
|
||||
- "dev/v*.*.*"
|
||||
- "beta/v*.*.*"
|
||||
- "stable/v*.*.*"
|
||||
- "staging/v*.*.*"
|
||||
jobs:
|
||||
prime_docker_cache:
|
||||
name: Prime docker cache
|
||||
|
||||
@@ -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'."
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
file: ./_docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.modify-json.outputs.cleaned_tag }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -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 }}
|
||||
+9
-4
@@ -10,22 +10,23 @@ rice-box.go
|
||||
/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-*.yaml
|
||||
/backend/*test_config.yaml
|
||||
/backend/generated.yaml
|
||||
/backend/srv
|
||||
/backend/http/dist
|
||||
/backend/http/embed/*
|
||||
/backend/http/embed
|
||||
|
||||
.DS_Store
|
||||
node_modules
|
||||
@@ -51,3 +52,7 @@ bin/
|
||||
build/
|
||||
*__debug*
|
||||
backend/custom.css
|
||||
|
||||
# Runtime cache directories created by tests/application
|
||||
backend/preview/heic/
|
||||
backend/preview/thumbnails/
|
||||
+211
@@ -2,6 +2,215 @@
|
||||
|
||||
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
|
||||
- stricter access control checks on file downloads.
|
||||
- Wrong translation on Chinese "save" button #1650
|
||||
- Incorrect Spanish Translations in Folder Creation and Rename Actions #1626
|
||||
- duplicate file detector has stricter partial checksum match #1617
|
||||
|
||||
**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
|
||||
- disable only office viewing settings not applying
|
||||
- OnlyOffice integration does not work behind proxy authentication #1422
|
||||
- Newly created users "add on" to defined scope of previous user #1628 #1518
|
||||
- disable chown on upload / file saving #1469 #1546
|
||||
- Uploading a file will silently overwrite any existing file with the same name #1564
|
||||
- share file url issue
|
||||
- Fix drag and drop and rows in normal view #1651
|
||||
- Some text based files are not able to edit. #1567
|
||||
|
||||
## 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**:
|
||||
@@ -42,6 +251,8 @@ All notable changes to this project will be documented in this file. For commit
|
||||
- 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
|
||||
|
||||
|
||||
+2
-2
@@ -5,9 +5,9 @@ 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.
|
||||
|
||||
### Optional Tools
|
||||
|
||||
|
||||
@@ -2,61 +2,59 @@
|
||||
|
||||
[](https://goreportcard.com/report/github.com/gtsteffaniak/filebrowser/backend)
|
||||
[](https://www.codacy.com/gh/gtsteffaniak/filebrowser/dashboard)
|
||||
[](https://github.com/gtsteffaniak/filebrowser/releases)
|
||||
[](https://github.com/gtsteffaniak/filebrowser/releases)
|
||||
[](https://hub.docker.com/r/gtstef/filebrowser)
|
||||
[](https://www.apache.org/licenses/LICENSE-2.0)
|
||||
|
||||
[](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/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
|
||||
|
||||
:loudspeaker: [What's Coming Soon](https://github.com/gtsteffaniak/filebrowser/discussions/1622)
|
||||
|
||||
## Announcements
|
||||
|
||||
[Stable Release & 0.9.0 update](https://github.com/gtsteffaniak/filebrowser/discussions/1293)
|
||||
: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.
|
||||
|
||||
@@ -65,7 +63,6 @@ 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
|
||||
@@ -93,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 |
|
||||
|
||||
+6
-4
@@ -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 \
|
||||
@@ -28,19 +28,21 @@ 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 curl
|
||||
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
|
||||
|
||||
@@ -15,5 +15,6 @@ 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -2,16 +2,19 @@ FROM ghcr.io/gtsteffaniak/playwright-base:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Copy setup scripts first to leverage caching
|
||||
COPY [ "./_docker/src/no-config/", "./" ]
|
||||
COPY [ "./_docker/src/settings/", "./" ]
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY [ "./_docker/src/no-config/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
|
||||
@@ -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
|
||||
@@ -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 \
|
||||
@@ -30,6 +30,7 @@ USER filebrowser
|
||||
WORKDIR /home/filebrowser
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/filebrowser", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./data/config.yaml" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/config.yaml", "./" ]
|
||||
COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "./" ]
|
||||
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
|
||||
## sanity checks
|
||||
|
||||
@@ -1,27 +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:
|
||||
disableIndexing: true
|
||||
defaultEnabled: true
|
||||
<<: *server-main
|
||||
frontend:
|
||||
name: "Graham's Filebrowser"
|
||||
disableDefaultLinks: true
|
||||
styling:
|
||||
customThemes:
|
||||
no-rounded:
|
||||
description: "No rounded corners" # The description of the theme to display in the UI.
|
||||
css: "no-rounded.css" # The css file path and filename to use for the theme.
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
<<: *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
|
||||
@@ -10,7 +10,7 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/local-setup",
|
||||
globalSetup: "./tests/playwright/no-config-setup",
|
||||
timeout: 5000,
|
||||
testDir: "./tests/playwright/no-config",
|
||||
/* Run tests in files in parallel */
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -16,7 +16,8 @@ frontend:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
|
||||
styling:
|
||||
disableEventThemes: true
|
||||
auth:
|
||||
methods:
|
||||
password:
|
||||
@@ -28,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
|
||||
@@ -35,4 +42,6 @@ userDefaults:
|
||||
admin: false
|
||||
modify: true
|
||||
share: true
|
||||
api: false
|
||||
api: false
|
||||
download: true
|
||||
create: true
|
||||
@@ -17,6 +17,7 @@ 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.
|
||||
@@ -24,4 +25,9 @@ frontend:
|
||||
externalLinks:
|
||||
- text: "A playwright test"
|
||||
url: "https://playwright.dev/"
|
||||
title: "Playwright"
|
||||
title: "Playwright"
|
||||
|
||||
auth:
|
||||
methods:
|
||||
password:
|
||||
signup: true
|
||||
@@ -11,14 +11,14 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
*/
|
||||
export default defineConfig({
|
||||
globalSetup: "./tests/playwright/global-setup",
|
||||
timeout: 10000,
|
||||
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: 2,
|
||||
retries: 1,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
|
||||
@@ -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
|
||||
+3
-3
@@ -2,11 +2,11 @@ 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"]
|
||||
exclude_dir = ["tmp", "vendor", "http/dist", "http/embed", "swagger/docs"]
|
||||
exclude_dir = ["tmp", "vendor", "http/dist", "http/embed", "swagger/docs", "preview/thumbnails", "preview/heic"]
|
||||
log = "air.log"
|
||||
delay = 1000 # ms
|
||||
|
||||
@@ -14,4 +14,4 @@ tmp_dir = "tmp"
|
||||
time = true
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
clean_on_exit = false
|
||||
@@ -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 = false
|
||||
@@ -0,0 +1,138 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetContent_UTF8Truncation(t *testing.T) {
|
||||
// Get the path to the test file
|
||||
// The test file is in frontend/tests/playwright-files/utf8-truncated.txt
|
||||
// We need to find it relative to the test execution directory
|
||||
cwd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Navigate from backend/adapters/fs/files to the project root
|
||||
// backend/adapters/fs/files -> backend/adapters/fs -> backend/adapters -> backend -> root
|
||||
testFilePath := filepath.Join(cwd, "..", "..", "..", "..", "frontend", "tests", "playwright-files", "utf8-truncated.txt")
|
||||
|
||||
// Check if file exists, if not try alternative path
|
||||
if _, err = os.Stat(testFilePath); os.IsNotExist(err) {
|
||||
// Try from project root
|
||||
testFilePath = filepath.Join("frontend", "tests", "playwright-files", "utf8-truncated.txt")
|
||||
if _, err = os.Stat(testFilePath); os.IsNotExist(err) {
|
||||
t.Skipf("Test file not found at %s, skipping test", testFilePath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get absolute path
|
||||
var absPath string
|
||||
absPath, err = filepath.Abs(testFilePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("file with UTF-8 truncation at 4096 byte boundary", func(t *testing.T) {
|
||||
// The test file is longer than 4096 bytes, with valid UTF-8 throughout.
|
||||
// However, when reading exactly 4096 bytes as a header, it cuts off in the
|
||||
// middle of a multi-byte UTF-8 sequence (e6 9c, missing the last byte 88 of '月').
|
||||
// This should trigger the truncation handling in getContent's header validation.
|
||||
// The fix should trim the incomplete sequence from the header, allowing the
|
||||
// header check to pass, and then the full file (which is valid UTF-8) should
|
||||
// pass the full file validation.
|
||||
content, err := getContent(absPath)
|
||||
|
||||
// Should not return an error - the header truncation is handled, and the full file is valid
|
||||
require.NoError(t, err)
|
||||
|
||||
// The content should be the full valid UTF-8 file
|
||||
require.NotEmpty(t, content, "Content should not be empty - full file is valid UTF-8")
|
||||
|
||||
// Verify it contains the expected text
|
||||
require.Contains(t, content, "文件已备份", "Content should contain Chinese characters")
|
||||
require.Contains(t, content, "2024年", "Content should contain date information")
|
||||
})
|
||||
|
||||
t.Run("regular UTF-8 text file", func(t *testing.T) {
|
||||
// Test with a simple text file to ensure normal files still work
|
||||
tmpFile, err := os.CreateTemp("", "test-utf8-*.txt")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
testText := "Hello, 世界! This is a test file with UTF-8 characters.\n"
|
||||
_, err = tmpFile.WriteString(testText)
|
||||
require.NoError(t, err)
|
||||
tmpFile.Close()
|
||||
|
||||
content, err := getContent(tmpFile.Name())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testText, content)
|
||||
})
|
||||
|
||||
t.Run("file smaller than header size", func(t *testing.T) {
|
||||
// Test with a file smaller than 4096 bytes
|
||||
tmpFile, err := os.CreateTemp("", "test-small-*.txt")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
testText := "Small file content"
|
||||
_, err = tmpFile.WriteString(testText)
|
||||
require.NoError(t, err)
|
||||
tmpFile.Close()
|
||||
|
||||
content, err := getContent(tmpFile.Name())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testText, content)
|
||||
})
|
||||
|
||||
t.Run("file with Chinese characters at boundary", func(t *testing.T) {
|
||||
// Create a file that's exactly 4094 bytes, ending with a complete Chinese character
|
||||
tmpFile, err := os.CreateTemp("", "test-chinese-*.txt")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmpFile.Name())
|
||||
defer tmpFile.Close()
|
||||
|
||||
// Create content that's exactly 4094 bytes, ending with complete UTF-8
|
||||
baseText := "2024年 06月 17日 星期一 04:05:58 CST 文件已备份\n"
|
||||
content := ""
|
||||
for len([]byte(content)) < 4094 {
|
||||
content += baseText
|
||||
}
|
||||
// Trim to exactly 4094 bytes
|
||||
encoded := []byte(content)
|
||||
if len(encoded) > 4094 {
|
||||
encoded = encoded[:4094]
|
||||
}
|
||||
// Ensure it ends with a complete character by finding the last complete rune
|
||||
for len(encoded) > 0 {
|
||||
lastRune, _ := decodeLastRune(encoded)
|
||||
if lastRune != 0xFFFD { // RuneError
|
||||
break
|
||||
}
|
||||
encoded = encoded[:len(encoded)-1]
|
||||
}
|
||||
|
||||
_, err = tmpFile.Write(encoded)
|
||||
require.NoError(t, err)
|
||||
tmpFile.Close()
|
||||
|
||||
result, err := getContent(tmpFile.Name())
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, result)
|
||||
require.Equal(t, string(encoded), result)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to check if last rune is valid
|
||||
func decodeLastRune(p []byte) (rune, int) {
|
||||
if len(p) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
r, size := utf8.DecodeLastRune(p)
|
||||
return r, size
|
||||
}
|
||||
@@ -98,11 +98,11 @@ func TestSortItems(t *testing.T) {
|
||||
{Name: "apple"},
|
||||
{Name: "Banana"},
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "File2.txt"},
|
||||
{Name: "File10.txt"},
|
||||
{Name: "File1"},
|
||||
{Name: "banana"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File2.txt"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File10.txt"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File1"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "banana"}},
|
||||
},
|
||||
},
|
||||
expected: iteminfo.FileInfo{
|
||||
@@ -112,11 +112,11 @@ func TestSortItems(t *testing.T) {
|
||||
{Name: "apple"},
|
||||
{Name: "Banana"},
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "banana"},
|
||||
{Name: "File1"},
|
||||
{Name: "File10.txt"},
|
||||
{Name: "File2.txt"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "banana"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File1"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File10.txt"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "File2.txt"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -128,10 +128,10 @@ func TestSortItems(t *testing.T) {
|
||||
{Name: "Cat.txt"},
|
||||
{Name: "apple"},
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "Zebra"},
|
||||
{Name: "apple"},
|
||||
{Name: "cat"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "Zebra"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "apple"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "cat"}},
|
||||
},
|
||||
},
|
||||
expected: iteminfo.FileInfo{
|
||||
@@ -140,10 +140,10 @@ func TestSortItems(t *testing.T) {
|
||||
{Name: "Cat.txt"},
|
||||
{Name: "dog.txt"},
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "apple"},
|
||||
{Name: "cat"},
|
||||
{Name: "Zebra"},
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "apple"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "cat"}},
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "Zebra"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -153,7 +153,7 @@ func TestSortItems(t *testing.T) {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
test.input.SortItems()
|
||||
|
||||
getNames := func(items []iteminfo.ItemInfo) []string {
|
||||
getFolderNames := func(items []iteminfo.ItemInfo) []string {
|
||||
names := []string{}
|
||||
for _, folder := range items {
|
||||
names = append(names, folder.Name)
|
||||
@@ -161,15 +161,23 @@ func TestSortItems(t *testing.T) {
|
||||
return names
|
||||
}
|
||||
|
||||
actualFolderNames := getNames(test.input.Folders)
|
||||
expectedFolderNames := getNames(test.expected.Folders)
|
||||
getFileNames := func(items []iteminfo.ExtendedItemInfo) []string {
|
||||
names := []string{}
|
||||
for _, file := range items {
|
||||
names = append(names, file.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
actualFolderNames := getFolderNames(test.input.Folders)
|
||||
expectedFolderNames := getFolderNames(test.expected.Folders)
|
||||
|
||||
if !reflect.DeepEqual(actualFolderNames, expectedFolderNames) {
|
||||
t.Errorf("Folders not sorted correctly.\nGot: %v\nExpected: %v", actualFolderNames, expectedFolderNames)
|
||||
}
|
||||
|
||||
actualFileNames := getNames(test.input.Files)
|
||||
expectedFileNames := getNames(test.expected.Files)
|
||||
actualFileNames := getFileNames(test.input.Files)
|
||||
expectedFileNames := getFileNames(test.expected.Files)
|
||||
|
||||
if !reflect.DeepEqual(actualFileNames, expectedFileNames) {
|
||||
t.Errorf("Files not sorted correctly.\nGot: %v\nExpected: %v", actualFileNames, expectedFileNames)
|
||||
@@ -178,73 +186,8 @@ func TestSortItems(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFilesCacheClearing(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "filebrowser_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create a test file
|
||||
testFile := filepath.Join(tempDir, "Test Object")
|
||||
err = os.WriteFile(testFile, []byte("test content"), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
// Initialize a mock index
|
||||
idx := indexing.Index{
|
||||
Source: settings.Source{
|
||||
Path: tempDir,
|
||||
},
|
||||
}
|
||||
|
||||
// Get the real path and cache it
|
||||
_, isDir, err := idx.GetRealPath("/Test Object")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get real path: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file is detected as a file (not directory)
|
||||
if isDir {
|
||||
t.Errorf("Expected file to be detected as file, but got directory")
|
||||
}
|
||||
|
||||
// Initialize the index in the indexing system
|
||||
indexing.Initialize(&settings.Source{
|
||||
Name: "test",
|
||||
Path: tempDir,
|
||||
}, true) // true for mock mode
|
||||
|
||||
// Delete the file
|
||||
err = DeleteFiles("test", testFile, tempDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete file: %v", err)
|
||||
}
|
||||
|
||||
// Create a directory with the same name
|
||||
testDir := filepath.Join(tempDir, "Test Object")
|
||||
err = os.Mkdir(testDir, 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test directory: %v", err)
|
||||
}
|
||||
|
||||
// Get the real path again - it should now detect it as a directory
|
||||
// The cache should have been cleared, so it should re-detect the type
|
||||
realPath2, isDir2, err := idx.GetRealPath("/Test Object")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get real path after recreation: %v", err)
|
||||
}
|
||||
|
||||
// Verify the directory is detected as a directory
|
||||
if !isDir2 {
|
||||
t.Errorf("Expected directory to be detected as directory, but got file. Real path: %s", realPath2)
|
||||
}
|
||||
|
||||
// Clean up
|
||||
os.RemoveAll(tempDir)
|
||||
}
|
||||
// TestDeleteFilesCacheClearing was removed because cache clearing is not performed
|
||||
// per design decision - RealPathCache is auxiliary and doesn't need clearing
|
||||
|
||||
func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
// Initialize the index in mock mode (no filesystem operations)
|
||||
@@ -255,12 +198,12 @@ func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
|
||||
// Get the index and set up mock data
|
||||
idx := indexing.GetIndex("test")
|
||||
if idx == nil {
|
||||
if idx == nil { //nolint:staticcheck // t.Fatal terminates execution
|
||||
t.Fatal("Failed to get test index")
|
||||
}
|
||||
|
||||
// Create mock directory structure
|
||||
idx.Directories["/"] = &iteminfo.FileInfo{
|
||||
idx.Directories["/"] = &iteminfo.FileInfo{ //nolint:staticcheck // t.Fatal terminates execution
|
||||
Path: "/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "/",
|
||||
@@ -273,7 +216,7 @@ func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
|
||||
// Simulate the directory-to-file override by updating the mock data
|
||||
// Remove the directory from the parent's Folders slice
|
||||
rootInfo := idx.Directories["/"]
|
||||
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:]...)
|
||||
@@ -282,9 +225,11 @@ func TestOverrideDirectoryToFile(t *testing.T) {
|
||||
}
|
||||
|
||||
// Add the file to the parent's Files slice
|
||||
rootInfo.Files = append(rootInfo.Files, iteminfo.ItemInfo{
|
||||
Name: "Test Object",
|
||||
Size: 25, // Length of "This is test file content"
|
||||
rootInfo.Files = append(rootInfo.Files, iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "Test Object",
|
||||
Size: 25, // Length of "This is test file content"
|
||||
},
|
||||
})
|
||||
|
||||
// Verify the directory was replaced with a file in the mock data
|
||||
@@ -330,25 +275,25 @@ func TestOverrideFileToDirectory(t *testing.T) {
|
||||
|
||||
// Get the index and set up mock data
|
||||
idx := indexing.GetIndex("test")
|
||||
if idx == nil {
|
||||
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{
|
||||
idx.Directories["/"] = &iteminfo.FileInfo{ //nolint:staticcheck // t.Fatal terminates execution
|
||||
Path: "/",
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: "/",
|
||||
Type: "directory",
|
||||
},
|
||||
Files: []iteminfo.ItemInfo{
|
||||
{Name: "Test Object", Size: 12}, // Length of "test content"
|
||||
Files: []iteminfo.ExtendedItemInfo{
|
||||
{ItemInfo: iteminfo.ItemInfo{Name: "Test Object", Size: 12}}, // Length of "test content"
|
||||
},
|
||||
}
|
||||
|
||||
// Simulate the file-to-directory override by updating the mock data
|
||||
// Remove the file from the parent's Files slice
|
||||
rootInfo := idx.Directories["/"]
|
||||
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:]...)
|
||||
|
||||
+276
-146
@@ -1,25 +1,25 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"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"
|
||||
@@ -43,58 +43,29 @@ func FileInfoFaster(opts utils.FileOptions, access *access.Storage) (*iteminfo.E
|
||||
}
|
||||
opts.IsDir = isDir
|
||||
var info *iteminfo.FileInfo
|
||||
var exists bool
|
||||
var useFsInfo bool
|
||||
|
||||
// Check if indexing is disabled for this source
|
||||
if index.Config.DisableIndexing {
|
||||
useFsInfo = true
|
||||
}
|
||||
// Check if path is viewable (allows filesystem access without indexing)
|
||||
isViewable := index.IsViewable(isDir, opts.Path)
|
||||
|
||||
if !useFsInfo && isDir {
|
||||
// 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 {
|
||||
if err == errors.ErrNotIndexed {
|
||||
return response, fmt.Errorf("could not refresh file info: %v", err)
|
||||
}
|
||||
return response, fmt.Errorf("path not accessible: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if useFsInfo {
|
||||
if isDir {
|
||||
info, err = index.GetFsDirInfo(opts.Path)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
} else {
|
||||
var fileInfo os.FileInfo
|
||||
// Get file info directly from filesystem
|
||||
fileInfo, err = os.Stat(realPath)
|
||||
if err != nil {
|
||||
return response, fmt.Errorf("could not stat file: %v", err)
|
||||
}
|
||||
|
||||
info = &iteminfo.FileInfo{
|
||||
Path: opts.Path,
|
||||
ItemInfo: iteminfo.ItemInfo{
|
||||
Name: fileInfo.Name(),
|
||||
Size: fileInfo.Size(),
|
||||
ModTime: fileInfo.ModTime(),
|
||||
},
|
||||
}
|
||||
info.DetectType(realPath, false)
|
||||
if isDir {
|
||||
info, err = index.GetFsDirInfo(opts.Path)
|
||||
if err != nil {
|
||||
return response, err
|
||||
}
|
||||
} else {
|
||||
info, exists = index.GetReducedMetadata(opts.Path, opts.IsDir)
|
||||
if !exists {
|
||||
err = index.RefreshFileInfo(opts)
|
||||
if err != nil {
|
||||
return response, fmt.Errorf("could not refresh file info: %v", err)
|
||||
}
|
||||
info, exists = index.GetReducedMetadata(opts.Path, opts.IsDir)
|
||||
if !exists {
|
||||
return response, fmt.Errorf("could not get metadata for path: %v", opts.Path)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,9 +81,55 @@ func FileInfoFaster(opts utils.FileOptions, access *access.Storage) (*iteminfo.E
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
if opts.Content || opts.Metadata {
|
||||
|
||||
// For directories, populate metadata for audio/video files ONLY if explicitly requested
|
||||
// This avoids expensive ffprobe calls on every directory listing
|
||||
if isDir && opts.Metadata {
|
||||
startTime := time.Now()
|
||||
metadataCount := 0
|
||||
|
||||
for i := range response.Files {
|
||||
fileItem := &response.Files[i]
|
||||
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
|
||||
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
|
||||
|
||||
if isItemAudio || isItemVideo {
|
||||
// Get the real path for this file
|
||||
itemRealPath, _, _ := index.GetRealPath(opts.Path, fileItem.Name)
|
||||
|
||||
// Extract metadata for audio files (without album art for performance)
|
||||
if isItemAudio {
|
||||
err := extractAudioMetadata(context.Background(), fileItem, itemRealPath, opts.AlbumArt || opts.Content, opts.Metadata)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract metadata for file: "+fileItem.Name, err)
|
||||
} else {
|
||||
metadataCount++
|
||||
}
|
||||
} else if isItemVideo {
|
||||
// Extract duration for video files
|
||||
err := extractVideoMetadata(context.Background(), fileItem, itemRealPath)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: "+fileItem.Name, err)
|
||||
} else {
|
||||
metadataCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if metadataCount > 0 {
|
||||
elapsed := time.Since(startTime)
|
||||
logger.Debugf("Extracted metadata for %d audio/video files in %v (avg: %v per file)",
|
||||
metadataCount, elapsed, elapsed/time.Duration(metadataCount))
|
||||
}
|
||||
}
|
||||
|
||||
// Extract content/metadata when explicitly requested OR for single file audio/video requests
|
||||
isAudioVideo := strings.HasPrefix(info.Type, "audio") || strings.HasPrefix(info.Type, "video")
|
||||
if opts.Content || opts.Metadata || (!isDir && isAudioVideo) {
|
||||
processContent(response, index, opts)
|
||||
}
|
||||
|
||||
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
|
||||
response.OnlyOfficeId = generateOfficeId(realPath)
|
||||
}
|
||||
@@ -129,6 +146,18 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
}
|
||||
|
||||
if isVideo {
|
||||
// Extract duration for video
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
err := extractVideoMetadata(context.Background(), extItem, info.RealPath)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract video metadata for file: "+info.RealPath, info.Name, err)
|
||||
} else {
|
||||
info.Metadata = extItem.Metadata
|
||||
}
|
||||
|
||||
// Handle subtitles if requested
|
||||
if opts.ExtractEmbeddedSubtitles {
|
||||
parentPath := filepath.Dir(info.Path)
|
||||
parentInfo, exists := idx.GetReducedMetadata(parentPath, true)
|
||||
@@ -144,11 +173,17 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
|
||||
}
|
||||
|
||||
if isAudio {
|
||||
err := extractAudioMetadata(info)
|
||||
// Create an ExtendedItemInfo to hold the metadata
|
||||
extItem := &iteminfo.ExtendedItemInfo{
|
||||
ItemInfo: info.ItemInfo,
|
||||
}
|
||||
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content)
|
||||
if err != nil {
|
||||
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
|
||||
} else {
|
||||
info.HasPreview = info.AudioMeta.AlbumArt != ""
|
||||
// Copy metadata to ExtendedFileInfo
|
||||
info.Metadata = extItem.Metadata
|
||||
info.HasPreview = extItem.Metadata != nil && extItem.Metadata.AlbumArt != ""
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -178,8 +213,9 @@ func generateOfficeId(realPath string) string {
|
||||
}
|
||||
|
||||
// extractAudioMetadata extracts metadata from an audio file using dhowden/tag
|
||||
func extractAudioMetadata(item *iteminfo.ExtendedFileInfo) error {
|
||||
file, err := os.Open(item.RealPath)
|
||||
// and optionally extracts duration using the ffmpeg service with concurrency control
|
||||
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool) error {
|
||||
file, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -202,7 +238,7 @@ func extractAudioMetadata(item *iteminfo.ExtendedFileInfo) error {
|
||||
return err
|
||||
}
|
||||
|
||||
item.AudioMeta = &iteminfo.AudioMetadata{
|
||||
item.Metadata = &iteminfo.MediaMetadata{
|
||||
Title: m.Title(),
|
||||
Artist: m.Artist(),
|
||||
Album: m.Album(),
|
||||
@@ -212,50 +248,96 @@ func extractAudioMetadata(item *iteminfo.ExtendedFileInfo) error {
|
||||
|
||||
// Extract track number
|
||||
track, _ := m.Track()
|
||||
item.AudioMeta.Track = track
|
||||
item.Metadata.Track = track
|
||||
|
||||
// Extract duration ONLY if explicitly requested using the ffmpeg VideoService
|
||||
// This respects concurrency limits and gracefully handles missing ffmpeg
|
||||
if getDuration {
|
||||
ffmpegService := ffmpeg.NewFFmpegService(5, false, "")
|
||||
if ffmpegService != nil {
|
||||
startTime := time.Now()
|
||||
if duration, err := ffmpegService.GetMediaDuration(ctx, realPath); err == nil {
|
||||
item.Metadata.Duration = int(duration)
|
||||
elapsed := time.Since(startTime)
|
||||
if elapsed > 50*time.Millisecond {
|
||||
logger.Debugf("Duration extraction took %v for file: %s", elapsed, item.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !getArt {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract album art and encode as base64 with strict size limits
|
||||
if picture := m.Picture(); picture != nil && picture.Data != nil {
|
||||
// More aggressive size limit to prevent memory issues (max 5MB)
|
||||
if len(picture.Data) <= 5*1024*1024 {
|
||||
item.AudioMeta.AlbumArt = base64.StdEncoding.EncodeToString(picture.Data)
|
||||
item.Metadata.AlbumArt = base64.StdEncoding.EncodeToString(picture.Data)
|
||||
} else {
|
||||
logger.Debugf("Skipping album art for %s: too large (%d bytes)", item.RealPath, len(picture.Data))
|
||||
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, len(picture.Data))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteFiles(source, absPath string, absDirPath string) error {
|
||||
err := os.RemoveAll(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
// extractVideoMetadata extracts duration from video files using ffprobe
|
||||
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string) error {
|
||||
// Extract duration using the ffmpeg VideoService with concurrency control
|
||||
videoService := ffmpeg.NewFFmpegService(10, false, "")
|
||||
if videoService != nil {
|
||||
duration, err := videoService.GetMediaDuration(ctx, realPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if duration > 0 {
|
||||
item.Metadata = &iteminfo.MediaMetadata{
|
||||
Duration: int(duration),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DeleteFiles(source, absPath string, absDirPath string, isDir bool) error {
|
||||
index := indexing.GetIndex(source)
|
||||
if index == nil {
|
||||
return fmt.Errorf("could not get index: %v ", source)
|
||||
}
|
||||
if index.Config.DisableIndexing {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear RealPathCache entries for the deleted path to prevent cache issues
|
||||
// when a folder with the same name is created later
|
||||
indexPath := index.MakeIndexPath(absPath)
|
||||
realPath, _, err := index.GetRealPath(indexPath)
|
||||
if err == nil {
|
||||
// Clear both the path and the isdir cache entries
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.RealPathCache.Delete(realPath + ":isdir")
|
||||
}
|
||||
if !index.Config.DisableIndexing {
|
||||
indexPath := index.MakeIndexPath(absPath)
|
||||
|
||||
refreshConfig := utils.FileOptions{Path: index.MakeIndexPath(absDirPath), IsDir: true}
|
||||
err = index.RefreshFileInfo(refreshConfig)
|
||||
if err != nil {
|
||||
// Perform the physical deletion
|
||||
err := os.RemoveAll(absPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove metadata from index
|
||||
if isDir {
|
||||
// Recursively remove directory and all subdirectories from index
|
||||
index.DeleteMetadata(indexPath, true, true)
|
||||
} else {
|
||||
// Remove file from parent's file list
|
||||
index.DeleteMetadata(indexPath, false, false)
|
||||
}
|
||||
|
||||
// Refresh the parent directory to recalculate sizes and update counts
|
||||
// This will traverse up the tree and update all parent sizes correctly
|
||||
refreshConfig := utils.FileOptions{
|
||||
Path: index.MakeIndexPath(absDirPath),
|
||||
IsDir: true,
|
||||
}
|
||||
err = index.RefreshFileInfo(refreshConfig)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
// Indexing disabled, just delete the file
|
||||
return os.RemoveAll(absPath)
|
||||
}
|
||||
|
||||
func RefreshIndex(source string, path string, isDir bool, recursive bool) error {
|
||||
@@ -274,6 +356,26 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
|
||||
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
|
||||
}
|
||||
@@ -307,21 +409,17 @@ 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
|
||||
}
|
||||
|
||||
err := fileutils.MoveFile(realsrc, realdst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// For move operations:
|
||||
// 1. Delete the source from the index (recursively if it's a directory)
|
||||
// 2. Recursively index the destination to capture the entire moved tree
|
||||
|
||||
// Get indexes for deletion and refresh operations
|
||||
srcIdx := indexing.GetIndex(sourceIndex)
|
||||
if srcIdx == nil {
|
||||
@@ -332,44 +430,71 @@ func MoveResource(isSrcDir, isDestDir bool, sourceIndex, destIndex, realsrc, rea
|
||||
return fmt.Errorf("could not get destination index: %v", destIndex)
|
||||
}
|
||||
|
||||
// Delete from source index (recursively for directories)
|
||||
go srcIdx.DeleteMetadata(realsrc, isSrcDir, isSrcDir) //nolint:errcheck
|
||||
|
||||
// Clear RealPathCache entries for the moved path to prevent cache issues
|
||||
// when a file/folder with the same name is created later
|
||||
// Prepare paths for index operations
|
||||
srcIndexPath := srcIdx.MakeIndexPath(realsrc)
|
||||
srcRealPath, _, err := srcIdx.GetRealPath(srcIndexPath)
|
||||
if err == nil {
|
||||
// Clear both the path and the isdir cache entries
|
||||
indexing.RealPathCache.Delete(srcRealPath)
|
||||
indexing.RealPathCache.Delete(srcRealPath + ":isdir")
|
||||
srcParentPath := filepath.Dir(realsrc)
|
||||
|
||||
// Perform the physical move
|
||||
err := fileutils.MoveFile(realsrc, realdst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// For move operations, refresh the moved item and its new parent directory
|
||||
// Run all refresh operations in a goroutine to avoid blocking the API
|
||||
if isSrcDir {
|
||||
go func() {
|
||||
// When moving a folder, refresh the folder itself recursively FIRST
|
||||
// Must complete before parent refresh to avoid race condition
|
||||
RefreshIndex(destIndex, realdst, true, true) //nolint:errcheck
|
||||
// Handle SOURCE cleanup (treat as deletion)
|
||||
if !srcIdx.Config.DisableIndexing {
|
||||
// Remove metadata from source index
|
||||
if isSrcDir {
|
||||
// Recursively remove directory and all subdirectories from source index
|
||||
srcIdx.DeleteMetadata(srcIndexPath, true, true)
|
||||
} else {
|
||||
// Remove file from source parent's file list
|
||||
srcIdx.DeleteMetadata(srcIndexPath, false, false)
|
||||
}
|
||||
|
||||
// THEN refresh the parent directory so it sees the newly indexed child
|
||||
// Refresh the source parent directory to recalculate sizes and update counts
|
||||
go RefreshIndex(sourceIndex, srcParentPath, true, false) //nolint:errcheck
|
||||
}
|
||||
|
||||
// Handle DESTINATION indexing
|
||||
if !dstIdx.Config.DisableIndexing {
|
||||
if isSrcDir {
|
||||
go func() {
|
||||
// When moving a folder, refresh the folder itself recursively FIRST
|
||||
// Must complete before parent refresh to avoid race condition
|
||||
RefreshIndex(destIndex, realdst, true, true) //nolint:errcheck
|
||||
|
||||
// THEN refresh the parent directory so it sees the newly indexed child
|
||||
parentDir := filepath.Dir(realdst)
|
||||
RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
|
||||
}()
|
||||
} else {
|
||||
// If moving a file, just refresh the parent directory
|
||||
parentDir := filepath.Dir(realdst)
|
||||
RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
|
||||
}()
|
||||
} else {
|
||||
// If moving a file, just refresh the parent directory
|
||||
parentDir := filepath.Dir(realdst)
|
||||
go RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
|
||||
go RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
|
||||
// Use backend source paths to match how shares are stored
|
||||
go s.UpdateShares(srcIdx.Path, srcIdx.MakeIndexPath(realsrc), dstIdx.Path, dstIdx.MakeIndexPath(realdst)) //nolint:errcheck
|
||||
|
||||
// Update access rules for the moved path
|
||||
if a != nil {
|
||||
// If moving within the same source, update the rules
|
||||
if srcIdx.Path == dstIdx.Path {
|
||||
go a.UpdateRules(srcIdx.Path, srcIdx.MakeIndexPath(realsrc), dstIdx.MakeIndexPath(realdst)) //nolint:errcheck
|
||||
}
|
||||
// Cross-source moves don't preserve access rules (they're source-specific)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func CopyResource(isSrcDir, 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
|
||||
@@ -396,14 +521,12 @@ func CopyResource(isSrcDir, isDestDir bool, sourceIndex, destIndex, realsrc, rea
|
||||
|
||||
// Refresh destination (parent directory if it's a file)
|
||||
dstRefreshPath := realdst
|
||||
dstRefreshIsDir := isDestDir
|
||||
if !isSrcDir {
|
||||
// If copying a file (regardless of destination), refresh the parent directory
|
||||
dstRefreshPath = filepath.Dir(realdst)
|
||||
dstRefreshIsDir = true
|
||||
}
|
||||
|
||||
go RefreshIndex(destIndex, dstRefreshPath, dstRefreshIsDir, true) //nolint:errcheck
|
||||
go RefreshIndex(destIndex, dstRefreshPath, true, true) //nolint:errcheck
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -424,35 +547,24 @@ func WriteDirectory(opts utils.FileOptions) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not remove existing file to create directory: %v", err)
|
||||
}
|
||||
// Clear the cache for the removed file
|
||||
realPath, _, err = idx.GetRealPath(opts.Path)
|
||||
if err == nil {
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.RealPathCache.Delete(realPath + ":isdir")
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the parent directories exist
|
||||
// Permissions are set by MkdirAll (subject to umask, which is usually acceptable)
|
||||
err = os.MkdirAll(realPath, fileutils.PermDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Explicitly set directory permissions to bypass umask
|
||||
err = os.Chmod(realPath, fileutils.PermDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return RefreshIndex(idx.Name, opts.Path, true, true)
|
||||
}
|
||||
|
||||
func WriteFile(opts utils.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)
|
||||
}
|
||||
realPath, _, _ := 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
|
||||
@@ -463,18 +575,20 @@ func WriteFile(opts utils.FileOptions, in io.Reader) error {
|
||||
}
|
||||
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)
|
||||
if stat, err = os.Stat(realPath); err == nil {
|
||||
if 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)
|
||||
}
|
||||
}
|
||||
// Clear the cache for the removed directory (use realPath without re-fetching)
|
||||
indexing.RealPathCache.Delete(realPath)
|
||||
indexing.RealPathCache.Delete(realPath + ":isdir")
|
||||
// If file exists, its permissions will be preserved (O_TRUNC doesn't change permissions)
|
||||
}
|
||||
|
||||
// Open the file for writing (create if it doesn't exist, truncate if it does)
|
||||
// For new files: permissions are set to fileutils.PermFile (subject to umask, which is usually acceptable)
|
||||
// For existing files: permissions are preserved automatically (O_TRUNC doesn't change them)
|
||||
file, err := os.OpenFile(realPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -487,13 +601,7 @@ func WriteFile(opts utils.FileOptions, in io.Reader) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Explicitly set file permissions to bypass umask
|
||||
err = os.Chmod(realPath, fileutils.PermFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return RefreshIndex(opts.Source, opts.Path, false, false)
|
||||
return RefreshIndex(source, path, false, false)
|
||||
}
|
||||
|
||||
// getContent reads and returns the file content if it's considered an editable text file.
|
||||
@@ -523,9 +631,31 @@ func getContent(realPath string) (string, error) {
|
||||
// --- Start of new heuristic checks ---
|
||||
|
||||
if n > 0 {
|
||||
// Trim header to last complete UTF-8 rune to avoid false negatives
|
||||
// when the header read cuts off in the middle of a multi-byte sequence.
|
||||
// We decode runes from the end until we find a valid one, trimming
|
||||
// any incomplete sequences at the end.
|
||||
trimmedHeader := actualHeader
|
||||
for len(trimmedHeader) > 0 {
|
||||
lastRune, size := utf8.DecodeLastRune(trimmedHeader)
|
||||
if lastRune != utf8.RuneError {
|
||||
// Found a valid complete rune
|
||||
break
|
||||
}
|
||||
// RuneError occurred - this could be an incomplete sequence or invalid byte
|
||||
// Trim the last byte and try again
|
||||
if size == 1 && len(trimmedHeader) > 0 {
|
||||
trimmedHeader = trimmedHeader[:len(trimmedHeader)-1]
|
||||
} else {
|
||||
// Shouldn't happen, but break to avoid infinite loop
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Basic Check: Is the header valid UTF-8?
|
||||
// If not, it's unlikely an editable UTF-8 text file.
|
||||
if !utf8.Valid(actualHeader) {
|
||||
// Use trimmed header to avoid false negatives from truncated sequences
|
||||
if len(trimmedHeader) > 0 && !utf8.Valid(trimmedHeader) {
|
||||
return "", nil // Not an error, just not the text file we want
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
@@ -31,11 +32,14 @@ func MakeUserDirs(u *users.User, disableScopeChange bool) error {
|
||||
if filepath.Base(scope.Scope) != cleanedUserName && source.Config.CreateUserDir && !disableScopeChange {
|
||||
fullPath := filepath.Join(source.Path, scope.Scope, cleanedUserName)
|
||||
parentDir := filepath.Join(source.Path, scope.Scope)
|
||||
// validate that scope path exists
|
||||
// If parent directory doesn't exist and createUserDir is enabled, create it
|
||||
if !Exists(parentDir) {
|
||||
return fmt.Errorf("MakeUserDirs: scope path does not exist: %s", scope.Scope)
|
||||
if err := MakeUserDir(parentDir); err != nil {
|
||||
return fmt.Errorf("MakeUserDirs: failed to create parent scope directory: %s - %v", scope.Scope, err)
|
||||
}
|
||||
}
|
||||
scope.Scope = filepath.Join(scope.Scope, cleanedUserName)
|
||||
// Use JoinPathAsUnix to ensure scope remains in Unix format (forward slashes)
|
||||
scope.Scope = utils.JoinPathAsUnix(scope.Scope, cleanedUserName)
|
||||
err := MakeUserDir(fullPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
|
||||
|
||||
@@ -29,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)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -62,6 +62,13 @@ func CopyFile(source, dest string) error {
|
||||
|
||||
// copySingleFile handles copying a single file.
|
||||
func copySingleFile(source, dest string) error {
|
||||
// Get source file info to preserve permissions
|
||||
srcInfo, err := os.Stat(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sourcePerms := srcInfo.Mode().Perm()
|
||||
|
||||
// Open the source file.
|
||||
src, err := os.Open(source)
|
||||
if err != nil {
|
||||
@@ -75,8 +82,8 @@ func copySingleFile(source, dest string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create the destination file.
|
||||
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, PermFile)
|
||||
// Create the destination file with source permissions
|
||||
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, sourcePerms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -88,11 +95,13 @@ func copySingleFile(source, dest string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set the configured file permissions instead of copying from source
|
||||
err = os.Chmod(dest, PermFile)
|
||||
|
||||
// Preserve source file permissions
|
||||
// Handle chmod errors gracefully (e.g., in rootless containers where chmod may be restricted)
|
||||
err = os.Chmod(dest, sourcePerms)
|
||||
if err != nil {
|
||||
return err
|
||||
// Log but don't fail - chmod may be restricted in some environments
|
||||
// The file was copied successfully, so we continue
|
||||
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", dest, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -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]
|
||||
@@ -15,9 +15,6 @@ func validateAccessRules() {
|
||||
if store.Access == nil {
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("Validating and migrating access rules to new format...")
|
||||
|
||||
// Get all sources
|
||||
for sourcePath := range settings.Config.Server.SourceMap {
|
||||
// Clear cache before migration to ensure we get fresh data
|
||||
@@ -50,8 +47,6 @@ func validateAccessRules() {
|
||||
// Create the new path with trailing slash
|
||||
newPath := oldPath + "/"
|
||||
|
||||
logger.Debugf("Migrating access rule from '%s' to '%s'", oldPath, newPath)
|
||||
|
||||
// Migrate the rule to the new path
|
||||
if err := migrateAccessRule(sourcePath, oldPath, newPath, rule); err != nil {
|
||||
logger.Errorf("Failed to migrate rule from %s to %s: %v", oldPath, newPath, err)
|
||||
@@ -74,8 +69,6 @@ func validateAccessRules() {
|
||||
logger.Infof("Migrated %d access rules for source %s", migratedCount, sourcePath)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Access rules validation completed")
|
||||
}
|
||||
|
||||
// migrateAccessRule creates a new access rule with the new path format
|
||||
|
||||
+7
-4
@@ -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)
|
||||
@@ -350,8 +354,7 @@ func createConfig(configpath string) {
|
||||
|
||||
func generateYaml() {
|
||||
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
|
||||
devMode := os.Getenv("FILEBROWSER_DEVMODE") == "true"
|
||||
if generateConfig || devMode {
|
||||
if generateConfig || settings.Env.IsDevMode {
|
||||
logger.Info("Generating config.yaml")
|
||||
settings.GenerateYaml()
|
||||
}
|
||||
|
||||
+12
-8
@@ -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"
|
||||
@@ -79,11 +80,10 @@ func StartFilebrowser() {
|
||||
}
|
||||
|
||||
// Dev mode enables development features like template hot-reloading
|
||||
devMode := os.Getenv("FILEBROWSER_DEVMODE") == "true"
|
||||
_, err := os.Stat("http/dist")
|
||||
// In dev mode, always use filesystem assets. Otherwise, check if http/dist exists
|
||||
if !devMode {
|
||||
settings.Config.Server.EmbeddedFs = os.IsNotExist(err)
|
||||
if !settings.Env.IsDevMode {
|
||||
settings.Env.EmbeddedFs = os.IsNotExist(err)
|
||||
}
|
||||
|
||||
sourceList := []string{}
|
||||
@@ -95,7 +95,7 @@ func StartFilebrowser() {
|
||||
logger.Infof("Auth Methods : %v", settings.Config.Auth.AuthMethods)
|
||||
logger.Info(database)
|
||||
logger.Infof("Sources : %v", sourceList)
|
||||
logger.Debugf("Using Embedded FS : %v", settings.Config.Server.EmbeddedFs)
|
||||
logger.Debugf("Using Embedded FS : %v", settings.Env.EmbeddedFs)
|
||||
|
||||
serverConfig := settings.Config.Server
|
||||
swagInfo := docs.SwaggerInfo
|
||||
@@ -108,9 +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 {
|
||||
@@ -126,7 +127,9 @@ func StartFilebrowser() {
|
||||
case <-done:
|
||||
logger.Info("Server stopped unexpectedly. Shutting down...")
|
||||
}
|
||||
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
|
||||
if !*settings.Config.Server.CacheDirCleanup {
|
||||
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
|
||||
}
|
||||
|
||||
<-shutdownComplete // Ensure we don't exit prematurely
|
||||
// Wait for the server to stop
|
||||
@@ -139,9 +142,10 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
|
||||
}
|
||||
cacheDir := settings.Config.Server.CacheDir
|
||||
numWorkers := settings.Config.Server.NumImageProcessors
|
||||
ffpmpegPath := settings.Config.Integrations.Media.FfmpegPath
|
||||
ffmpeg.SetFFmpegPaths()
|
||||
|
||||
// setup disk cache
|
||||
err := preview.StartPreviewGenerator(numWorkers, ffpmpegPath, cacheDir)
|
||||
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
|
||||
if err != nil {
|
||||
logger.Fatalf("Error starting preview service: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// validateShareInfo migrates share links to add default sidebar links
|
||||
func validateShareInfo() {
|
||||
if store.Share == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get all shares
|
||||
shares, err := store.Share.All()
|
||||
if err != nil {
|
||||
logger.Debugf("No shares found or error getting shares: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
migratedCount := 0
|
||||
for _, link := range shares {
|
||||
// Check if this share needs migration (version not set or 0)
|
||||
if link.Version == 0 {
|
||||
// Add default sidebar links
|
||||
link.SidebarLinks = []users.SidebarLink{
|
||||
{
|
||||
Name: "Share QR Code and Info",
|
||||
Category: "shareInfo",
|
||||
Target: "#",
|
||||
Icon: "qr_code",
|
||||
},
|
||||
{
|
||||
Name: "Download",
|
||||
Category: "download",
|
||||
Target: "#",
|
||||
Icon: "download",
|
||||
},
|
||||
}
|
||||
|
||||
// Set version to 1 to indicate migration is complete
|
||||
link.Version = 1
|
||||
|
||||
// Save the updated share
|
||||
if err := store.Share.Save(link); err != nil {
|
||||
logger.Errorf("Failed to migrate share %s: %v", link.Hash, err)
|
||||
continue
|
||||
}
|
||||
|
||||
migratedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if migratedCount > 0 {
|
||||
logger.Infof("Migrated %d share links with default sidebar links", migratedCount)
|
||||
}
|
||||
}
|
||||
+48
-14
@@ -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
|
||||
}
|
||||
|
||||
@@ -23,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")
|
||||
)
|
||||
@@ -103,7 +103,9 @@ func validateOidcAuth() error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a custom HTTP client with proper User-Agent to avoid being blocked by bot protection
|
||||
transport := &http.Transport{}
|
||||
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 {
|
||||
|
||||
+304
-218
@@ -1,6 +1,7 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/version"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
@@ -32,6 +34,9 @@ func Initialize(configFile string) {
|
||||
time.Sleep(5 * time.Second) // allow sleep time before exiting to give docker/kubernetes time before restarting
|
||||
logger.Fatal(err.Error())
|
||||
}
|
||||
setupLogging()
|
||||
// setup logging first to ensure we log any errors
|
||||
setupEnv()
|
||||
err = ValidateConfig(Config)
|
||||
if err != nil {
|
||||
errmsg := "The provided config file failed validation. "
|
||||
@@ -43,12 +48,29 @@ func Initialize(configFile string) {
|
||||
logger.Fatal(err.Error())
|
||||
}
|
||||
setupFs()
|
||||
setupLogging()
|
||||
setupServer()
|
||||
setupAuth(false)
|
||||
setupSources(false)
|
||||
setupUrls()
|
||||
setupFrontend(false)
|
||||
setupVideoPreview()
|
||||
setupMedia()
|
||||
}
|
||||
|
||||
func setupServer() {
|
||||
if Config.Server.ListenAddress == "" {
|
||||
Config.Server.ListenAddress = "0.0.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
func setupEnv() {
|
||||
Env.IsPlaywright = os.Getenv("FILEBROWSER_PLAYWRIGHT_TEST") == "true"
|
||||
if Env.IsPlaywright {
|
||||
logger.Warning("Running in playwright test mode. This is not recommended for production.")
|
||||
}
|
||||
Env.IsDevMode = os.Getenv("FILEBROWSER_DEVMODE") == "true"
|
||||
if Env.IsDevMode {
|
||||
logger.Warning("Running in dev mode. This is not recommended for production.")
|
||||
}
|
||||
}
|
||||
|
||||
func setupFs() {
|
||||
@@ -64,9 +86,23 @@ func setupFs() {
|
||||
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
|
||||
}
|
||||
@@ -86,7 +122,13 @@ 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{}
|
||||
if Config.Frontend.Styling.CustomThemes == nil {
|
||||
Config.Frontend.Styling.CustomThemes = map[string]CustomTheme{}
|
||||
@@ -112,12 +154,11 @@ func setupFrontend(generate bool) {
|
||||
if !ok {
|
||||
addCustomTheme("default", "The default theme", "")
|
||||
}
|
||||
|
||||
// Load custom favicon if configured
|
||||
loadCustomFavicon()
|
||||
}
|
||||
|
||||
func setupVideoPreview() {
|
||||
func setupMedia() {
|
||||
// If VideoPreview is not initialized, initialize with all types enabled
|
||||
if Config.Integrations.Media.Convert.VideoPreview == nil {
|
||||
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
|
||||
@@ -155,18 +196,6 @@ func setupVideoPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
func getRealPath(path string) string {
|
||||
realPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
logger.Fatalf("could not find configured source path: %v", err)
|
||||
}
|
||||
// check path exists
|
||||
if _, err = os.Stat(realPath); os.IsNotExist(err) {
|
||||
logger.Fatalf("configured source path does not exist: %v", realPath)
|
||||
}
|
||||
return realPath
|
||||
}
|
||||
|
||||
func setupSources(generate bool) {
|
||||
if len(Config.Server.Sources) == 0 {
|
||||
logger.Fatal("There are no `server.sources` configured. If you have `server.root` configured, please update the config and add at least one `server.sources` with a `path` configured.")
|
||||
@@ -175,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]
|
||||
@@ -194,6 +230,7 @@ func setupSources(generate bool) {
|
||||
}
|
||||
}
|
||||
modifyExcludeInclude(source)
|
||||
setConditionals(source)
|
||||
if source.Config.DefaultUserScope == "" {
|
||||
source.Config.DefaultUserScope = "/"
|
||||
}
|
||||
@@ -209,14 +246,12 @@ func setupSources(generate bool) {
|
||||
Config.Server.Sources[0].Config.DefaultEnabled = true
|
||||
}
|
||||
for _, sourcePathOnly := range Config.Server.Sources {
|
||||
var realPath string
|
||||
absPath := sourcePathOnly.Path
|
||||
if generate {
|
||||
// When generating, skip path validation and use the already-set path
|
||||
realPath = sourcePathOnly.Path
|
||||
} else {
|
||||
realPath = getRealPath(sourcePathOnly.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 {
|
||||
@@ -320,67 +355,65 @@ func setupLogging() {
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfigWithDefaults(configFile string, isGenerate bool) error {
|
||||
Config = setDefaults(isGenerate)
|
||||
// Open and read the YAML file
|
||||
yamlFile, err := os.Open(configFile)
|
||||
if err != nil {
|
||||
func loadConfigWithDefaults(configFile string, generate bool) error {
|
||||
Config = setDefaults(generate)
|
||||
|
||||
// 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{
|
||||
{
|
||||
Path: ".",
|
||||
Config: SourceConfig{
|
||||
DefaultEnabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
loadEnvConfig()
|
||||
return nil
|
||||
}
|
||||
defer yamlFile.Close()
|
||||
|
||||
// Get the directory containing the config file for reference resolution
|
||||
configDir := filepath.Dir(configFile)
|
||||
if configDir == "" {
|
||||
configDir = "."
|
||||
// Try multi-file config first (combine all YAML files in the directory)
|
||||
combinedYAML, err := combineYAMLFiles(configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to combine YAML files: %v", err)
|
||||
}
|
||||
|
||||
// Create decoder with ReferenceDirs to support multi-config files
|
||||
// This enables YAML anchors and references to work across multiple files
|
||||
decoder := yaml.NewDecoder(yamlFile,
|
||||
yaml.DisallowUnknownField(),
|
||||
yaml.ReferenceDirs(configDir),
|
||||
)
|
||||
// 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 {
|
||||
return fmt.Errorf("error parsing YAML data: %v", err)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Fallback to old method if multi-config parsing fails
|
||||
logger.Debugf("Multi-config parsing failed, falling back to single file parsing: %v", err)
|
||||
|
||||
// Reset file position and try old method
|
||||
if _, seekErr := yamlFile.Seek(0, 0); seekErr != nil {
|
||||
return fmt.Errorf("failed to read yaml file: %v", seekErr)
|
||||
}
|
||||
stat, statErr := yamlFile.Stat()
|
||||
if statErr != nil {
|
||||
return statErr
|
||||
}
|
||||
yamlData := make([]byte, stat.Size())
|
||||
_, readErr := yamlFile.Read(yamlData)
|
||||
if readErr != nil && configFile != "config.yaml" {
|
||||
return fmt.Errorf("could not load specified config file: %v", readErr.Error())
|
||||
}
|
||||
if readErr != nil {
|
||||
logger.Warningf("Could not load config file '%v', using default settings: %v", configFile, readErr)
|
||||
}
|
||||
|
||||
// Use simple decoder without ReferenceDirs
|
||||
fallbackErr := yaml.NewDecoder(strings.NewReader(string(yamlData)), yaml.DisallowUnknownField()).Decode(&Config)
|
||||
if fallbackErr != nil {
|
||||
return fmt.Errorf("error unmarshaling YAML data: %v", fallbackErr)
|
||||
}
|
||||
return fmt.Errorf("error unmarshaling YAML data: %v", err)
|
||||
}
|
||||
|
||||
loadEnvConfig()
|
||||
@@ -481,6 +514,14 @@ func setDefaults(generate bool) Settings {
|
||||
database := os.Getenv("FILEBROWSER_DATABASE")
|
||||
if database == "" {
|
||||
database = "database.db"
|
||||
} else {
|
||||
// check if database file exists
|
||||
if _, err := os.Stat(database); os.IsNotExist(err) {
|
||||
database = "database.db"
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(database); os.IsNotExist(err) {
|
||||
logger.Warning("database file could not be found. If this is unexpected, the default path is `./database.db`, but it can be configured in the config file under `server.database`.")
|
||||
}
|
||||
s := Settings{
|
||||
Server: Server{
|
||||
@@ -492,6 +533,7 @@ func setDefaults(generate bool) Settings {
|
||||
NameToSource: map[string]*Source{},
|
||||
MaxArchiveSizeGB: 50,
|
||||
CacheDir: "tmp",
|
||||
CacheDirCleanup: boolPtr(true),
|
||||
Filesystem: Filesystem{
|
||||
CreateFilePermission: "644",
|
||||
CreateDirectoryPermission: "755",
|
||||
@@ -513,21 +555,32 @@ func setDefaults(generate bool) Settings {
|
||||
},
|
||||
|
||||
UserDefaults: UserDefaults{
|
||||
DisableOnlyOfficeExt: ".md .txt .pdf",
|
||||
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
|
||||
StickySidebar: true,
|
||||
LockPassword: false,
|
||||
ShowHidden: false,
|
||||
DarkMode: true,
|
||||
DarkMode: boolPtr(true),
|
||||
DisableSettings: false,
|
||||
ViewMode: "normal",
|
||||
Locale: "en",
|
||||
GallerySize: 3,
|
||||
ThemeColor: "var(--blue)",
|
||||
Permissions: users.Permissions{
|
||||
Modify: false,
|
||||
Share: false,
|
||||
Admin: false,
|
||||
Api: false,
|
||||
Permissions: UserDefaultsPermissions{
|
||||
Modify: false,
|
||||
Share: false,
|
||||
Admin: false,
|
||||
Api: false,
|
||||
Download: boolPtr(true), // defaults to true
|
||||
},
|
||||
Preview: UserDefaultsPreview{
|
||||
HighQuality: boolPtr(true),
|
||||
Image: boolPtr(true),
|
||||
Video: boolPtr(true),
|
||||
MotionVideoPreview: boolPtr(true),
|
||||
Office: boolPtr(true),
|
||||
PopUp: boolPtr(true),
|
||||
AutoplayMedia: boolPtr(true),
|
||||
Folder: boolPtr(true),
|
||||
},
|
||||
FileLoading: users.FileLoading{
|
||||
MaxConcurrent: 10,
|
||||
@@ -549,183 +602,216 @@ func setDefaults(generate bool) Settings {
|
||||
return s
|
||||
}
|
||||
|
||||
func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, error) {
|
||||
if len(scopes) == 0 {
|
||||
return Config.UserDefaults.DefaultScopes, nil
|
||||
// validateCustomImage validates a custom image file path and returns the absolute path or error
|
||||
func validateCustomImage(configPath, imageName string, maxSize int64, allowedFormats []string) (absolutePath string, err error) {
|
||||
// Get absolute path
|
||||
absolutePath, err = filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not resolve path: %w", err)
|
||||
}
|
||||
newScopes := []users.SourceScope{}
|
||||
for _, scope := range scopes {
|
||||
|
||||
// first check if its already a path name and keep it
|
||||
source, ok := Config.Server.SourceMap[scope.Name]
|
||||
if ok {
|
||||
if scope.Scope == "" {
|
||||
scope.Scope = source.Config.DefaultUserScope
|
||||
}
|
||||
if !strings.HasPrefix(scope.Scope, "/") {
|
||||
scope.Scope = "/" + scope.Scope
|
||||
}
|
||||
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
|
||||
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
|
||||
}
|
||||
newScopes = append(newScopes, users.SourceScope{
|
||||
Name: source.Path, // backend name is path
|
||||
Scope: scope.Scope,
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Check if 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", ".svg":
|
||||
// Valid favicon formats
|
||||
default:
|
||||
logger.Warningf("Unsupported favicon format '%v', supported formats: .ico, .png, .svg", 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)
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package settings
|
||||
|
||||
func CanConvertImage(ext string) bool {
|
||||
return Config.Integrations.Media.FfmpegPath != "" && Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
|
||||
return MediaEnabled() && Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
|
||||
}
|
||||
|
||||
func CanConvertVideo(ext string) bool {
|
||||
return Config.Integrations.Media.FfmpegPath != "" && Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
|
||||
return MediaEnabled() && Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
|
||||
}
|
||||
|
||||
func MediaEnabled() bool {
|
||||
return Env.FFmpegPath != "" && Env.FFprobePath != ""
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -577,7 +577,8 @@ 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] {
|
||||
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 == "" {
|
||||
@@ -705,6 +706,13 @@ func GenerateYaml() {
|
||||
Config.Server.Sources = []*Source{
|
||||
{
|
||||
Path: ".",
|
||||
Config: SourceConfig{
|
||||
Conditionals: ConditionalFilter{
|
||||
ItemRules: []ConditionalIndexConfig{
|
||||
{}, // Add empty sample rule to show all fields in generated YAML
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -719,6 +727,7 @@ func GenerateYaml() {
|
||||
|
||||
setupSources(true)
|
||||
setupUrls()
|
||||
setupMedia()
|
||||
setupFrontend(true)
|
||||
|
||||
// Restore original paths so the YAML output has the correct paths, not the placeholder
|
||||
|
||||
@@ -9,10 +9,11 @@ import (
|
||||
func TestGenerateConfigYaml_Basic(t *testing.T) {
|
||||
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
|
||||
// Test using the actual source directory structure
|
||||
trueVal := true
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
Locale: "es", // Non-default value
|
||||
DarkMode: true, // Default value
|
||||
DarkMode: &trueVal, // Default value
|
||||
DisableOfficePreviewExt: ".docx .xlsx", // This field is deprecated
|
||||
},
|
||||
Auth: Auth{
|
||||
@@ -104,6 +105,11 @@ func TestGenerateConfigYaml_Basic(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's null (should NOT be quoted - it's a YAML literal for nil)
|
||||
if value == "null" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Everything else should be a quoted string
|
||||
if !strings.HasPrefix(value, "\"") || !strings.HasSuffix(value, "\"") {
|
||||
// Add debug info to see what's wrong
|
||||
|
||||
@@ -108,6 +108,11 @@ type Auth struct {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's null (should NOT be quoted - it's a YAML literal for nil)
|
||||
if value == "null" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Everything else should be a quoted string
|
||||
if !strings.HasPrefix(value, "\"") || !strings.HasSuffix(value, "\"") {
|
||||
t.Errorf("String value should be quoted: %s (value: '%s')", line, value)
|
||||
@@ -204,10 +209,11 @@ type UserDefaults struct {
|
||||
sourcePath := tmpFile[:strings.LastIndex(tmpFile, "/")]
|
||||
|
||||
// Create a config with both default and non-default values
|
||||
trueVal := true
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
DarkMode: true, // This matches default
|
||||
Locale: "es", // This differs from default ("en")
|
||||
DarkMode: &trueVal, // This matches default
|
||||
Locale: "es", // This differs from default ("en")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -278,10 +284,11 @@ func TestGenerateConfigYaml_CommentsOnOff(t *testing.T) {
|
||||
func TestGenerateConfigYaml_IntegrationTest(t *testing.T) {
|
||||
reNumber := regexp.MustCompile(`^-?\d+(\.\d+)?$`)
|
||||
// Comprehensive test with all features
|
||||
trueVal := true
|
||||
config := &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
Locale: "es", // Non-default
|
||||
DarkMode: true, // Default
|
||||
DarkMode: &trueVal, // Default
|
||||
DisableOfficePreviewExt: ".docx .xlsx", // This is deprecated
|
||||
},
|
||||
Auth: Auth{
|
||||
@@ -400,8 +407,8 @@ func TestGenerateConfigYaml_IntegrationTest(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip arrays, objects, and empty values
|
||||
if value == "" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") || strings.Contains(value, ":") {
|
||||
// Skip arrays, objects, empty values, and null
|
||||
if value == "" || value == "null" || strings.HasPrefix(value, "[") || strings.HasPrefix(value, "{") || strings.Contains(value, ":") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -433,8 +440,8 @@ func TestGenerateConfigYaml_EdgeCases(t *testing.T) {
|
||||
name: "only_defaults",
|
||||
config: &Settings{
|
||||
UserDefaults: UserDefaults{
|
||||
DarkMode: true, // default value
|
||||
Locale: "en", // default value
|
||||
DarkMode: boolPtr(true), // default value
|
||||
Locale: "en", // default value
|
||||
},
|
||||
},
|
||||
desc: "Config with only default values",
|
||||
|
||||
@@ -29,7 +29,8 @@ simple_auth: &simple_auth
|
||||
enabled: true
|
||||
minLength: 8
|
||||
`
|
||||
anchorsPath := filepath.Join(testDir, "anchors.yaml")
|
||||
// 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)
|
||||
}
|
||||
@@ -61,7 +62,8 @@ userDefaults:
|
||||
}
|
||||
|
||||
// Test loading the multi-config setup
|
||||
err := loadConfigWithDefaults(mainPath, false)
|
||||
// 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)
|
||||
}
|
||||
@@ -128,7 +130,8 @@ frontend:
|
||||
}
|
||||
|
||||
// Test that fallback works for simple configs
|
||||
err := loadConfigWithDefaults(configPath, false)
|
||||
// Use generate=true to skip filesystem validation
|
||||
err := loadConfigWithDefaults(configPath, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load simple config: %v", err)
|
||||
}
|
||||
@@ -163,7 +166,8 @@ base_permissions: &base_permissions
|
||||
admin: false
|
||||
api: false
|
||||
`
|
||||
anchorsPath := filepath.Join(testDir, "anchors.yaml")
|
||||
// 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)
|
||||
}
|
||||
@@ -194,7 +198,8 @@ frontend:
|
||||
}
|
||||
|
||||
// Test loading the nested reference setup
|
||||
err := loadConfigWithDefaults(mainPath, false)
|
||||
// Use generate=true to skip filesystem validation
|
||||
err := loadConfigWithDefaults(mainPath, true)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load nested config: %v", err)
|
||||
}
|
||||
@@ -209,7 +214,7 @@ frontend:
|
||||
}
|
||||
|
||||
// Verify user defaults
|
||||
if Config.UserDefaults.DarkMode != true {
|
||||
if Config.UserDefaults.DarkMode == nil || !*Config.UserDefaults.DarkMode {
|
||||
t.Errorf("Expected dark mode true, got %v", Config.UserDefaults.DarkMode)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,33 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
)
|
||||
|
||||
// boolPtr returns a pointer to a bool value
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
// boolValueOrDefault returns the value of a bool pointer, or the default if nil
|
||||
func boolValueOrDefault(ptr *bool, defaultValue bool) bool {
|
||||
if ptr != nil {
|
||||
return *ptr
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// ConvertPermissionsToUsers converts UserDefaultsPermissions to users.Permissions
|
||||
func ConvertPermissionsToUsers(p UserDefaultsPermissions) users.Permissions {
|
||||
return users.Permissions{
|
||||
Api: p.Api,
|
||||
Admin: p.Admin,
|
||||
Modify: p.Modify,
|
||||
Share: p.Share,
|
||||
Realtime: p.Realtime,
|
||||
Delete: p.Delete,
|
||||
Create: p.Create,
|
||||
Download: boolValueOrDefault(p.Download, true),
|
||||
}
|
||||
}
|
||||
|
||||
const DefaultUsersHomeBasePath = "/users"
|
||||
|
||||
// AuthMethod describes an authentication method.
|
||||
@@ -41,12 +68,36 @@ func AdminPerms() users.Permissions {
|
||||
func ApplyUserDefaults(u *users.User) {
|
||||
u.StickySidebar = Config.UserDefaults.StickySidebar
|
||||
u.DisableSettings = Config.UserDefaults.DisableSettings
|
||||
u.DarkMode = Config.UserDefaults.DarkMode
|
||||
|
||||
// Handle DarkMode with default - dereference pointer from config
|
||||
u.DarkMode = boolValueOrDefault(Config.UserDefaults.DarkMode, true)
|
||||
|
||||
u.Locale = Config.UserDefaults.Locale
|
||||
u.ViewMode = Config.UserDefaults.ViewMode
|
||||
u.SingleClick = Config.UserDefaults.SingleClick
|
||||
u.Permissions = Config.UserDefaults.Permissions
|
||||
u.Preview = Config.UserDefaults.Preview
|
||||
|
||||
// Handle Permissions - convert from pointer-based defaults to regular bools
|
||||
u.Permissions.Api = Config.UserDefaults.Permissions.Api
|
||||
u.Permissions.Admin = Config.UserDefaults.Permissions.Admin
|
||||
u.Permissions.Modify = Config.UserDefaults.Permissions.Modify
|
||||
u.Permissions.Share = Config.UserDefaults.Permissions.Share
|
||||
u.Permissions.Realtime = Config.UserDefaults.Permissions.Realtime
|
||||
u.Permissions.Delete = Config.UserDefaults.Permissions.Delete
|
||||
u.Permissions.Create = Config.UserDefaults.Permissions.Create
|
||||
u.Permissions.Download = boolValueOrDefault(Config.UserDefaults.Permissions.Download, true)
|
||||
|
||||
// Handle Preview - convert from pointer-based defaults to regular bools
|
||||
u.Preview.DisableHideSidebar = Config.UserDefaults.Preview.DisableHideSidebar
|
||||
u.Preview.HighQuality = boolValueOrDefault(Config.UserDefaults.Preview.HighQuality, true)
|
||||
u.Preview.Image = boolValueOrDefault(Config.UserDefaults.Preview.Image, true)
|
||||
u.Preview.Video = boolValueOrDefault(Config.UserDefaults.Preview.Video, true)
|
||||
u.Preview.MotionVideoPreview = boolValueOrDefault(Config.UserDefaults.Preview.MotionVideoPreview, true)
|
||||
u.Preview.Office = boolValueOrDefault(Config.UserDefaults.Preview.Office, true)
|
||||
u.Preview.PopUp = boolValueOrDefault(Config.UserDefaults.Preview.PopUp, true)
|
||||
u.Preview.AutoplayMedia = boolValueOrDefault(Config.UserDefaults.Preview.AutoplayMedia, true)
|
||||
u.Preview.DefaultMediaPlayer = Config.UserDefaults.Preview.DefaultMediaPlayer
|
||||
u.Preview.Folder = boolValueOrDefault(Config.UserDefaults.Preview.Folder, true)
|
||||
|
||||
u.ShowHidden = Config.UserDefaults.ShowHidden
|
||||
u.DateFormat = Config.UserDefaults.DateFormat
|
||||
u.DisableViewingExt = Config.UserDefaults.DisableViewingExt
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
type AllowedMethods string
|
||||
|
||||
var Env Environment
|
||||
|
||||
const (
|
||||
ProxyAuth AllowedMethods = "proxyAuth"
|
||||
NoAuth AllowedMethods = "noAuth"
|
||||
@@ -20,6 +22,22 @@ 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" yaml:"minSearchLength"` // minimum length of search query to begin searching (default: 3)
|
||||
DisableUpdateCheck bool `json:"disableUpdateCheck"` // disables backend update check service
|
||||
@@ -31,20 +49,20 @@ type Server struct {
|
||||
DisableResize bool `json:"disablePreviewResize"` // disable resizing of previews for faster loading over slow connections
|
||||
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
|
||||
Port int `json:"port"` // port to listen on
|
||||
ListenAddress string `json:"listen"` // address to listen on (default: 0.0.0.0)
|
||||
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
|
||||
Logging []LogConfig `json:"logging" yaml:"logging"`
|
||||
Database string `json:"database"` // path to the database file
|
||||
Sources []*Source `json:"sources" validate:"required,dive"`
|
||||
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
|
||||
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
|
||||
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
|
||||
Filesystem Filesystem `json:"filesystem"` // filesystem settings
|
||||
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
|
||||
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
|
||||
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
|
||||
CacheDirCleanup *bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (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
|
||||
EmbeddedFs bool `json:"-"` // used internally if compiled with embedded fs support
|
||||
SourceMap map[string]*Source `json:"-" validate:"omitempty"` // uses realpath as key
|
||||
NameToSource map[string]*Source `json:"-" validate:"omitempty"` // uses name as key
|
||||
}
|
||||
|
||||
type Filesystem struct {
|
||||
@@ -160,37 +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
|
||||
IndexAlbumArt bool `json:"indexAlbumArt"` // deprecated: always enabled since 0.8.6
|
||||
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"
|
||||
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-"
|
||||
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 {
|
||||
@@ -202,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
|
||||
}
|
||||
@@ -226,38 +272,64 @@ type ExternalLink struct {
|
||||
Url string `json:"url" validate:"required"` // the url to link to
|
||||
}
|
||||
|
||||
// UserDefaultsPreview holds preview settings with pointer types for defaults
|
||||
type UserDefaultsPreview struct {
|
||||
DisableHideSidebar bool `json:"disableHideSidebar"` // keep sidebar open when previewing files
|
||||
HighQuality *bool `json:"highQuality"` // use high quality thumbnails
|
||||
Image *bool `json:"image"` // show thumbnails for image files
|
||||
Video *bool `json:"video"` // show thumbnails for video files
|
||||
MotionVideoPreview *bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
|
||||
Office *bool `json:"office"` // show thumbnails for office files
|
||||
PopUp *bool `json:"popup"` // show larger popup preview when hovering over thumbnail
|
||||
AutoplayMedia *bool `json:"autoplayMedia"` // autoplay media files in preview
|
||||
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable the styled feature-rich media player for browser default
|
||||
Folder *bool `json:"folder"` // show thumbnails for folders that have previewable contents
|
||||
}
|
||||
|
||||
// UserDefaultsPermissions holds permission settings with pointer types for defaults
|
||||
type UserDefaultsPermissions struct {
|
||||
Api bool `json:"api"` // allow api access
|
||||
Admin bool `json:"admin"` // allow admin access
|
||||
Modify bool `json:"modify"` // allow modifying files
|
||||
Share bool `json:"share"` // allow sharing files
|
||||
Realtime bool `json:"realtime"` // allow realtime updates
|
||||
Delete bool `json:"delete"` // allow deleting files
|
||||
Create bool `json:"create"` // allow creating or uploading files
|
||||
Download *bool `json:"download"` // allow downloading files
|
||||
}
|
||||
|
||||
// UserDefaults is a type that holds the default values
|
||||
// for some fields on User.
|
||||
type UserDefaults struct {
|
||||
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
|
||||
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
|
||||
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
|
||||
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
|
||||
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
|
||||
DarkMode bool `json:"darkMode"` // should dark mode be enabled
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
|
||||
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
|
||||
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
|
||||
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
|
||||
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
|
||||
DisableSettings bool `json:"disableSettings,omitempty"` // disable the user from viewing the settings page
|
||||
Preview users.Preview `json:"preview"`
|
||||
DefaultScopes []users.SourceScope `json:"-"`
|
||||
Permissions users.Permissions `json:"permissions"`
|
||||
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
FileLoading users.FileLoading `json:"fileLoading"` // upload and download settings
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated: use disablePreviewExt instead
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor for
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
DefaultLandingPage string `json:"defaultLandingPage"` // default landing page to use if no redirect is specified: eg. /files/mysource/mysubpath, /settings, etc.
|
||||
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
|
||||
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
|
||||
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
|
||||
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
|
||||
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
|
||||
DarkMode *bool `json:"darkMode"` // should dark mode be enabled
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
|
||||
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
|
||||
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
|
||||
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
|
||||
LockPassword bool `json:"lockPassword"` // disable the user from changing their password
|
||||
DisableSettings bool `json:"disableSettings,omitempty"` // disable the user from viewing the settings page
|
||||
Preview UserDefaultsPreview `json:"preview"`
|
||||
DefaultScopes []users.SourceScope `json:"-"`
|
||||
Permissions UserDefaultsPermissions `json:"permissions"`
|
||||
LoginMethod string `json:"loginMethod,omitempty"` // login method to use: eg. password, proxy, oidc
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications banner for admin users
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
FileLoading users.FileLoading `json:"fileLoading"` // upload and download settings
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated: use disablePreviewExt instead
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // list of file extensions to disable onlyoffice editor for
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
DefaultLandingPage string `json:"defaultLandingPage"` // deprecated: determined by sidebar link order since 1.1.0
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
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)
|
||||
return string(content), nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -48,11 +48,11 @@ type FileOptions struct {
|
||||
Path string // realpath
|
||||
Source string
|
||||
IsDir bool
|
||||
Modify bool
|
||||
Expand bool
|
||||
ReadHeader bool
|
||||
Content bool
|
||||
Recursive bool // whether to recursively index directories
|
||||
Metadata bool // whether to get metadata
|
||||
ExtractEmbeddedSubtitles bool // whether to extract embedded subtitles from media files
|
||||
AlbumArt bool // whether to get album art from media files
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
math "math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
@@ -128,3 +129,23 @@ func Ternary[T any](cond bool, vtrue, vfalse T) T {
|
||||
}
|
||||
return vfalse
|
||||
}
|
||||
|
||||
// NormalizeRulePath ensures directory paths have trailing slashes for consistent rule storage
|
||||
func AddTrailingSlashIfNotExists(indexPath string) string {
|
||||
// Root path stays as "/"
|
||||
if indexPath == "/" {
|
||||
return "/"
|
||||
}
|
||||
// For all other paths, ensure they have trailing slashes
|
||||
if !strings.HasSuffix(indexPath, "/") {
|
||||
return indexPath + "/"
|
||||
}
|
||||
return indexPath
|
||||
}
|
||||
|
||||
func CheckPathExists(realPath string) bool {
|
||||
if _, err := os.Stat(realPath); os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
+41
-12
@@ -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
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -63,6 +64,7 @@ type FrontendAccessRule struct {
|
||||
Deny FrontendRuleSet `json:"deny"`
|
||||
Allow FrontendRuleSet `json:"allow"`
|
||||
SourceDenyDefault bool `json:"sourceDenyDefault"`
|
||||
PathExists bool `json:"pathExists"`
|
||||
}
|
||||
|
||||
// GroupMap maps group names to a set of usernames.
|
||||
@@ -197,24 +199,11 @@ func (s *Storage) RemoveRuleByPath(sourcePath, indexPath string) {
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeRulePath ensures directory paths have trailing slashes for consistent rule storage
|
||||
func normalizeRulePath(indexPath string) string {
|
||||
// Root path stays as "/"
|
||||
if indexPath == "/" {
|
||||
return "/"
|
||||
}
|
||||
// For all other paths, ensure they have trailing slashes
|
||||
if !strings.HasSuffix(indexPath, "/") {
|
||||
return indexPath + "/"
|
||||
}
|
||||
return indexPath
|
||||
}
|
||||
|
||||
// getOrCreateRuleNL ensures a rule exists for the given source and index path.
|
||||
// The caller must hold the lock.
|
||||
func (s *Storage) getOrCreateRuleNL(sourcePath, indexPath string) *AccessRule {
|
||||
// Normalize the path to ensure consistent rule storage
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
if _, ok := s.AllRules[sourcePath]; !ok {
|
||||
s.AllRules[sourcePath] = make(RuleMap)
|
||||
}
|
||||
@@ -412,7 +401,7 @@ func (s *Storage) getRuleAtExactPath(sourcePath, indexPath string) (*AccessRule,
|
||||
return nil, false
|
||||
}
|
||||
// Normalize the path to ensure consistent rule lookup
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rule, ok := rulesBySource[normalizedPath]
|
||||
return rule, ok
|
||||
}
|
||||
@@ -471,8 +460,12 @@ func (s *Storage) GetFrontendRules(sourcePath, indexPath string) (FrontendAccess
|
||||
sourceDenyDefault = sourceInfo.Config.DenyByDefault
|
||||
}
|
||||
|
||||
// Check if path exists on filesystem
|
||||
pathExists := utils.CheckPathExists(filepath.Join(sourcePath, indexPath))
|
||||
|
||||
frontendRules := FrontendAccessRule{
|
||||
SourceDenyDefault: sourceDenyDefault,
|
||||
PathExists: pathExists,
|
||||
Deny: FrontendRuleSet{
|
||||
Users: make([]string, 0),
|
||||
Groups: make([]string, 0),
|
||||
@@ -532,10 +525,14 @@ func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule,
|
||||
// This ensures consistency between internal storage and frontend display
|
||||
frontendPath := indexPath
|
||||
|
||||
// Check if path exists on filesystem
|
||||
pathExists := utils.CheckPathExists(filepath.Join(sourcePath, indexPath))
|
||||
|
||||
// Convert AccessRule to FrontendAccessRule
|
||||
frontendRules[frontendPath] = FrontendAccessRule{
|
||||
DenyAll: rule.DenyAll,
|
||||
SourceDenyDefault: sourceDenyDefault,
|
||||
PathExists: pathExists,
|
||||
Deny: FrontendRuleSet{
|
||||
Users: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Users))),
|
||||
Groups: utils.NonNilSlice(slices.Collect(maps.Keys(rule.Deny.Groups))),
|
||||
@@ -653,7 +650,7 @@ func (s *Storage) RemoveUserFromGroup(group, username string) error {
|
||||
func (s *Storage) RemoveAllowUser(sourcePath, indexPath, username string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -686,7 +683,7 @@ func (s *Storage) RemoveAllowUser(sourcePath, indexPath, username string) (bool,
|
||||
func (s *Storage) RemoveAllowGroup(sourcePath, indexPath, groupname string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -719,7 +716,7 @@ func (s *Storage) RemoveAllowGroup(sourcePath, indexPath, groupname string) (boo
|
||||
func (s *Storage) RemoveDenyUser(sourcePath, indexPath, username string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -752,7 +749,7 @@ func (s *Storage) RemoveDenyUser(sourcePath, indexPath, username string) (bool,
|
||||
func (s *Storage) RemoveDenyGroup(sourcePath, indexPath, groupname string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -785,7 +782,7 @@ func (s *Storage) RemoveDenyGroup(sourcePath, indexPath, groupname string) (bool
|
||||
func (s *Storage) RemoveDenyAll(sourcePath, indexPath string) (bool, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
normalizedPath := normalizeRulePath(indexPath)
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rule, ok := s.AllRules[sourcePath][normalizedPath]
|
||||
if !ok {
|
||||
return false, nil
|
||||
@@ -1116,3 +1113,206 @@ func (s *Storage) HasAnyVisibleItems(sourcePath, parentPath string, itemNames []
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// RemoveUserCascade removes a user from either the allow or deny list for a given path and all its subpaths.
|
||||
// This is used for cascade delete operations when deleting user access from a directory tree.
|
||||
// The allow parameter determines which list to remove from: true for allow list, false for deny list.
|
||||
func (s *Storage) RemoveUserCascade(sourcePath, indexPath, username string, allow bool) (int, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
changed := false
|
||||
removedCount := 0
|
||||
|
||||
// Iterate through all rules for this source
|
||||
for rulePath, rule := range rulesBySource {
|
||||
// Check if this rule path matches or is a subpath of the target path
|
||||
if rulePath == normalizedPath || strings.HasPrefix(rulePath, normalizedPath) {
|
||||
if allow {
|
||||
// Remove user from allow list only
|
||||
if _, exists := rule.Allow.Users[username]; exists {
|
||||
delete(rule.Allow.Users, username)
|
||||
changed = true
|
||||
removedCount++
|
||||
}
|
||||
} else {
|
||||
// Remove user from deny list only
|
||||
if _, exists := rule.Deny.Users[username]; exists {
|
||||
delete(rule.Deny.Users, username)
|
||||
changed = true
|
||||
removedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// If rule is now empty, mark it for deletion
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 &&
|
||||
len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 && !rule.DenyAll {
|
||||
delete(s.AllRules[sourcePath], rulePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no rules left for this source, remove the source entry
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
|
||||
if changed {
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
return removedCount, s.SaveToDB()
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// RemoveGroupCascade removes a group from either the allow or deny list for a given path and all its subpaths.
|
||||
// This is used for cascade delete operations when deleting group access from a directory tree.
|
||||
// The allow parameter determines which list to remove from: true for allow list, false for deny list.
|
||||
func (s *Storage) RemoveGroupCascade(sourcePath, indexPath, groupname string, allow bool) (int, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
normalizedPath := utils.AddTrailingSlashIfNotExists(indexPath)
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
changed := false
|
||||
removedCount := 0
|
||||
|
||||
// Iterate through all rules for this source
|
||||
for rulePath, rule := range rulesBySource {
|
||||
// Check if this rule path matches or is a subpath of the target path
|
||||
if rulePath == normalizedPath || strings.HasPrefix(rulePath, normalizedPath) {
|
||||
if allow {
|
||||
// Remove group from allow list only
|
||||
if _, exists := rule.Allow.Groups[groupname]; exists {
|
||||
delete(rule.Allow.Groups, groupname)
|
||||
changed = true
|
||||
removedCount++
|
||||
}
|
||||
} else {
|
||||
// Remove group from deny list only
|
||||
if _, exists := rule.Deny.Groups[groupname]; exists {
|
||||
delete(rule.Deny.Groups, groupname)
|
||||
changed = true
|
||||
removedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// If rule is now empty, mark it for deletion
|
||||
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 &&
|
||||
len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 && !rule.DenyAll {
|
||||
delete(s.AllRules[sourcePath], rulePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no rules left for this source, remove the source entry
|
||||
if len(s.AllRules[sourcePath]) == 0 {
|
||||
delete(s.AllRules, sourcePath)
|
||||
}
|
||||
|
||||
if changed {
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
return removedCount, s.SaveToDB()
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// UpdateRules updates all access rules that match oldPath to point to newPath.
|
||||
// Handles both exact matches and subdirectories. Similar to share.Storage.UpdateShares.
|
||||
func (s *Storage) UpdateRules(sourcePath, oldPath, newPath string) (int, error) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
return 0, nil // No rules for this source, not an error
|
||||
}
|
||||
|
||||
// Normalize paths (always add trailing slash for access rules)
|
||||
oldPath = utils.AddTrailingSlashIfNotExists(oldPath)
|
||||
newPath = utils.AddTrailingSlashIfNotExists(newPath)
|
||||
|
||||
updated := 0
|
||||
rulesToUpdate := make(map[string]string) // old path -> new path
|
||||
|
||||
// Find all rules that need to be updated
|
||||
for rulePath := range rulesBySource {
|
||||
if rulePath == oldPath {
|
||||
// Exact match
|
||||
rulesToUpdate[rulePath] = newPath
|
||||
} else if strings.HasPrefix(rulePath, oldPath) {
|
||||
// Subdirectory - replace prefix
|
||||
newRulePath := newPath + strings.TrimPrefix(rulePath, oldPath)
|
||||
rulesToUpdate[rulePath] = newRulePath
|
||||
}
|
||||
}
|
||||
|
||||
// Update all matched rules
|
||||
for oldRulePath, newRulePath := range rulesToUpdate {
|
||||
rule := rulesBySource[oldRulePath]
|
||||
delete(rulesBySource, oldRulePath)
|
||||
rulesBySource[newRulePath] = rule
|
||||
logger.Info("access rule updated", "source", sourcePath, "fromPath", oldRulePath, "toPath", newRulePath)
|
||||
updated++
|
||||
}
|
||||
|
||||
if updated > 0 {
|
||||
// Invalidate caches
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
|
||||
if err := s.SaveToDB(); err != nil {
|
||||
return updated, err
|
||||
}
|
||||
}
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// UpdateRulePath updates the path for a specific access rule (used by PATCH API endpoint).
|
||||
func (s *Storage) UpdateRulePath(sourcePath, oldPath, newPath string) error {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
// Normalize paths
|
||||
oldPath = utils.AddTrailingSlashIfNotExists(oldPath)
|
||||
newPath = utils.AddTrailingSlashIfNotExists(newPath)
|
||||
|
||||
rulesBySource, ok := s.AllRules[sourcePath]
|
||||
if !ok {
|
||||
return fmt.Errorf("no rules found for source: %s", sourcePath)
|
||||
}
|
||||
|
||||
rule, ok := rulesBySource[oldPath]
|
||||
if !ok {
|
||||
return fmt.Errorf("no rule found for path: %s", oldPath)
|
||||
}
|
||||
|
||||
// Remove the old rule and add it with the new path
|
||||
delete(rulesBySource, oldPath)
|
||||
rulesBySource[newPath] = rule
|
||||
|
||||
// Invalidate caches
|
||||
s.incrementSourceVersion(sourcePath)
|
||||
accessCache.Set(accessChangedKey+sourcePath, "false")
|
||||
rulesCache.Delete(accessChangedKey + sourcePath)
|
||||
|
||||
logger.Debugf("access rule path updated: source=%s, fromPath=%s, toPath=%s", sourcePath, oldPath, newPath)
|
||||
return s.SaveToDB()
|
||||
}
|
||||
@@ -1203,3 +1203,513 @@ func TestHasAnyVisibleItems(t *testing.T) {
|
||||
|
||||
t.Log("✓ HasAnyVisibleItems correctly checks access permissions for items")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_OnlyRemovesSpecificList tests that cascade delete only removes from the specified list
|
||||
func TestRemoveUserCascade_OnlyRemovesSpecificList(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "alice")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Set up rules: alice has both allow and deny rules on different paths
|
||||
if err = s.AllowUser("mnt/storage", "/docs/", "alice"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/docs/public/", "alice"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.DenyUser("mnt/storage", "/docs/", "alice"); err != nil {
|
||||
t.Fatalf("DenyUser failed: %v", err)
|
||||
}
|
||||
if err = s.DenyUser("mnt/storage", "/docs/private/", "alice"); err != nil {
|
||||
t.Fatalf("DenyUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete only allow rules
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/docs/", "alice", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 allow rules removed, got %d", count)
|
||||
}
|
||||
|
||||
// Verify allow rules are gone
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/docs/")
|
||||
if ok && len(rule.Allow.Users) != 0 {
|
||||
t.Error("Allow rule should be removed from /docs/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/docs/public/")
|
||||
if ok && len(rule.Allow.Users) != 0 {
|
||||
t.Error("Allow rule should be removed from /docs/public/")
|
||||
}
|
||||
|
||||
// Verify deny rules are still present
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/docs/")
|
||||
if !ok || len(rule.Deny.Users) == 0 {
|
||||
t.Error("Deny rule should still exist on /docs/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/docs/private/")
|
||||
if !ok || len(rule.Deny.Users) == 0 {
|
||||
t.Error("Deny rule should still exist on /docs/private/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete correctly removes only allow rules, leaving deny rules intact")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_DenyRules tests cascade delete for deny rules specifically
|
||||
func TestRemoveUserCascade_DenyRules(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "bob")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Set up both allow and deny rules
|
||||
if err = s.AllowUser("mnt/storage", "/projects/", "bob"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/projects/team/", "bob"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.DenyUser("mnt/storage", "/projects/", "bob"); err != nil {
|
||||
t.Fatalf("DenyUser failed: %v", err)
|
||||
}
|
||||
if err = s.DenyUser("mnt/storage", "/projects/secret/", "bob"); err != nil {
|
||||
t.Fatalf("DenyUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete only deny rules
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/projects/", "bob", false)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 deny rules removed, got %d", count)
|
||||
}
|
||||
|
||||
// Verify deny rules are gone
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/projects/")
|
||||
if !ok || len(rule.Deny.Users) != 0 {
|
||||
t.Error("Deny rule should be removed from /projects/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/projects/secret/")
|
||||
if ok && len(rule.Deny.Users) != 0 {
|
||||
t.Error("Deny rule should be removed from /projects/secret/")
|
||||
}
|
||||
|
||||
// Verify allow rules are still present
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/projects/")
|
||||
if !ok || len(rule.Allow.Users) == 0 {
|
||||
t.Error("Allow rule should still exist on /projects/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/projects/team/")
|
||||
if !ok || len(rule.Allow.Users) == 0 {
|
||||
t.Error("Allow rule should still exist on /projects/team/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete correctly removes only deny rules, leaving allow rules intact")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_MultipleSubpaths tests cascade delete across multiple subpath levels
|
||||
func TestRemoveUserCascade_MultipleSubpaths(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "carol")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Set up a deep hierarchy of allow rules
|
||||
paths := []string{
|
||||
"/data/",
|
||||
"/data/2024/",
|
||||
"/data/2024/q1/",
|
||||
"/data/2024/q1/jan/",
|
||||
"/data/2024/q1/feb/",
|
||||
"/data/2024/q2/",
|
||||
"/data/2024/q2/apr/",
|
||||
"/data/2025/",
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
if err = s.AllowUser("mnt/storage", path, "carol"); err != nil {
|
||||
t.Fatalf("AllowUser failed for %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Cascade delete from /data/2024/ should remove all 2024 subpaths
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/data/2024/", "carol", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 6 {
|
||||
t.Errorf("Expected 6 rules removed (2024 + 5 subpaths), got %d", count)
|
||||
}
|
||||
|
||||
// Verify 2024 paths are gone
|
||||
for _, path := range []string{"/data/2024/", "/data/2024/q1/", "/data/2024/q1/jan/", "/data/2024/q1/feb/", "/data/2024/q2/", "/data/2024/q2/apr/"} {
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", path)
|
||||
if ok && len(rule.Allow.Users) > 0 {
|
||||
t.Errorf("Rule should be removed from %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify other paths still exist
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/data/")
|
||||
if !ok || len(rule.Allow.Users) == 0 {
|
||||
t.Error("Rule should still exist on /data/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/data/2025/")
|
||||
if !ok || len(rule.Allow.Users) == 0 {
|
||||
t.Error("Rule should still exist on /data/2025/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete correctly removes rules from all subpath levels")
|
||||
}
|
||||
|
||||
// TestRemoveGroupCascade_OnlyRemovesSpecificList tests cascade delete for groups
|
||||
func TestRemoveGroupCascade_OnlyRemovesSpecificList(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "alice")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Create a group
|
||||
if err = s.AddUserToGroup("editors", "alice"); err != nil {
|
||||
t.Fatalf("AddUserToGroup failed: %v", err)
|
||||
}
|
||||
|
||||
// Set up both allow and deny rules for the group
|
||||
if err = s.AllowGroup("mnt/storage", "/content/", "editors"); err != nil {
|
||||
t.Fatalf("AllowGroup failed: %v", err)
|
||||
}
|
||||
if err = s.AllowGroup("mnt/storage", "/content/articles/", "editors"); err != nil {
|
||||
t.Fatalf("AllowGroup failed: %v", err)
|
||||
}
|
||||
if err = s.DenyGroup("mnt/storage", "/content/", "editors"); err != nil {
|
||||
t.Fatalf("DenyGroup failed: %v", err)
|
||||
}
|
||||
if err = s.DenyGroup("mnt/storage", "/content/drafts/", "editors"); err != nil {
|
||||
t.Fatalf("DenyGroup failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete only allow rules
|
||||
count, err := s.RemoveGroupCascade("mnt/storage", "/content/", "editors", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveGroupCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 allow rules removed, got %d", count)
|
||||
}
|
||||
|
||||
// Verify allow rules are gone
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/content/")
|
||||
if !ok || len(rule.Allow.Groups) != 0 {
|
||||
t.Error("Allow rule should be removed from /content/")
|
||||
}
|
||||
|
||||
// Verify deny rules are still present
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/content/")
|
||||
if !ok || len(rule.Deny.Groups) == 0 {
|
||||
t.Error("Deny rule should still exist on /content/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/content/drafts/")
|
||||
if !ok || len(rule.Deny.Groups) == 0 {
|
||||
t.Error("Deny rule should still exist on /content/drafts/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete for groups correctly removes only allow rules")
|
||||
}
|
||||
|
||||
// TestRemoveGroupCascade_DenyRules tests cascade delete for group deny rules specifically
|
||||
func TestRemoveGroupCascade_DenyRules(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "alice")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Create a group
|
||||
if err = s.AddUserToGroup("contractors", "alice"); err != nil {
|
||||
t.Fatalf("AddUserToGroup failed: %v", err)
|
||||
}
|
||||
|
||||
// Set up both allow and deny rules for the group
|
||||
if err = s.AllowGroup("mnt/storage", "/work/", "contractors"); err != nil {
|
||||
t.Fatalf("AllowGroup failed: %v", err)
|
||||
}
|
||||
if err = s.AllowGroup("mnt/storage", "/work/public/", "contractors"); err != nil {
|
||||
t.Fatalf("AllowGroup failed: %v", err)
|
||||
}
|
||||
if err = s.DenyGroup("mnt/storage", "/work/", "contractors"); err != nil {
|
||||
t.Fatalf("DenyGroup failed: %v", err)
|
||||
}
|
||||
if err = s.DenyGroup("mnt/storage", "/work/confidential/", "contractors"); err != nil {
|
||||
t.Fatalf("DenyGroup failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete only deny rules
|
||||
var count int
|
||||
count, err = s.RemoveGroupCascade("mnt/storage", "/work/", "contractors", false)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveGroupCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 deny rules removed, got %d", count)
|
||||
}
|
||||
|
||||
// Verify deny rules are gone
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/work/")
|
||||
if ok && len(rule.Deny.Groups) != 0 {
|
||||
t.Error("Deny rule should be removed from /work/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/work/confidential/")
|
||||
if ok && len(rule.Deny.Groups) != 0 {
|
||||
t.Error("Deny rule should be removed from /work/confidential/")
|
||||
}
|
||||
|
||||
// Verify allow rules are still present
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/work/")
|
||||
if !ok || len(rule.Allow.Groups) == 0 {
|
||||
t.Error("Allow rule should still exist on /work/")
|
||||
}
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/work/public/")
|
||||
if !ok || len(rule.Allow.Groups) == 0 {
|
||||
t.Error("Allow rule should still exist on /work/public/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete for groups correctly removes only deny rules, leaving allow rules intact")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_EmptyResult tests cascade delete when no rules exist
|
||||
func TestRemoveUserCascade_EmptyResult(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "dave")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Try to cascade delete when no rules exist
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/nonexistent/", "dave", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade should not error on nonexistent rules: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("Expected 0 rules removed, got %d", count)
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete correctly returns 0 when no rules exist")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_ExactPathOnly tests that exact path is included in cascade
|
||||
func TestRemoveUserCascade_ExactPathOnly(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "eve")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Add rule only on exact path, no subpaths
|
||||
if err = s.AllowUser("mnt/storage", "/single/", "eve"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete should remove the exact path rule
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/single/", "eve", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("Expected 1 rule removed, got %d", count)
|
||||
}
|
||||
|
||||
// Verify rule is gone
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/single/")
|
||||
if ok && len(rule.Allow.Users) > 0 {
|
||||
t.Error("Rule should be removed from /single/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete correctly removes the exact path rule")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_DoesNotAffectParentPaths tests that parent paths are not affected
|
||||
func TestRemoveUserCascade_DoesNotAffectParentPaths(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "frank")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Set up rules on parent and child paths
|
||||
if err = s.AllowUser("mnt/storage", "/parent/", "frank"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/parent/child/", "frank"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/parent/child/grandchild/", "frank"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete from child path
|
||||
count, err := s.RemoveUserCascade("mnt/storage", "/parent/child/", "frank", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 rules removed (child + grandchild), got %d", count)
|
||||
}
|
||||
|
||||
// Verify parent rule still exists
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/parent/")
|
||||
if !ok || len(rule.Allow.Users) == 0 {
|
||||
t.Error("Parent rule should still exist")
|
||||
}
|
||||
|
||||
// Verify child rules are gone
|
||||
rule, ok = s.GetFrontendRules("mnt/storage", "/parent/child/")
|
||||
if ok && len(rule.Allow.Users) > 0 {
|
||||
t.Error("Child rule should be removed")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete does not affect parent paths")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_CleanupEmptyRules tests that empty rules are cleaned up
|
||||
func TestRemoveUserCascade_CleanupEmptyRules(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "grace")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Add only allow rule (no deny rules)
|
||||
if err = s.AllowUser("mnt/storage", "/temp/", "grace"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/temp/files/", "grace"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify rules exist
|
||||
allRules, err := s.GetAllRules("mnt/storage")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllRules failed: %v", err)
|
||||
}
|
||||
if len(allRules) < 2 {
|
||||
t.Error("Expected at least 2 rules to exist")
|
||||
}
|
||||
|
||||
// Cascade delete - should remove rules and cleanup empty rule objects
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/temp/", "grace", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 rules removed, got %d", count)
|
||||
}
|
||||
|
||||
// Verify rules are completely gone (not just empty)
|
||||
allRules, err = s.GetAllRules("mnt/storage")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllRules failed: %v", err)
|
||||
}
|
||||
if _, exists := allRules["/temp/"]; exists {
|
||||
t.Error("Empty rule should be cleaned up from /temp/")
|
||||
}
|
||||
if _, exists := allRules["/temp/files/"]; exists {
|
||||
t.Error("Empty rule should be cleaned up from /temp/files/")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete correctly cleans up empty rules")
|
||||
}
|
||||
|
||||
// TestRemoveUserCascade_MixedUsers tests that cascade delete only affects the specified user
|
||||
func TestRemoveUserCascade_MixedUsers(t *testing.T) {
|
||||
setupTestSources()
|
||||
s, userStore := createTestStorage(t)
|
||||
createTestUser(t, userStore, "henry")
|
||||
createTestUser(t, userStore, "iris")
|
||||
err := s.LoadFromDB()
|
||||
if err != nil && err != storm.ErrNotFound {
|
||||
t.Errorf("unexpected error loading from DB: %v", err)
|
||||
}
|
||||
|
||||
// Add rules for both users on same paths
|
||||
if err = s.AllowUser("mnt/storage", "/shared/", "henry"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/shared/", "iris"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/shared/docs/", "henry"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
if err = s.AllowUser("mnt/storage", "/shared/docs/", "iris"); err != nil {
|
||||
t.Fatalf("AllowUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Cascade delete only henry's rules
|
||||
var count int
|
||||
count, err = s.RemoveUserCascade("mnt/storage", "/shared/", "henry", true)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveUserCascade failed: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 rules removed for henry, got %d", count)
|
||||
}
|
||||
|
||||
// Verify henry's rules are gone
|
||||
rule, ok := s.GetFrontendRules("mnt/storage", "/shared/")
|
||||
if !ok {
|
||||
t.Fatal("Rule should still exist (iris's rule)")
|
||||
}
|
||||
hasHenry := false
|
||||
for _, user := range rule.Allow.Users {
|
||||
if user == "henry" {
|
||||
hasHenry = true
|
||||
}
|
||||
}
|
||||
if hasHenry {
|
||||
t.Error("henry should be removed from allow list")
|
||||
}
|
||||
|
||||
// Verify iris's rules still exist
|
||||
hasIris := false
|
||||
for _, user := range rule.Allow.Users {
|
||||
if user == "iris" {
|
||||
hasIris = true
|
||||
}
|
||||
}
|
||||
if !hasIris {
|
||||
t.Error("iris should still be in allow list")
|
||||
}
|
||||
|
||||
t.Log("✓ Cascade delete only affects the specified user")
|
||||
}
|
||||
@@ -26,12 +26,16 @@ func (s *Storage) CheckChildItemAccess(response *iteminfo.ExtendedFileInfo, inde
|
||||
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.ItemInfo, 0)
|
||||
response.Files = make([]iteminfo.ExtendedItemInfo, 0)
|
||||
|
||||
// Check each subfolder for access permissions
|
||||
for _, folder := range response.Folders {
|
||||
for _, folder := range originalFolders {
|
||||
indexPath := parentPath + folder.Name
|
||||
if s.Permitted(index.Path, indexPath, username) {
|
||||
response.Folders = append(response.Folders, folder)
|
||||
@@ -39,7 +43,7 @@ func (s *Storage) CheckChildItemAccess(response *iteminfo.ExtendedFileInfo, inde
|
||||
}
|
||||
|
||||
// Check each subfile for access permissions
|
||||
for _, file := range response.Files {
|
||||
for _, file := range originalFiles {
|
||||
indexPath := parentPath + file.Name
|
||||
if s.Permitted(index.Path, indexPath, username) {
|
||||
response.Files = append(response.Files, file)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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).
|
||||
@@ -140,3 +143,11 @@ func (l *Link) HasReachedUserLimit(username string) bool {
|
||||
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
|
||||
}
|
||||
@@ -1,37 +1,46 @@
|
||||
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"`
|
||||
Source string `json:"source,omitempty"` // backend source is path to maintain between name changes
|
||||
Path string `json:"path,omitempty"`
|
||||
DownloadURL string `json:"downloadURL,omitempty"`
|
||||
DisableShareCard bool `json:"disableShareCard,omitempty"`
|
||||
EnforceDarkLightMode string `json:"enforceDarkLightMode,omitempty"` // "dark" or "light"
|
||||
ViewMode string `json:"viewMode,omitempty"` // default view mode for anonymous users: "list", "compact", "normal", "gallery"
|
||||
EnableOnlyOffice bool `json:"enableOnlyOffice,omitempty"`
|
||||
EnableOnlyOfficeEditing bool `json:"enableOnlyOfficeEditing,omitempty"`
|
||||
ShareType string `json:"shareType"` // type of share: normal, upload, max
|
||||
PerUserDownloadLimit bool `json:"perUserDownloadLimit,omitempty"`
|
||||
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles,omitempty"` // can be io intensive for large files and take 10-30 seconds.
|
||||
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"`
|
||||
ShareURL string `json:"shareURL,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
|
||||
@@ -44,15 +53,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"`
|
||||
UserDownloads map[string]int `json:"-"` // Track downloads per username (not persisted)
|
||||
Hash string `json:"hash" storm:"id,index"`
|
||||
UserID uint `json:"userID"`
|
||||
Expire int64 `json:"expire"`
|
||||
PasswordHash string `json:"password_hash,omitempty"`
|
||||
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"`
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/crud"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
@@ -16,6 +17,7 @@ type StorageBackend interface {
|
||||
All() ([]*Link, error)
|
||||
FindByUserID(id uint) ([]*Link, error)
|
||||
GetByHash(hash string) (*Link, error)
|
||||
GetCommonShareByHash(hash string) (*CommonShare, error)
|
||||
GetPermanent(path, source string, id uint) (*Link, error)
|
||||
GetBySourcePath(path, source string) ([]*Link, error)
|
||||
Gets(path, source string, id uint) ([]*Link, error)
|
||||
@@ -220,51 +222,84 @@ func (s *Storage) GetBySourcePath(path, source string) ([]*Link, error) {
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// UpdateShares updates all shares that exactly match oldSource and oldPath
|
||||
// to point to newSource and newPath. Returns the number of updated shares.
|
||||
// UpdateShares updates all shares that match oldSource and oldPath to point to newSource and newPath.
|
||||
// Handles both exact matches and subdirectories, regardless of trailing slashes.
|
||||
func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (int, error) {
|
||||
links, err := s.back.All()
|
||||
if err != nil && err != errors.ErrNotExist {
|
||||
logger.Error("failed to list shares", "error", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Normalize paths for comparison (remove trailing slashes)
|
||||
oldPath = utils.AddTrailingSlashIfNotExists(oldPath)
|
||||
newPath = utils.AddTrailingSlashIfNotExists(newPath)
|
||||
|
||||
updated := 0
|
||||
for _, l := range links {
|
||||
if l == nil {
|
||||
continue
|
||||
}
|
||||
if l.Source != oldSource {
|
||||
if l == nil || l.Source != oldSource {
|
||||
continue
|
||||
}
|
||||
l.Path = utils.AddTrailingSlashIfNotExists(l.Path)
|
||||
|
||||
pos := strings.Index(l.Path, oldPath)
|
||||
if pos < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
oldFullPath := l.Path
|
||||
l.Source = newSource
|
||||
l.Path = oldFullPath[:pos] + newPath + oldFullPath[pos+len(oldPath):]
|
||||
l.Path = newPath
|
||||
|
||||
if err := s.back.Save(l); err != nil {
|
||||
logger.Error("failed to save updated share", "hash", l.Hash, "error", err)
|
||||
return updated, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
|
||||
// update existing cached pointer fields
|
||||
existing.Source = l.Source
|
||||
existing.Path = l.Path
|
||||
} else {
|
||||
s.shareCache[l.Hash] = l
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
logger.Info("share updated", "hash", l.Hash, "fromPath", oldFullPath, "toPath", l.Path)
|
||||
updated++
|
||||
}
|
||||
|
||||
if updated == 0 {
|
||||
logger.Warning("no matching shares to update for provided source/path")
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// UpdateSharePath updates the path for a specific share identified by hash
|
||||
func (s *Storage) UpdateSharePath(hash, newPath string) error {
|
||||
link, err := s.GetByHash(hash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oldPath := link.Path
|
||||
link.Path = newPath
|
||||
|
||||
if err := s.back.Save(link); err != nil {
|
||||
logger.Error("failed to save updated share", "hash", hash, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if existing, ok := s.shareCache[hash]; ok && existing != nil {
|
||||
existing.Path = newPath
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
logger.Debug("share path updated", "hash", hash, "fromPath", oldPath, "toPath", newPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save wraps StorageBackend.Save
|
||||
func (s *Storage) Save(l *Link) error {
|
||||
if err := s.back.Save(l); err != nil {
|
||||
@@ -321,3 +356,8 @@ func (s *Storage) filterExpired(links []*Link) ([]*Link, error) {
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// GetCommonShareByHash retrieves only the CommonShare attributes for a given hash
|
||||
func (s *Storage) GetCommonShareByHash(hash string) (*CommonShare, error) {
|
||||
return s.back.GetCommonShareByHash(hash)
|
||||
}
|
||||
@@ -45,6 +45,23 @@ func (s shareBackend) GetByHash(hash string) (*share.Link, error) {
|
||||
return &v, err
|
||||
}
|
||||
|
||||
func (s shareBackend) GetCommonShareByHash(hash string) (*share.CommonShare, error) {
|
||||
var v share.Link
|
||||
err := s.db.One("Hash", hash, &v)
|
||||
if err == storm.ErrNotFound {
|
||||
return nil, errors.ErrNotExist
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.Source = ""
|
||||
v.Path = ""
|
||||
v.AllowedUsernames = nil
|
||||
v.DownloadsLimit = 0
|
||||
v.CommonShare.HasPassword = v.HasPassword()
|
||||
return &v.CommonShare, nil
|
||||
}
|
||||
|
||||
func (s shareBackend) GetPermanent(path, source string, id uint) (*share.Link, error) {
|
||||
var v share.Link
|
||||
// TODO remove legacy and return notfound errors
|
||||
|
||||
@@ -74,20 +74,28 @@ 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")
|
||||
}
|
||||
}
|
||||
@@ -112,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 {
|
||||
@@ -275,6 +291,7 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
|
||||
if capitalField == "Password" {
|
||||
// Only process password if it's actually being updated (not empty)
|
||||
if user.Password != "" {
|
||||
logger.Debugf("Updating password for user [%s] loginMethod: %s", user.Username, user.LoginMethod)
|
||||
if user.LoginMethod != users.LoginMethodPassword {
|
||||
return nil, fmt.Errorf("password cannot be changed when login method is not password")
|
||||
}
|
||||
|
||||
@@ -42,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)
|
||||
}
|
||||
|
||||
@@ -94,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)
|
||||
@@ -101,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")
|
||||
@@ -118,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)
|
||||
|
||||
@@ -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.
|
||||
@@ -69,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 {
|
||||
@@ -79,35 +84,36 @@ type SourceScope struct {
|
||||
|
||||
// json tags must match variable name with smaller case first letter
|
||||
type NonAdminEditable struct {
|
||||
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
|
||||
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
|
||||
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
|
||||
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
Preview Preview `json:"preview"`
|
||||
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
|
||||
DarkMode bool `json:"darkMode"` // should dark mode be enabled
|
||||
Password string `json:"password,omitempty"`
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
|
||||
Sorting Sorting `json:"sorting"`
|
||||
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
|
||||
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications
|
||||
FileLoading FileLoading `json:"fileLoading"` // upload and download settings
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // deprecated
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
|
||||
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
|
||||
DefaultLandingPage string `json:"defaultLandingPage"` // default landing page to use: eg. /files/mysource/mysubpath, /settings, etc.
|
||||
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
|
||||
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
|
||||
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
|
||||
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
Preview Preview `json:"preview"`
|
||||
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
|
||||
DarkMode bool `json:"darkMode"` // should dark mode be enabled
|
||||
Password string `json:"password,omitempty"`
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
|
||||
Sorting Sorting `json:"sorting"`
|
||||
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
|
||||
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications
|
||||
FileLoading FileLoading `json:"fileLoading"` // upload and download settings
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // deprecated
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
|
||||
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
|
||||
DefaultLandingPage string `json:"defaultLandingPage"` // deprecated: determined by sidebar link order instead
|
||||
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
|
||||
}
|
||||
|
||||
type FileLoading struct {
|
||||
@@ -116,6 +122,15 @@ type FileLoading struct {
|
||||
ClearAll bool `json:"clearAll"`
|
||||
}
|
||||
|
||||
// SidebarLink represents a customizable link in the sidebar.
|
||||
type SidebarLink struct {
|
||||
Name string `json:"name"` // Display name of the link
|
||||
Category string `json:"category"` // Category type: "source", "share", "tool", "custom", etc.
|
||||
Target string `json:"target"` // Target path/URL for the link (relative for source/share)
|
||||
Icon string `json:"icon"` // Material icon name
|
||||
SourceName string `json:"sourceName,omitempty"` // Source identifier for source-type links
|
||||
}
|
||||
|
||||
func CleanUsername(s string) string {
|
||||
// Remove any trailing space to avoid ending on -
|
||||
s = strings.Trim(s, " ")
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,13 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
)
|
||||
|
||||
var (
|
||||
MediaCache = cache.NewCache[[]SubtitleTrack](24 * time.Hour) // subtitles get cached for 24 hours
|
||||
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // subtitle content gets cached for 24 hours
|
||||
MetadataCache = cache.NewCache[float64](24 * time.Hour) // media duration gets cached for 24 hours
|
||||
)
|
||||
+6
-41
@@ -13,41 +13,8 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
|
||||
)
|
||||
|
||||
// ImageService handles image operations with ffmpeg
|
||||
type ImageService struct {
|
||||
ffmpegPath string
|
||||
ffprobePath string
|
||||
debug bool
|
||||
cacheDir string
|
||||
semaphore chan struct{}
|
||||
}
|
||||
|
||||
// NewImageService creates a new image service instance
|
||||
func NewImageService(ffmpegPath, ffprobePath string, maxConcurrent int, debug bool, cacheDir string) *ImageService {
|
||||
return &ImageService{
|
||||
ffmpegPath: ffmpegPath,
|
||||
ffprobePath: ffprobePath,
|
||||
debug: debug,
|
||||
cacheDir: cacheDir,
|
||||
semaphore: make(chan struct{}, maxConcurrent),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ImageService) Acquire(ctx context.Context) error {
|
||||
select {
|
||||
case s.semaphore <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ImageService) Release() {
|
||||
<-s.semaphore
|
||||
}
|
||||
|
||||
// GetImageOrientation extracts the EXIF orientation from an image file using exiftool
|
||||
func (s *ImageService) GetImageOrientation(imagePath string) (string, error) {
|
||||
func (s *FFmpegService) GetImageOrientation(imagePath string) (string, error) {
|
||||
// Use exiftool to get orientation information
|
||||
cmd := exec.Command("exiftool", "-Orientation", "-s3", imagePath)
|
||||
var out bytes.Buffer
|
||||
@@ -67,7 +34,7 @@ func (s *ImageService) GetImageOrientation(imagePath string) (string, error) {
|
||||
}
|
||||
|
||||
// GetOrientationFilter converts EXIF orientation to FFmpeg filter
|
||||
func (s *ImageService) GetOrientationFilter(orientation string) string {
|
||||
func (s *FFmpegService) GetOrientationFilter(orientation string) string {
|
||||
switch orientation {
|
||||
// Text-based EXIF orientation values (from exiftool)
|
||||
case "Rotate 90 CW", "Right-top":
|
||||
@@ -111,7 +78,7 @@ func (s *ImageService) GetOrientationFilter(orientation string) string {
|
||||
}
|
||||
|
||||
// GetImageDimensions extracts the dimensions of an image file using ffprobe
|
||||
func (s *ImageService) GetImageDimensions(imagePath string) (width, height int, err error) {
|
||||
func (s *FFmpegService) GetImageDimensions(imagePath string) (width, height int, err error) {
|
||||
|
||||
// Get HEIC dimensions (fallback to individual stream dimensions for nowe re
|
||||
probeCmd := exec.Command(
|
||||
@@ -161,7 +128,7 @@ func (s *ImageService) GetImageDimensions(imagePath string) (width, height int,
|
||||
}
|
||||
|
||||
// ConvertHEICToJPEGDirect converts a HEIC file to JPEG using direct FFmpeg conversion (fast method)
|
||||
func (s *ImageService) ConvertHEICToJPEGDirect(ctx context.Context, heicPath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
|
||||
func (s *FFmpegService) ConvertHEICToJPEGDirect(ctx context.Context, heicPath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
|
||||
// Check if context is cancelled before starting
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
@@ -229,15 +196,13 @@ func (s *ImageService) ConvertHEICToJPEGDirect(ctx context.Context, heicPath str
|
||||
}
|
||||
|
||||
// ConvertHEICToJPEG converts a HEIC file to JPEG with specified dimensions and quality using proper tile extraction
|
||||
func (s *ImageService) ConvertHEICToJPEG(ctx context.Context, heicPath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
|
||||
func (s *FFmpegService) ConvertHEICToJPEG(ctx context.Context, heicPath string, targetWidth, targetHeight int, quality string) ([]byte, error) {
|
||||
// Check if context is cancelled before starting
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
// Create temporary directory for tile processing
|
||||
outputDir := s.cacheDir
|
||||
tempDir := filepath.Join(outputDir, fmt.Sprintf("heic_tiles_%d", os.Getpid()))
|
||||
err := os.MkdirAll(tempDir, fileutils.PermDir)
|
||||
tempDir, err := os.MkdirTemp(s.cacheDir, "heic_convert")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetOrientationFilter(t *testing.T) {
|
||||
service := &ImageService{}
|
||||
service := &FFmpegService{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -125,7 +124,7 @@ func TestGetImageOrientation(t *testing.T) {
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
service := NewImageService("ffmpeg", "ffprobe", 2, false, tempDir)
|
||||
service := NewFFmpegService(2, false, "/tmp/test_cache")
|
||||
|
||||
t.Run("Non-existent file", func(t *testing.T) {
|
||||
nonExistentFile := filepath.Join(tempDir, "non_existent.heic")
|
||||
@@ -153,94 +152,9 @@ func TestGetImageOrientation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewImageService(t *testing.T) {
|
||||
ffmpegPath := "/usr/bin/ffmpeg"
|
||||
ffprobePath := "/usr/bin/ffprobe"
|
||||
debug := true
|
||||
cacheDir := "/tmp/test_cache"
|
||||
|
||||
service := NewImageService(ffmpegPath, ffprobePath, 2, debug, cacheDir)
|
||||
|
||||
if service.ffmpegPath != ffmpegPath {
|
||||
t.Errorf("Expected ffmpegPath %q, got %q", ffmpegPath, service.ffmpegPath)
|
||||
}
|
||||
if service.ffprobePath != ffprobePath {
|
||||
t.Errorf("Expected ffprobePath %q, got %q", ffprobePath, service.ffprobePath)
|
||||
}
|
||||
if service.debug != debug {
|
||||
t.Errorf("Expected debug %v, got %v", debug, service.debug)
|
||||
}
|
||||
if service.cacheDir != cacheDir {
|
||||
t.Errorf("Expected cacheDir %q, got %q", cacheDir, service.cacheDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Integration test for orientation handling in direct conversion
|
||||
func TestConvertHEICToJPEGDirect_OrientationHandling(t *testing.T) {
|
||||
// Skip this test if ffmpeg is not available
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "ffmpeg_integration_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
service := NewImageService("ffmpeg", "ffprobe", 2, true, tempDir)
|
||||
|
||||
t.Run("Direct conversion with orientation detection", func(t *testing.T) {
|
||||
// This test requires actual HEIC files to work properly
|
||||
// For CI/CD environments, we can mock this or skip if files don't exist
|
||||
|
||||
heicTestFiles := []string{
|
||||
"/Users/steffag/Downloads/heic/IMG_6660.heic",
|
||||
"/Users/steffag/Downloads/heic/IMG_2919.HEIC",
|
||||
"/Users/steffag/Downloads/heic/shelf-christmas-decoration.heic",
|
||||
}
|
||||
|
||||
for _, testFile := range heicTestFiles {
|
||||
// Check if test file exists
|
||||
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
||||
t.Logf("Skipping test for %s (file not found)", filepath.Base(testFile))
|
||||
continue
|
||||
}
|
||||
|
||||
t.Logf("Testing orientation handling for %s", filepath.Base(testFile))
|
||||
|
||||
// Test orientation detection
|
||||
orientation, err := service.GetImageOrientation(testFile)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to get orientation for %s: %v", filepath.Base(testFile), err)
|
||||
continue
|
||||
}
|
||||
t.Logf("Detected orientation: %s", orientation)
|
||||
|
||||
// Test filter generation
|
||||
filter := service.GetOrientationFilter(orientation)
|
||||
t.Logf("Generated filter: %s", filter)
|
||||
|
||||
// Test actual conversion (small size for speed)
|
||||
jpegBytes, err := service.ConvertHEICToJPEGDirect(context.Background(), testFile, 100, 100, "5")
|
||||
if err != nil {
|
||||
t.Errorf("Failed to convert %s: %v", filepath.Base(testFile), err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(jpegBytes) < 400 {
|
||||
t.Errorf("Converted image too small (%d bytes), likely conversion failed", len(jpegBytes))
|
||||
}
|
||||
|
||||
t.Logf("Successfully converted %s to %d bytes", filepath.Base(testFile), len(jpegBytes))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Benchmark the orientation filter generation
|
||||
func BenchmarkGetOrientationFilter(b *testing.B) {
|
||||
service := &ImageService{}
|
||||
service := &FFmpegService{}
|
||||
orientations := []string{
|
||||
"Horizontal (normal)",
|
||||
"Rotate 90 CW",
|
||||
@@ -258,7 +172,7 @@ func BenchmarkGetOrientationFilter(b *testing.B) {
|
||||
|
||||
// Test table for all EXIF orientation values
|
||||
func TestGetOrientationFilter_AllEXIFValues(t *testing.T) {
|
||||
service := &ImageService{}
|
||||
service := &FFmpegService{}
|
||||
|
||||
// Test all possible EXIF orientation values according to EXIF spec
|
||||
exifTests := []struct {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetMediaDuration extracts the duration of a media file (audio or video) using ffprobe
|
||||
// This method respects the concurrency limits, uses caching, and gracefully handles errors
|
||||
// Cache key is based on file path + mod time for automatic cache invalidation on file changes
|
||||
func (s *FFmpegService) GetMediaDuration(ctx context.Context, mediaPath string) (float64, error) {
|
||||
// Get file info for cache key (path + mod time)
|
||||
fileInfo, err := os.Stat(mediaPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to stat file: %w", err)
|
||||
}
|
||||
// Create cache key from path and modification time
|
||||
cacheKey := fmt.Sprintf("%s:%d", mediaPath, fileInfo.ModTime().Unix())
|
||||
// Check cache first
|
||||
if duration, found := MetadataCache.Get(cacheKey); found {
|
||||
return duration, nil
|
||||
}
|
||||
// Acquire semaphore slot for concurrency control
|
||||
if err = s.Acquire(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer s.Release()
|
||||
|
||||
// Check if context is cancelled before starting
|
||||
if ctx.Err() != nil {
|
||||
return 0, ctx.Err()
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx,
|
||||
s.ffprobePath,
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
mediaPath,
|
||||
)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
MetadataCache.Set(cacheKey, 0)
|
||||
return 0, fmt.Errorf("ffprobe failed: %w", err)
|
||||
}
|
||||
|
||||
durationStr := strings.TrimSpace(string(output))
|
||||
if durationStr == "" || durationStr == "N/A" {
|
||||
MetadataCache.Set(cacheKey, 0)
|
||||
return 0, fmt.Errorf("could not determine media duration")
|
||||
}
|
||||
|
||||
duration, err := strconv.ParseFloat(durationStr, 64)
|
||||
if err != nil {
|
||||
MetadataCache.Set(cacheKey, 0)
|
||||
return 0, fmt.Errorf("invalid duration: %v", err)
|
||||
}
|
||||
|
||||
// Cache successful result
|
||||
MetadataCache.Set(cacheKey, duration)
|
||||
|
||||
return duration, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// VideoService handles video preview operations with ffmpeg
|
||||
type FFmpegService struct {
|
||||
ffmpegPath string
|
||||
ffprobePath string
|
||||
debug bool
|
||||
semaphore chan struct{}
|
||||
cacheDir string
|
||||
maxConcurrent int // For logging purposes
|
||||
}
|
||||
|
||||
// NewVideoService creates a new video service instance
|
||||
func NewFFmpegService(maxConcurrent int, debug bool, cacheDir string) *FFmpegService {
|
||||
if settings.Env.FFmpegPath == "" || settings.Env.FFprobePath == "" {
|
||||
return nil
|
||||
}
|
||||
return &FFmpegService{
|
||||
ffmpegPath: settings.Env.FFmpegPath,
|
||||
ffprobePath: settings.Env.FFprobePath,
|
||||
debug: debug,
|
||||
semaphore: make(chan struct{}, maxConcurrent),
|
||||
maxConcurrent: maxConcurrent,
|
||||
cacheDir: cacheDir,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FFmpegService) Acquire(ctx context.Context) error {
|
||||
select {
|
||||
case s.semaphore <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FFmpegService) Release() {
|
||||
<-s.semaphore
|
||||
}
|
||||
|
||||
// CheckValidFFmpeg checks for a valid ffmpeg executable.
|
||||
// If a path is provided, it looks there. Otherwise, it searches the system's PATH.
|
||||
func CheckValidFFmpeg(path string) (string, error) {
|
||||
return checkExecutable(path, "ffmpeg")
|
||||
}
|
||||
|
||||
// CheckValidFFprobe checks for a valid ffprobe executable.
|
||||
// If a path is provided, it looks there. Otherwise, it searches the system's PATH.
|
||||
func CheckValidFFprobe(path string) (string, error) {
|
||||
return checkExecutable(path, "ffprobe")
|
||||
}
|
||||
|
||||
// checkExecutable is an internal helper function to find and validate an executable.
|
||||
// It checks a specific path if provided, otherwise falls back to searching the system PATH.
|
||||
func checkExecutable(providedPath, execName string) (string, error) {
|
||||
// Add .exe extension for Windows systems
|
||||
if runtime.GOOS == "windows" {
|
||||
execName += ".exe"
|
||||
}
|
||||
|
||||
var finalPath string
|
||||
var err error
|
||||
|
||||
if providedPath != "" {
|
||||
// A path was provided, so we'll use it.
|
||||
finalPath = filepath.Join(providedPath, execName)
|
||||
} else {
|
||||
// No path was provided, so search the system's PATH for the executable.
|
||||
finalPath, err = exec.LookPath(execName)
|
||||
if err != nil {
|
||||
// The executable was not found in the system's PATH.
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the executable is valid by running the "-version" command.
|
||||
cmd := exec.Command(finalPath, "-version")
|
||||
err = cmd.Run()
|
||||
|
||||
return finalPath, err
|
||||
}
|
||||
|
||||
func SetFFmpegPaths() {
|
||||
ffmpegMainPath, err := CheckValidFFmpeg(settings.Env.FFmpegPath)
|
||||
if err != nil && settings.Env.FFmpegPath != "" {
|
||||
logger.Warningf("the configured ffmpeg path does not contain a valid ffmpeg binary %s, err: %v", settings.Env.FFmpegPath, err)
|
||||
}
|
||||
ffprobePath, errprobe := CheckValidFFprobe(settings.Env.FFprobePath)
|
||||
if errprobe != nil && settings.Env.FFprobePath != "" {
|
||||
logger.Warningf("the configured ffmpeg path is not a valid ffprobe binary %s, err: %v", settings.Env.FFprobePath, err)
|
||||
}
|
||||
settings.Env.FFmpegPath = ffmpegMainPath
|
||||
settings.Env.FFprobePath = ffprobePath
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
@@ -25,11 +24,6 @@ type SubtitleTrack struct {
|
||||
IsFile bool `json:"isFile"` // true for external files, false for embedded streams
|
||||
}
|
||||
|
||||
var (
|
||||
MediaCache = cache.NewCache[[]SubtitleTrack](24 * time.Hour) // subtitles get cached for 24 hours
|
||||
SubtitleContentCache = cache.NewCache[string](24 * time.Hour) // subtitle content gets cached for 24 hours
|
||||
)
|
||||
|
||||
// FFProbeOutput represents the JSON output from ffprobe
|
||||
type FFProbeOutput struct {
|
||||
Streams []struct {
|
||||
|
||||
+3
-62
@@ -13,46 +13,13 @@ import (
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
)
|
||||
|
||||
// VideoService handles video preview operations with ffmpeg
|
||||
type VideoService struct {
|
||||
ffmpegPath string
|
||||
ffprobePath string
|
||||
debug bool
|
||||
semaphore chan struct{}
|
||||
maxConcurrent int // For logging purposes
|
||||
}
|
||||
|
||||
// NewVideoService creates a new video service instance
|
||||
func NewVideoService(ffmpegPath, ffprobePath string, maxConcurrent int, debug bool) *VideoService {
|
||||
return &VideoService{
|
||||
ffmpegPath: ffmpegPath,
|
||||
ffprobePath: ffprobePath,
|
||||
debug: debug,
|
||||
semaphore: make(chan struct{}, maxConcurrent),
|
||||
maxConcurrent: maxConcurrent,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *VideoService) acquire(ctx context.Context) error {
|
||||
select {
|
||||
case s.semaphore <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *VideoService) release() {
|
||||
<-s.semaphore
|
||||
}
|
||||
|
||||
// GenerateVideoPreviewStreaming generates a video preview and streams it directly to a writer
|
||||
// This is more memory efficient for large previews as it doesn't load the entire file into memory
|
||||
func (s *VideoService) GenerateVideoPreviewStreaming(ctx context.Context, videoPath string, percentageSeek int, writer io.Writer) error {
|
||||
if err := s.acquire(ctx); err != nil {
|
||||
func (s *FFmpegService) GenerateVideoPreviewStreaming(ctx context.Context, videoPath string, percentageSeek int, writer io.Writer) error {
|
||||
if err := s.Acquire(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.release()
|
||||
defer s.Release()
|
||||
|
||||
// Check if context is cancelled before starting
|
||||
if ctx.Err() != nil {
|
||||
@@ -145,29 +112,3 @@ func (s *VideoService) GenerateVideoPreviewStreaming(ctx context.Context, videoP
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetVideoDuration extracts the duration of a video file using ffprobe
|
||||
func GetVideoDuration(ffprobePath string, videoPath string) (float64, error) {
|
||||
cmd := exec.Command(ffprobePath,
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
videoPath)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ffprobe failed: %w", err)
|
||||
}
|
||||
|
||||
durationStr := strings.TrimSpace(string(output))
|
||||
if durationStr == "" || durationStr == "N/A" {
|
||||
return 0, fmt.Errorf("could not determine video duration")
|
||||
}
|
||||
|
||||
duration, err := strconv.ParseFloat(durationStr, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration: %v", err)
|
||||
}
|
||||
|
||||
return duration, nil
|
||||
}
|
||||
+59
-66
@@ -4,28 +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 v1.0.0
|
||||
github.com/gtsteffaniak/go-logger v0.2.3-0.20250926212932-e91a612f674c
|
||||
github.com/kovidgoyal/imaging v1.6.4
|
||||
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.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.42.0
|
||||
golang.org/x/image v0.31.0
|
||||
golang.org/x/mod v0.28.0
|
||||
golang.org/x/oauth2 v0.31.0
|
||||
golang.org/x/sys v0.36.0
|
||||
golang.org/x/time v0.13.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
|
||||
)
|
||||
|
||||
@@ -37,7 +37,7 @@ 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.1 // indirect
|
||||
@@ -46,10 +46,10 @@ require (
|
||||
github.com/BurntSushi/toml v1.5.0 // 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.63.0 // indirect
|
||||
github.com/air-verse/air v1.63.1 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
|
||||
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
|
||||
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
|
||||
@@ -57,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
|
||||
@@ -66,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.2.0 // 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
|
||||
@@ -83,7 +83,6 @@ require (
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/ckaznocha/intrange v0.3.1 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/curioswitch/go-reassign v0.3.0 // indirect
|
||||
github.com/daixiang0/gci v0.13.7 // indirect
|
||||
github.com/dave/dst v0.27.3 // indirect
|
||||
@@ -92,33 +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.9.0 // indirect
|
||||
github.com/ebitengine/purego v0.9.1 // indirect
|
||||
github.com/ettle/strcase v0.2.0 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fatih/structtag v1.2.0 // indirect
|
||||
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.16 // 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.22.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.2 // indirect
|
||||
github.com/go-openapi/spec v0.22.0 // indirect
|
||||
github.com/go-openapi/swag v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/loading v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
|
||||
github.com/go-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
|
||||
@@ -131,19 +125,18 @@ require (
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
|
||||
github.com/gobwas/glob v0.2.3 // indirect
|
||||
github.com/godoc-lint/godoc-lint v0.10.0 // indirect
|
||||
github.com/gofrs/flock v0.12.1 // indirect
|
||||
github.com/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-20250917161122-64cb148137c6 // indirect
|
||||
github.com/golang/geo v0.0.0-20251117194806-05dcfdd28b33 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/golangci/asciicheck v0.5.0 // indirect
|
||||
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
|
||||
github.com/golangci/go-printf-func-name v0.1.1 // indirect
|
||||
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
|
||||
github.com/golangci/golangci-lint/v2 v2.5.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/nilerr v0.0.0-20250918000102-015671e622fe // indirect
|
||||
github.com/golangci/plugin-module-register v0.1.2 // indirect
|
||||
github.com/golangci/revgrep v0.8.0 // indirect
|
||||
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
|
||||
@@ -152,7 +145,7 @@ require (
|
||||
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
|
||||
@@ -162,17 +155,17 @@ 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/kovidgoyal/go-parallel v1.1.1 // indirect
|
||||
github.com/kulti/thelper v0.7.1 // indirect
|
||||
github.com/kunwardeep/paralleltest v1.0.14 // 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/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
|
||||
@@ -184,8 +177,8 @@ require (
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
|
||||
github.com/manuelarte/funcorder v0.5.0 // indirect
|
||||
github.com/maratori/testableexamples v1.0.0 // indirect
|
||||
github.com/maratori/testpackage v1.1.1 // indirect
|
||||
github.com/maratori/testableexamples v1.0.1 // indirect
|
||||
github.com/maratori/testpackage v1.1.2 // indirect
|
||||
github.com/matoous/godox v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
@@ -199,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.21.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
|
||||
@@ -208,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
|
||||
@@ -217,13 +210,14 @@ 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.8 // 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
|
||||
@@ -238,7 +232,6 @@ require (
|
||||
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.3 // indirect
|
||||
github.com/tetafro/godot v1.5.4 // indirect
|
||||
github.com/timakin/bodyclose v0.0.0-20241222091800-1db5c5ca4d67 // indirect
|
||||
@@ -258,24 +251,24 @@ require (
|
||||
gitlab.com/bosi/decorder v0.4.2 // indirect
|
||||
go-simpler.org/musttag v0.14.0 // indirect
|
||||
go-simpler.org/sloglint v0.11.1 // indirect
|
||||
go.augendre.info/arangolint v0.2.0 // indirect
|
||||
go.augendre.info/fatcontext v0.8.1 // 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
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20250911091902-df9299821621 // indirect
|
||||
golang.org/x/net v0.44.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/tools v0.37.0 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/tools v0.39.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
honnef.co/go/tools v0.6.1 // indirect
|
||||
mvdan.cc/gofumpt v0.9.1 // 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
|
||||
)
|
||||
|
||||
|
||||
+131
-230
@@ -45,22 +45,16 @@ 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/errname v1.1.1 h1:bllB7mlIbTVzO9jmSWVWLjxTEbGBVQ1Ff/ClQgtPw9Q=
|
||||
github.com/Antonboom/errname v1.1.1/go.mod h1:gjhe24xoxXp0ScLtHzjiXp0Exi1RFLKJb0bVBtWKCWQ=
|
||||
github.com/Antonboom/nilnil v1.1.0 h1:jGxJxjgYS3VUUtOTNk8Z1icwT5ESpLH/426fjmQG+ng=
|
||||
github.com/Antonboom/nilnil v1.1.0/go.mod h1:b7sAlogQjFa1wV8jUW3o4PMzDVFLbTux+xnQdvzdcIE=
|
||||
github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksufQ=
|
||||
github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II=
|
||||
github.com/Antonboom/testifylint v1.6.1 h1:6ZSytkFWatT8mwZlmRCHkWz1gPi+q6UBSbieji2Gj/o=
|
||||
github.com/Antonboom/testifylint v1.6.1/go.mod h1:k+nEkathI2NFjKO6HvwmSrbzUcQ6FAnbZV+ZRrnXPLI=
|
||||
github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ=
|
||||
github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4=
|
||||
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
|
||||
@@ -71,22 +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.63.0 h1:fwcdHpwaUe4/+q349PxptzAIn8gVE6Yke8TgW0LsDxQ=
|
||||
github.com/air-verse/air v1.63.0/go.mod h1:RyCQVx2+3Zz2BzoqkukYiGmWkWXNKMf0x5ubIFcUB8Q=
|
||||
github.com/air-verse/air v1.63.1 h1:N6kD5niKKVx0wF2mW0mgK6LNfJqP5/lCAqm3WWl9vlw=
|
||||
github.com/air-verse/air v1.63.1/go.mod h1:Dnn4m4DlC9IQiNd3ir57SOdpvGJ3gnC1+OlIGMi2fJY=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
||||
@@ -114,10 +106,10 @@ 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=
|
||||
@@ -160,10 +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.2.0 h1:PyCCwd3Q7abGs3e34IW4jLYlBS+FbsU6iK+Tb3NnDp4=
|
||||
github.com/bombsimon/wsl/v5 v5.2.0/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=
|
||||
@@ -175,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=
|
||||
@@ -184,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=
|
||||
@@ -205,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=
|
||||
@@ -248,10 +236,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LV
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
|
||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
|
||||
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/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=
|
||||
@@ -273,20 +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.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/ghostiam/protogetter v0.3.16 h1:UkrisuJBYLnZW6FcYUNBDJOqY3X22RtoYMlCsiNlFFA=
|
||||
github.com/ghostiam/protogetter v0.3.16/go.mod h1:4SRRIv6PcjkIMpUkRUsP4TsUTqO/N3Fmvwivuc/sCHA=
|
||||
github.com/go-critic/go-critic v0.13.0 h1:kJzM7wzltQasSUXtYyTl6UaPVySO6GkaR1thFnJ6afY=
|
||||
github.com/go-critic/go-critic v0.13.0/go.mod h1:M/YeuJ3vOCQDnP2SU+ZhjgRzwzcBW87JqLpMJLrZDLI=
|
||||
github.com/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=
|
||||
@@ -296,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=
|
||||
@@ -306,74 +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.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM=
|
||||
github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU=
|
||||
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
|
||||
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
|
||||
github.com/go-openapi/jsonreference v0.21.1 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA=
|
||||
github.com/go-openapi/jsonreference v0.21.1/go.mod h1:PWs8rO4xxTUqKGu+lEvvCxD5k2X7QYkKAepJyCmSTT8=
|
||||
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
|
||||
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
|
||||
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
|
||||
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
||||
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
|
||||
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
|
||||
github.com/go-openapi/swag v0.24.1 h1:DPdYTZKo6AQCRqzwr/kGkxJzHhpKxZ9i/oX0zag+MF8=
|
||||
github.com/go-openapi/swag v0.24.1/go.mod h1:sm8I3lCPlspsBBwUm1t5oZeWZS0s7m/A+Psg0ooRU0A=
|
||||
github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8=
|
||||
github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo=
|
||||
github.com/go-openapi/swag/cmdutils v0.24.0 h1:KlRCffHwXFI6E5MV9n8o8zBRElpY4uK4yWyAMWETo9I=
|
||||
github.com/go-openapi/swag/cmdutils v0.24.0/go.mod h1:uxib2FAeQMByyHomTlsP8h1TtPd54Msu2ZDU/H5Vuf8=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY=
|
||||
github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
|
||||
github.com/go-openapi/swag/conv v0.24.0 h1:ejB9+7yogkWly6pnruRX45D1/6J+ZxRu92YFivx54ik=
|
||||
github.com/go-openapi/swag/conv v0.24.0/go.mod h1:jbn140mZd7EW2g8a8Y5bwm8/Wy1slLySQQ0ND6DPc2c=
|
||||
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
|
||||
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
|
||||
github.com/go-openapi/swag/fileutils v0.24.0 h1:U9pCpqp4RUytnD689Ek/N1d2N/a//XCeqoH508H5oak=
|
||||
github.com/go-openapi/swag/fileutils v0.24.0/go.mod h1:3SCrCSBHyP1/N+3oErQ1gP+OX1GV2QYFSnrTbzwli90=
|
||||
github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU=
|
||||
github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M=
|
||||
github.com/go-openapi/swag/jsonname v0.24.0 h1:2wKS9bgRV/xB8c62Qg16w4AUiIrqqiniJFtZGi3dg5k=
|
||||
github.com/go-openapi/swag/jsonname v0.24.0/go.mod h1:GXqrPzGJe611P7LG4QB9JKPtUZ7flE4DOVechNaDd7Q=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
|
||||
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
|
||||
github.com/go-openapi/swag/jsonutils v0.24.0 h1:F1vE1q4pg1xtO3HTyJYRmEuJ4jmIp2iZ30bzW5XgZts=
|
||||
github.com/go-openapi/swag/jsonutils v0.24.0/go.mod h1:vBowZtF5Z4DDApIoxcIVfR8v0l9oq5PpYRUuteVu6f0=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
|
||||
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
|
||||
github.com/go-openapi/swag/loading v0.24.0 h1:ln/fWTwJp2Zkj5DdaX4JPiddFC5CHQpvaBKycOlceYc=
|
||||
github.com/go-openapi/swag/loading v0.24.0/go.mod h1:gShCN4woKZYIxPxbfbyHgjXAhO61m88tmjy0lp/LkJk=
|
||||
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
|
||||
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
|
||||
github.com/go-openapi/swag/mangling v0.24.0 h1:PGOQpViCOUroIeak/Uj/sjGAq9LADS3mOyjznmHy2pk=
|
||||
github.com/go-openapi/swag/mangling v0.24.0/go.mod h1:Jm5Go9LHkycsz0wfoaBDkdc4CkpuSnIEf62brzyCbhc=
|
||||
github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync=
|
||||
github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ=
|
||||
github.com/go-openapi/swag/netutils v0.24.0 h1:Bz02HRjYv8046Ycg/w80q3g9QCWeIqTvlyOjQPDjD8w=
|
||||
github.com/go-openapi/swag/netutils v0.24.0/go.mod h1:WRgiHcYTnx+IqfMCtu0hy9oOaPR0HnPbmArSRN1SkZM=
|
||||
github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4=
|
||||
github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE=
|
||||
github.com/go-openapi/swag/stringutils v0.24.0 h1:i4Z/Jawf9EvXOLUbT97O0HbPUja18VdBxeadyAqS1FM=
|
||||
github.com/go-openapi/swag/stringutils v0.24.0/go.mod h1:5nUXB4xA0kw2df5PRipZDslPJgJut+NjL7D25zPZ/4w=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
|
||||
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
|
||||
github.com/go-openapi/swag/typeutils v0.24.0 h1:d3szEGzGDf4L2y1gYOSSLeK6h46F+zibnEas2Jm/wIw=
|
||||
github.com/go-openapi/swag/typeutils v0.24.0/go.mod h1:q8C3Kmk/vh2VhpCLaoR2MVWOGP8y7Jc8l82qCTd1DYI=
|
||||
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
|
||||
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
|
||||
github.com/go-openapi/swag/yamlutils v0.24.0 h1:bhw4894A7Iw6ne+639hsBNRHg9iZg/ISrOVr+sJGp4c=
|
||||
github.com/go-openapi/swag/yamlutils v0.24.0/go.mod h1:DpKv5aYuaGm/sULePoeiG8uwMpZSfReo1HR3Ik0yaG8=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
|
||||
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
|
||||
github.com/go-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=
|
||||
@@ -408,10 +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/godoc-lint/godoc-lint v0.10.0 h1:OcyrziBi18sQSEpib6NesVHEJ/Xcng97NunePBA48g4=
|
||||
github.com/godoc-lint/godoc-lint v0.10.0/go.mod h1:KleLcHu/CGSvkjUH2RvZyoK1MBC7pDQg4NxMYLcBBsw=
|
||||
github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E=
|
||||
github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0=
|
||||
github.com/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=
|
||||
@@ -434,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-20250917161122-64cb148137c6 h1:8VSqEdhJCzXKe/SYulyQ7jgHNxP+BoCPmqzWF5STIyw=
|
||||
github.com/golang/geo v0.0.0-20250917161122-64cb148137c6/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=
|
||||
@@ -471,23 +422,16 @@ github.com/golangci/asciicheck v0.5.0 h1:jczN/BorERZwK8oiFBOGvlGPknhvq0bjnysTj4n
|
||||
github.com/golangci/asciicheck v0.5.0/go.mod h1:5RMNAInbNFw2krqN6ibBxN/zfRFa9S6tA1nPdM0l8qQ=
|
||||
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 h1:WUvBfQL6EW/40l6OmeSBYQJNSif4O11+bmWEz+C7FYw=
|
||||
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32/go.mod h1:NUw9Zr2Sy7+HxzdjIULge71wI6yEg1lWQr7Evcu8K0E=
|
||||
github.com/golangci/go-printf-func-name v0.1.0 h1:dVokQP+NMTO7jwO4bwsRwLWeudOVUPPyAKJuzv8pEJU=
|
||||
github.com/golangci/go-printf-func-name v0.1.0/go.mod h1:wqhWFH5mUdJQhweRnldEywnR5021wTdZSNgwYceV14s=
|
||||
github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarogrvjO9AfiW3B4U=
|
||||
github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
|
||||
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE=
|
||||
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
|
||||
github.com/golangci/golangci-lint v1.64.8 h1:y5TdeVidMtBGG32zgSC7ZXTFNHrsJkDnpO4ItB3Am+I=
|
||||
github.com/golangci/golangci-lint/v2 v2.4.0 h1:qz6O6vr7kVzXJqyvHjHSz5fA3D+PM8v96QU5gxZCNWM=
|
||||
github.com/golangci/golangci-lint/v2 v2.4.0/go.mod h1:Oq7vuAf6L1iNL34uHDcsIF6Mnc0amOPdsT3/GlpHD+I=
|
||||
github.com/golangci/golangci-lint/v2 v2.5.0 h1:BDRg4ASm4J1y/DSRY6zwJ5tr5Yy8ZqbZ79XrCeFxaQo=
|
||||
github.com/golangci/golangci-lint/v2 v2.5.0/go.mod h1:IJtWJBZkLbx7AVrIUzLd8Oi3ADtwaNpWbR3wthVWHcc=
|
||||
github.com/golangci/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=
|
||||
github.com/golangci/misspell v0.7.0/go.mod h1:WZyyI2P3hxPY2UVHs3cS8YcllAeyfquQcKfdeE9AFVg=
|
||||
github.com/golangci/nilerr v0.0.0-20250918000102-015671e622fe h1:F1pK9tBy41i7eesBFkSNMldwtiAaWiU+3fT/24sTnNI=
|
||||
github.com/golangci/nilerr v0.0.0-20250918000102-015671e622fe/go.mod h1:CtTxAluxD2ng9aIT9bPrVoMuISFWCD+SaxtvYtdWA2k=
|
||||
github.com/golangci/plugin-module-register v0.1.2 h1:e5WM6PO6NIAEcij3B053CohVp3HIYbzSuP53UAYgOpg=
|
||||
github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw=
|
||||
github.com/golangci/revgrep v0.8.0 h1:EZBctwbVd0aMeRnNUsFogoyayvKHyxlV3CdUA46FX2s=
|
||||
@@ -521,42 +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 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.1 h1:NTim7RgpZ0+JAuep+2n1a7oHJbnyaksnWJStdM7qMeU=
|
||||
github.com/gtsteffaniak/go-logger v0.2.1/go.mod h1:U3ZkdAclcxXNMvoQLq+zDkqcTmdqhX3hTf+6AuTo6v0=
|
||||
github.com/gtsteffaniak/go-logger v0.2.2 h1:5bEX4ndHK0CsHZjw76T+j0phKoaEGiDwC8erseF99yU=
|
||||
github.com/gtsteffaniak/go-logger v0.2.2/go.mod h1:U3ZkdAclcxXNMvoQLq+zDkqcTmdqhX3hTf+6AuTo6v0=
|
||||
github.com/gtsteffaniak/go-logger v0.2.3-0.20250926211917-4f1ceadcc169 h1:kW5TWd6IsIB3ypImyoPGWj+jzXaG3muQ1lXdJpj7KCc=
|
||||
github.com/gtsteffaniak/go-logger v0.2.3-0.20250926211917-4f1ceadcc169/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
|
||||
github.com/gtsteffaniak/go-logger v0.2.3-0.20250926212455-9b14f7183bd2 h1:gCyi3Whqu0rp8+HG/VEjjBi1vZvp+yBl4c8pHABeV0s=
|
||||
github.com/gtsteffaniak/go-logger v0.2.3-0.20250926212455-9b14f7183bd2/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
|
||||
github.com/gtsteffaniak/go-logger v0.2.3-0.20250926212932-e91a612f674c h1:JGf2H8yweS5xYO5kGF0/IDs4WalX3TMhLq4D2gDSQ/g=
|
||||
github.com/gtsteffaniak/go-logger v0.2.3-0.20250926212932-e91a612f674c/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
|
||||
github.com/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=
|
||||
@@ -602,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=
|
||||
@@ -611,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=
|
||||
@@ -621,26 +556,20 @@ 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/kulti/thelper v0.7.1 h1:fI8QITAoFVLx+y+vSyuLBP+rcVIB8jKooNSCT2EiI98=
|
||||
github.com/kulti/thelper v0.7.1/go.mod h1:NsMjfQEy6sd+9Kfw8kCP61W1I0nerGSYSFnGaxQkcbs=
|
||||
github.com/kunwardeep/paralleltest v1.0.14 h1:wAkMoMeGX/kGfhQBPODT/BL8XhK23ol/nuQ3SwFaUw8=
|
||||
github.com/kunwardeep/paralleltest v1.0.14/go.mod h1:di4moFqtfz3ToSKxhNjhOZL+696QtJGCFe132CbBLGk=
|
||||
github.com/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/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.1 h1:bTgKjjc2sQcsgPiT902+aadvMjCeMHrY7ly2XKFORIk=
|
||||
github.com/ldez/tagliatelle v0.7.1/go.mod h1:3zjxUpsNB2aEZScWiZTHrAXOl1x25t3cRmzfK1mlo2I=
|
||||
github.com/ldez/tagliatelle v0.7.2 h1:KuOlL70/fu9paxuxbeqlicJnCspCRjH0x8FW+NfgYUk=
|
||||
github.com/ldez/tagliatelle v0.7.2/go.mod h1:PtGgm163ZplJfZMZ2sf5nhUT170rSuPgBimoyYtdaSI=
|
||||
github.com/ldez/usetesting v0.5.0 h1:3/QtzZObBKLy1F4F8jLuKJiKBjjVFi1IavpoWbmqLwc=
|
||||
@@ -659,16 +588,14 @@ 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=
|
||||
@@ -683,8 +610,6 @@ 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=
|
||||
@@ -716,10 +641,8 @@ github.com/nishanths/exhaustive v0.12.0 h1:vIY9sALmw6T/yxiASewa4TQcFsVYZQQRUQJhK
|
||||
github.com/nishanths/exhaustive v0.12.0/go.mod h1:mEZ95wPIZW+x8kC4TgC+9YCUgiST7ecevsVDTgc2obs=
|
||||
github.com/nishanths/predeclared v0.2.2 h1:V2EPdZPliZymNAn79T8RkNApBjMmVKh5XRpLm/w98Vk=
|
||||
github.com/nishanths/predeclared v0.2.2/go.mod h1:RROzoN6TnGQupbC+lqggsOlcgysk3LMK/HI84Mp280c=
|
||||
github.com/nunnatsa/ginkgolinter v0.20.0 h1:OmWLkAFO2HUTYcU6mprnKud1Ey5pVdiVNYGO5HVicx8=
|
||||
github.com/nunnatsa/ginkgolinter v0.20.0/go.mod h1:dCIuFlTPfQerXgGUju3VygfAFPdC5aE1mdacCDKDJcQ=
|
||||
github.com/nunnatsa/ginkgolinter v0.21.0 h1:IYwuX+ajy3G1MezlMLB1BENRtFj16+Evyi4uki1NOOQ=
|
||||
github.com/nunnatsa/ginkgolinter v0.21.0/go.mod h1:QlzY9UP9zaqu58FjYxhp9bnjuwXwG1bfW5rid9ChNMw=
|
||||
github.com/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=
|
||||
@@ -730,11 +653,10 @@ github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
|
||||
github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g=
|
||||
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
|
||||
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
||||
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
||||
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||
github.com/onsi/gomega v1.38.0 h1:c/WX+w8SLAinvuKKQFh77WEucCnPk4j2OTUr7lt7BeY=
|
||||
github.com/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=
|
||||
@@ -784,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=
|
||||
@@ -805,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=
|
||||
@@ -817,10 +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.8 h1:3NMpmfXO8wAVFZPNsd3EscOTa32Jyo6FLLlW53bexMI=
|
||||
github.com/securego/gosec/v2 v2.22.8/go.mod h1:ZAw8K2ikuH9qDlfdV87JmNghnVfKB1XC7+TVzk6Utto=
|
||||
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=
|
||||
@@ -841,16 +763,11 @@ 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.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
|
||||
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
|
||||
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
@@ -878,8 +795,6 @@ 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.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=
|
||||
@@ -890,8 +805,6 @@ 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=
|
||||
@@ -943,18 +856,14 @@ 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/fatcontext v0.8.1 h1:/T4+cCjpL9g71gJpcFAgVo/K5VFpqlN+NPU7QXxD5+A=
|
||||
go.augendre.info/fatcontext v0.8.1/go.mod h1:r3Qz4ZOzex66wfyyj5VZ1xUcl81vzvHQ6/GWzzlMEwA=
|
||||
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=
|
||||
@@ -982,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.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
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=
|
||||
@@ -998,14 +907,12 @@ golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11
|
||||
golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b h1:KdrhdYPDUvJTvrDK9gdjfFd6JTk8vA1WJoldYSi0kHo=
|
||||
golang.org/x/exp/typeparams v0.0.0-20250620022241-b7579e27df2b/go.mod h1:LKZHyeOpPuZcMgxeHjJp4p5yvxrCX1xDvH10zYHhjjQ=
|
||||
golang.org/x/exp/typeparams v0.0.0-20250911091902-df9299821621 h1:Yl4H5w2RV7L/dvSHp2GerziT5K2CORgFINPaMFxWGWw=
|
||||
golang.org/x/exp/typeparams v0.0.0-20250911091902-df9299821621/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
|
||||
golang.org/x/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.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
|
||||
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
|
||||
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=
|
||||
@@ -1031,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.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||
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=
|
||||
@@ -1075,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.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
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.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
|
||||
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
||||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -1100,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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -1155,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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -1173,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.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
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.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
|
||||
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
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=
|
||||
@@ -1212,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=
|
||||
@@ -1222,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.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
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=
|
||||
@@ -1353,12 +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/gofumpt v0.9.1 h1:p5YT2NfFWsYyTieYgwcQ8aKV3xRvFH4uuN/zB2gBbMQ=
|
||||
mvdan.cc/gofumpt v0.9.1/go.mod h1:3xYtNemnKiXaTh6R4VtlqDATFwBbdXI8lJvH/4qk7mw=
|
||||
mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4 h1:WjUu4yQoT5BHT1w8Zu56SP8367OuBV5jvo+4Ulppyf8=
|
||||
mvdan.cc/unparam v0.0.0-20250301125049-0df0534333a4/go.mod h1:rthT7OuvRbaGcd5ginj6dA2oLE7YNlta9qhBNNdCaLE=
|
||||
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=
|
||||
|
||||
+80
-1
@@ -133,7 +133,7 @@ func accessPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
|
||||
// accessDeleteHandler deletes a single user or group from a rule.
|
||||
// @Summary Delete access rule entry
|
||||
// @Description Delete a user or group from an allow or deny list for a sourcePath and indexPath.
|
||||
// @Description Delete a user or group from an allow or deny list for a sourcePath and indexPath. When cascade=true, removes the user/group from the specified path and all subpaths.
|
||||
// @Tags Access
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
@@ -142,6 +142,7 @@ func accessPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
// @Param ruleType query string true "Rule type (allow or deny)"
|
||||
// @Param ruleCategory query string true "Rule category (user or group)"
|
||||
// @Param value query string true "Username or groupname to remove"
|
||||
// @Param cascade query boolean false "Cascade delete to all subpaths (default: false)"
|
||||
// @Success 200 {object} map[string]string "Rule entry deleted"
|
||||
// @Failure 404 {object} map[string]string "Not found"
|
||||
// @Failure 400 {object} map[string]string "Bad request"
|
||||
@@ -158,12 +159,47 @@ func accessDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
ruleType := r.URL.Query().Get("ruleType")
|
||||
ruleCategory := r.URL.Query().Get("ruleCategory")
|
||||
value := r.URL.Query().Get("value")
|
||||
cascade := r.URL.Query().Get("cascade") == "true"
|
||||
allow := ruleType == "allow"
|
||||
|
||||
if indexPath == "" || ruleCategory == "" || (ruleCategory != "all" && value == "") {
|
||||
return http.StatusBadRequest, fmt.Errorf("path, ruleCategory, and value are required, unless ruleCategory is 'all'")
|
||||
}
|
||||
|
||||
// Handle cascade delete
|
||||
if cascade {
|
||||
if ruleCategory == "all" {
|
||||
return http.StatusBadRequest, fmt.Errorf("cascade delete is not supported for 'all' rule category")
|
||||
}
|
||||
|
||||
var count int
|
||||
var err error
|
||||
|
||||
switch ruleCategory {
|
||||
case "user":
|
||||
count, err = store.Access.RemoveUserCascade(index.Path, indexPath, value, allow)
|
||||
case "group":
|
||||
count, err = store.Access.RemoveGroupCascade(index.Path, indexPath, value, allow)
|
||||
default:
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid ruleCategory for cascade delete: must be 'user' or 'group'")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Errorf("failed to cascade delete rule entry: %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to cascade delete rule entry: %w", err)
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
return http.StatusNotFound, fmt.Errorf("no entries found in rule hierarchy")
|
||||
}
|
||||
|
||||
return renderJSON(w, r, map[string]interface{}{
|
||||
"message": "rule entries deleted",
|
||||
"count": count,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle non-cascade delete (original behavior)
|
||||
var found bool
|
||||
var err error
|
||||
if allow {
|
||||
@@ -265,3 +301,46 @@ func groupDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
}
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
// accessPatchHandler updates an access rule's path.
|
||||
// @Summary Update access rule path
|
||||
// @Description Updates the path for a specific access rule
|
||||
// @Tags Access
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body object{source=string,oldPath=string,newPath=string} true "Source, old path, and new path"
|
||||
// @Success 200 {object} map[string]string "Rule path updated successfully"
|
||||
// @Failure 400 {object} map[string]string "Bad request - missing or invalid parameters"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/access [patch]
|
||||
func accessPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
var body struct {
|
||||
Source string `json:"source"`
|
||||
OldPath string `json:"oldPath"`
|
||||
NewPath string `json:"newPath"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("failed to decode body: %w", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
if body.Source == "" || body.OldPath == "" || body.NewPath == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("source, oldPath, and newPath are required")
|
||||
}
|
||||
|
||||
// Get the index for the source
|
||||
index := indexing.GetIndex(body.Source)
|
||||
if index == nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("source not found: %s", body.Source)
|
||||
}
|
||||
|
||||
// Update the access rule path
|
||||
err := store.Access.UpdateRulePath(index.Path, body.OldPath, body.NewPath)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to update rule path: %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to update rule path: %w", err)
|
||||
}
|
||||
|
||||
return renderJSON(w, r, map[string]string{"message": "rule path updated"})
|
||||
}
|
||||
@@ -49,6 +49,8 @@ func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
Api: strings.Contains(permissionsStr, "api") && d.user.Permissions.Api,
|
||||
Admin: strings.Contains(permissionsStr, "admin") && d.user.Permissions.Admin,
|
||||
Modify: strings.Contains(permissionsStr, "modify") && d.user.Permissions.Modify,
|
||||
Delete: strings.Contains(permissionsStr, "delete") && d.user.Permissions.Delete,
|
||||
Create: strings.Contains(permissionsStr, "create") && d.user.Permissions.Create,
|
||||
Share: strings.Contains(permissionsStr, "share") && d.user.Permissions.Share,
|
||||
Realtime: strings.Contains(permissionsStr, "realtime") && d.user.Permissions.Realtime,
|
||||
}
|
||||
|
||||
+76
-25
@@ -28,7 +28,7 @@ import (
|
||||
// then checks for query parameter
|
||||
func extractToken(r *http.Request) (string, error) {
|
||||
hasToken := false
|
||||
tokenObj, err := r.Cookie("auth")
|
||||
tokenObj, err := r.Cookie("filebrowser_quantum_jwt")
|
||||
if err == nil {
|
||||
hasToken = true
|
||||
token := tokenObj.Value
|
||||
@@ -81,7 +81,10 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
|
||||
Username: proxyUser,
|
||||
}
|
||||
settings.ApplyUserDefaults(&user)
|
||||
err = storage.CreateUser(user)
|
||||
if user.Username == config.Auth.AdminUsername {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
err = storage.CreateUser(user, user.Permissions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -96,6 +99,13 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
|
||||
if data.user.LoginMethod != users.LoginMethodProxy {
|
||||
return nil, errors.ErrWrongLoginMethod
|
||||
}
|
||||
if data.user.Username == config.Auth.AdminUsername && !data.user.Permissions.Admin {
|
||||
data.user.Permissions.Admin = true
|
||||
err = store.Users.Update(data.user, true, "Permissions")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return data.user, nil
|
||||
}
|
||||
|
||||
@@ -108,8 +118,11 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
|
||||
// @Success 200 {string} string "JWT token for authentication"
|
||||
// @Failure 403 {object} map[string]string "Forbidden - authentication failed"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/login [post]
|
||||
// @Router /api/auth/login [post]
|
||||
func loginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if d.user.LoginMethod == users.LoginMethodProxy {
|
||||
return printToken(w, r, d.user)
|
||||
}
|
||||
passwordUser := d.user.LoginMethod == users.LoginMethodPassword
|
||||
enforcedOtp := config.Auth.Methods.PasswordAuth.EnforcedOtp
|
||||
missingOtp := d.user.TOTPSecret == ""
|
||||
@@ -129,6 +142,24 @@ func loginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
|
||||
// @Router /api/auth/logout [post]
|
||||
func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
defer auth.RevokeAPIKey(d.token)
|
||||
|
||||
// Clear the authentication cookie by setting it to expire in the past
|
||||
// Get the correct domain for cookie - prefer X-Forwarded-Host from reverse proxy
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
cookie := &http.Cookie{
|
||||
Name: "filebrowser_quantum_jwt",
|
||||
Value: "",
|
||||
Domain: strings.Split(host, ":")[0],
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Unix(0, 0), // Expire immediately
|
||||
MaxAge: -1, // Delete cookie
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
|
||||
logoutUrl := fmt.Sprintf("%vlogin", config.Server.BaseURL) // Default fallback
|
||||
if d.user != nil && d.user.LoginMethod == users.LoginMethodProxy {
|
||||
proxyRedirectUrl := config.Auth.Methods.ProxyAuth.LogoutRedirectUrl
|
||||
@@ -159,49 +190,44 @@ func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
type signupBody struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// signupHandler registers a new user account.
|
||||
// @Summary User signup
|
||||
// @Description Register a new user account with a username and password.
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body signupBody true "User signup details"
|
||||
// @Success 201 {string} string "User created successfully"
|
||||
// @Failure 400 {object} map[string]string "Bad request - invalid input"
|
||||
// @Failure 405 {object} map[string]string "Method not allowed - signup is disabled"
|
||||
// @Failure 409 {object} map[string]string "Conflict - user already exists"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /api/signup [post]
|
||||
// @Router /api/auth/signup [post]
|
||||
func signupHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !settings.Config.Auth.Methods.PasswordAuth.Signup {
|
||||
return http.StatusMethodNotAllowed, fmt.Errorf("signup is disabled")
|
||||
}
|
||||
if r.Body == nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("no post body provided")
|
||||
}
|
||||
info := &signupBody{}
|
||||
err := json.NewDecoder(r.Body).Decode(info)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid request body: %v", err)
|
||||
|
||||
// Get credentials from query parameters
|
||||
username := r.URL.Query().Get("username")
|
||||
password := r.URL.Query().Get("password")
|
||||
|
||||
// Validate that we have both username and password
|
||||
if username == "" || password == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("username and password are required")
|
||||
}
|
||||
|
||||
user := users.User{
|
||||
Username: info.Username,
|
||||
Username: username,
|
||||
NonAdminEditable: users.NonAdminEditable{
|
||||
Password: info.Password,
|
||||
Password: password,
|
||||
},
|
||||
LoginMethod: users.LoginMethodPassword,
|
||||
}
|
||||
user.Permissions = settings.Config.UserDefaults.Permissions
|
||||
err = storage.CreateUser(user)
|
||||
err := storage.CreateUser(user, settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Permissions))
|
||||
if err != nil {
|
||||
logger.Debug(err.Error())
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return http.StatusConflict, fmt.Errorf("user already exists")
|
||||
// Return the actual error message instead of a generic one
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
return 201, nil
|
||||
}
|
||||
@@ -221,14 +247,39 @@ func renewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
|
||||
return printToken(w, r, d.user)
|
||||
}
|
||||
|
||||
func printToken(w http.ResponseWriter, _ *http.Request, user *users.User) (int, error) {
|
||||
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Permissions)
|
||||
func printToken(w http.ResponseWriter, r *http.Request, user *users.User) (int, error) {
|
||||
expires := time.Hour * time.Duration(config.Auth.TokenExpirationHours)
|
||||
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "key already exists with same name") {
|
||||
return http.StatusConflict, err
|
||||
}
|
||||
return 401, errors.ErrUnauthorized
|
||||
}
|
||||
|
||||
// Add 30 minutes buffer so expired token doesn't get automatically deleted by the browser
|
||||
// This allows backend to identify expired sessions and provide better user feedback
|
||||
expiresTime := time.Now().Add(expires).Add(time.Minute * 30)
|
||||
|
||||
// Set the authentication token as an HTTP cookie
|
||||
// Get the correct domain for cookie - prefer X-Forwarded-Host from reverse proxy
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
cookie := &http.Cookie{
|
||||
Name: "filebrowser_quantum_jwt",
|
||||
Value: signed.Key,
|
||||
Domain: strings.Split(host, ":")[0], // Set domain to the host without port
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: expiresTime,
|
||||
// HttpOnly: true, // Cannot use HttpOnly since frontend needs to read cookie for renew operations
|
||||
// Secure: true, // Enable this in production with HTTPS
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
|
||||
// Still return token in body for backward compatibility and state management
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
if _, err := w.Write([]byte(signed.Key)); err != nil {
|
||||
return 401, errors.ErrUnauthorized
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/go-cache/cache"
|
||||
)
|
||||
|
||||
// duplicateSearchMutex serializes duplicate searches to run one at a time
|
||||
// This is separate from the index's scanMutex to avoid conflicts with indexing
|
||||
var duplicateSearchMutex sync.Mutex
|
||||
|
||||
// duplicateResultsCache caches duplicate search results for 15 seconds
|
||||
var duplicateResultsCache = cache.NewCache[[]duplicateGroup](15 * time.Second)
|
||||
|
||||
// fileLocation is a minimal reference to a file in the index
|
||||
// Used during duplicate detection to avoid allocating full SearchResult objects
|
||||
type fileLocation struct {
|
||||
dirPath string // path to directory in index
|
||||
fileIdx int // index in directory's Files slice
|
||||
}
|
||||
|
||||
type duplicateGroup struct {
|
||||
Size int64 `json:"size"`
|
||||
Count int `json:"count"`
|
||||
Files []*indexing.SearchResult `json:"files"`
|
||||
}
|
||||
|
||||
type duplicatesOptions struct {
|
||||
source string
|
||||
searchScope string
|
||||
combinedPath string
|
||||
minSize int64
|
||||
useChecksum bool
|
||||
}
|
||||
|
||||
// duplicatesHandler handles requests to find duplicate files
|
||||
//
|
||||
// This endpoint finds files with the same size, uses fuzzy filename matching
|
||||
// for initial grouping (fast), then verifies all final groups with checksums.
|
||||
//
|
||||
// It's optimized to handle large directories efficiently by:
|
||||
// 1. Filtering by minimum size first
|
||||
// 2. Grouping by size
|
||||
// 3. Initial grouping with fuzzy filename similarity (fast)
|
||||
// 4. Final verification with checksums on all groups (accurate)
|
||||
//
|
||||
// @Summary Find Duplicate Files
|
||||
// @Description Finds duplicate files based on size and fuzzy filename matching
|
||||
// @Tags Duplicates
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param source query string true "Source name for the desired source"
|
||||
// @Param scope query string false "path within user scope to search"
|
||||
// @Param minSizeMb query int false "Minimum file size in megabytes (default: 1)"
|
||||
// @Success 200 {array} duplicateGroup "List of duplicate file groups"
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
// @Router /api/duplicates [get]
|
||||
func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
opts, err := prepDuplicatesOptions(r, d)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
index := indexing.GetIndex(opts.source)
|
||||
if index == nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("index not found for source %s", opts.source)
|
||||
}
|
||||
|
||||
// Generate cache key from all input parameters that affect results
|
||||
// Checksums are always enabled, so cache key doesn't need to include that flag
|
||||
cacheKey := fmt.Sprintf("%s:%s:%d", index.Path, opts.combinedPath, opts.minSize)
|
||||
|
||||
// Check cache first (before acquiring mutex)
|
||||
if cachedResults, ok := duplicateResultsCache.Get(cacheKey); ok {
|
||||
return renderJSON(w, r, cachedResults)
|
||||
}
|
||||
|
||||
// Reject concurrent requests
|
||||
if !duplicateSearchMutex.TryLock() {
|
||||
return http.StatusServiceUnavailable, fmt.Errorf("another duplicate search is currently running, please try again in a moment")
|
||||
}
|
||||
defer duplicateSearchMutex.Unlock()
|
||||
|
||||
// Check cache again after acquiring lock (another request might have just completed)
|
||||
if cachedResults, ok := duplicateResultsCache.Get(cacheKey); ok {
|
||||
return renderJSON(w, r, cachedResults)
|
||||
}
|
||||
|
||||
// Find duplicates using index-native approach to minimize memory allocation
|
||||
// This avoids creating SearchResult objects until we know the final limited set
|
||||
duplicateGroups := findDuplicatesInIndex(index, opts)
|
||||
|
||||
// Cache the results before returning
|
||||
duplicateResultsCache.Set(cacheKey, duplicateGroups)
|
||||
|
||||
return renderJSON(w, r, duplicateGroups)
|
||||
}
|
||||
|
||||
// findDuplicatesInIndex finds duplicates by working directly with index structures
|
||||
// This minimizes memory allocation by only creating SearchResult objects for final results
|
||||
func findDuplicatesInIndex(index *indexing.Index, opts *duplicatesOptions) []duplicateGroup {
|
||||
const maxTotalFiles = 1000 // Limit total files across all groups
|
||||
|
||||
// Step 1: Group files by size, working directly with index structures
|
||||
// Key: size, Value: list of (dirPath, fileIndex) minimal references
|
||||
sizeGroups := make(map[int64][]fileLocation)
|
||||
|
||||
index.ReadOnlyOperation(func() {
|
||||
for dirPath, dir := range index.GetDirectories() {
|
||||
// Skip directories not in scope
|
||||
if opts.combinedPath != "" && !strings.HasPrefix(dirPath, opts.combinedPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
for i := range dir.Files {
|
||||
file := &dir.Files[i]
|
||||
if file.Size >= opts.minSize {
|
||||
sizeGroups[file.Size] = append(sizeGroups[file.Size], fileLocation{dirPath, i})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Step 2: Process each size group to find duplicates using filename matching (fast)
|
||||
// We'll verify with checksums later on the final groups
|
||||
candidateGroups := []struct {
|
||||
size int64
|
||||
locations []fileLocation
|
||||
}{}
|
||||
totalFiles := 0
|
||||
|
||||
for size, locations := range sizeGroups {
|
||||
if len(locations) < 2 {
|
||||
continue // Skip files with no potential duplicates
|
||||
}
|
||||
|
||||
// Use filename matching for initial grouping (fast)
|
||||
groups := groupLocationsByFilename(locations, index, size)
|
||||
|
||||
// Collect candidate groups up to the limit
|
||||
for _, locGroup := range groups {
|
||||
if len(locGroup) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if adding this group would exceed the limit
|
||||
if totalFiles+len(locGroup) > maxTotalFiles {
|
||||
break
|
||||
}
|
||||
|
||||
candidateGroups = append(candidateGroups, struct {
|
||||
size int64
|
||||
locations []fileLocation
|
||||
}{size, locGroup})
|
||||
totalFiles += len(locGroup)
|
||||
}
|
||||
|
||||
// Stop processing more size groups if we've hit the limit
|
||||
if totalFiles >= maxTotalFiles {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Verify all final groups with checksums
|
||||
duplicateGroups := []duplicateGroup{}
|
||||
for _, candidate := range candidateGroups {
|
||||
// Verify with checksums
|
||||
verifiedGroups := groupLocationsByChecksum(candidate.locations, index, candidate.size)
|
||||
|
||||
// Create SearchResult objects only for verified duplicates
|
||||
for _, locGroup := range verifiedGroups {
|
||||
if len(locGroup) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
resultGroup := make([]*indexing.SearchResult, 0, len(locGroup))
|
||||
index.ReadOnlyOperation(func() {
|
||||
dirs := index.GetDirectories()
|
||||
for _, loc := range locGroup {
|
||||
dir := dirs[loc.dirPath]
|
||||
if dir == nil {
|
||||
continue
|
||||
}
|
||||
if loc.fileIdx >= len(dir.Files) {
|
||||
continue
|
||||
}
|
||||
file := &dir.Files[loc.fileIdx]
|
||||
|
||||
// CRITICAL: Verify size matches the expected size for this group
|
||||
// Index could have changed between initial grouping and now
|
||||
if file.Size != candidate.size {
|
||||
continue
|
||||
}
|
||||
|
||||
// Construct full path
|
||||
fullPath := filepath.Join(loc.dirPath, file.Name)
|
||||
|
||||
// Remove the user scope from path
|
||||
adjustedPath := strings.TrimPrefix(fullPath, opts.combinedPath)
|
||||
if adjustedPath == "" {
|
||||
adjustedPath = "/"
|
||||
}
|
||||
|
||||
resultGroup = append(resultGroup, &indexing.SearchResult{
|
||||
Path: adjustedPath,
|
||||
Type: file.Type,
|
||||
Size: file.Size,
|
||||
Modified: file.ModTime.Format(time.RFC3339),
|
||||
HasPreview: file.HasPreview,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if len(resultGroup) >= 2 {
|
||||
duplicateGroups = append(duplicateGroups, duplicateGroup{
|
||||
Size: candidate.size,
|
||||
Count: len(resultGroup),
|
||||
Files: resultGroup,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort groups by size (largest to smallest)
|
||||
sort.Slice(duplicateGroups, func(i, j int) bool {
|
||||
return duplicateGroups[i].Size > duplicateGroups[j].Size
|
||||
})
|
||||
|
||||
return duplicateGroups
|
||||
}
|
||||
|
||||
func prepDuplicatesOptions(r *http.Request, d *requestContext) (*duplicatesOptions, error) {
|
||||
source := r.URL.Query().Get("source")
|
||||
scope := r.URL.Query().Get("scope")
|
||||
minSizeMbStr := r.URL.Query().Get("minSizeMb")
|
||||
// Checksums are always enabled by default for final verification
|
||||
// First pass uses filename matching for speed, then checksums verify final groups
|
||||
useChecksum := true
|
||||
|
||||
// Default minimum size: 1MB
|
||||
minSizeMb := int64(1)
|
||||
if minSizeMbStr != "" {
|
||||
if _, err := fmt.Sscanf(minSizeMbStr, "%d", &minSizeMb); err != nil {
|
||||
return nil, fmt.Errorf("invalid minSizeMb parameter: %w", err)
|
||||
}
|
||||
}
|
||||
// Convert MB to bytes
|
||||
minSize := minSizeMb * 1024 * 1024
|
||||
|
||||
unencodedScope, err := url.PathUnescape(scope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
|
||||
searchScope := strings.TrimPrefix(unencodedScope, ".")
|
||||
|
||||
index := indexing.GetIndex(source)
|
||||
if index == nil {
|
||||
return nil, fmt.Errorf("index not found for source %s", source)
|
||||
}
|
||||
|
||||
userscope, err := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
combinedPath := index.MakeIndexPath(filepath.Join(userscope, searchScope))
|
||||
|
||||
return &duplicatesOptions{
|
||||
source: source,
|
||||
searchScope: searchScope,
|
||||
combinedPath: combinedPath,
|
||||
minSize: minSize,
|
||||
useChecksum: useChecksum,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// groupLocationsByChecksum groups file locations by partial checksum
|
||||
// Works with minimal fileLocation references instead of full SearchResult objects
|
||||
func groupLocationsByChecksum(locations []fileLocation, index *indexing.Index, fileSize int64) [][]fileLocation {
|
||||
checksumMap := make(map[string][]fileLocation)
|
||||
|
||||
// Build checksum groups
|
||||
for _, loc := range locations {
|
||||
// Construct filesystem path for checksum computation
|
||||
// index.Path is the absolute filesystem root, loc.dirPath is index-relative
|
||||
fullPath := filepath.Join(index.Path, loc.dirPath)
|
||||
|
||||
// Get the filename from the index and verify size still matches
|
||||
var fileName string
|
||||
var sizeMatches bool
|
||||
index.ReadOnlyOperation(func() {
|
||||
if dir := index.GetDirectories()[loc.dirPath]; dir != nil {
|
||||
if loc.fileIdx < len(dir.Files) {
|
||||
file := &dir.Files[loc.fileIdx]
|
||||
// CRITICAL: Verify size matches expected size for this group
|
||||
if file.Size == fileSize {
|
||||
fileName = file.Name
|
||||
sizeMatches = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if fileName == "" || !sizeMatches {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(fullPath, fileName)
|
||||
checksum, err := computePartialChecksum(filePath, fileSize)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
checksumMap[checksum] = append(checksumMap[checksum], loc)
|
||||
}
|
||||
|
||||
// Convert map to slice of groups
|
||||
groups := make([][]fileLocation, 0, len(checksumMap))
|
||||
for _, group := range checksumMap {
|
||||
if len(group) >= 2 {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// computePartialChecksum calculates MD5 hash by sampling key portions of a file
|
||||
// This is 10-100x faster than full file checksums while maintaining high accuracy
|
||||
// Strategy:
|
||||
// - Always read first 8KB (header/metadata)
|
||||
// - For files > 24KB: sample middle 8KB and last 8KB
|
||||
// - Total read: ~24KB max per file regardless of file size
|
||||
func computePartialChecksum(path string, size int64) (string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hash := md5.New()
|
||||
buf := make([]byte, 8192) // 8KB buffer
|
||||
|
||||
// Always read first 8KB (or entire file if smaller)
|
||||
n, err := io.ReadFull(file, buf)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
return "", err
|
||||
}
|
||||
hash.Write(buf[:n])
|
||||
|
||||
// For larger files, sample middle and end portions
|
||||
if size > 24576 { // 24KB
|
||||
// Sample middle 8KB
|
||||
middleOffset := size / 2
|
||||
if _, err := file.Seek(middleOffset, 0); err == nil {
|
||||
n, err := io.ReadFull(file, buf)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
return "", err
|
||||
}
|
||||
hash.Write(buf[:n])
|
||||
}
|
||||
|
||||
// Sample last 8KB
|
||||
endOffset := size - 8192
|
||||
if endOffset > 8192 { // Don't re-read start
|
||||
if _, err := file.Seek(endOffset, 0); err == nil {
|
||||
n, err := io.ReadFull(file, buf)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
return "", err
|
||||
}
|
||||
hash.Write(buf[:n])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%x", hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// groupLocationsByFilename groups file locations by fuzzy filename matching
|
||||
// Works with minimal fileLocation references instead of full SearchResult objects
|
||||
func groupLocationsByFilename(locations []fileLocation, index *indexing.Index, expectedSize int64) [][]fileLocation {
|
||||
if len(locations) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// First, fetch all file metadata we need for comparison in a single lock
|
||||
type fileMetadata struct {
|
||||
name string
|
||||
size int64
|
||||
}
|
||||
metadata := make([]fileMetadata, len(locations))
|
||||
|
||||
index.ReadOnlyOperation(func() {
|
||||
dirs := index.GetDirectories()
|
||||
for i, loc := range locations {
|
||||
if dir := dirs[loc.dirPath]; dir != nil {
|
||||
if loc.fileIdx < len(dir.Files) {
|
||||
file := &dir.Files[loc.fileIdx]
|
||||
// CRITICAL: Only include files that match the expected size
|
||||
// Index could have changed since locations were collected
|
||||
if file.Size == expectedSize {
|
||||
metadata[i] = fileMetadata{
|
||||
name: file.Name,
|
||||
size: file.Size,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Now group by fuzzy filename matching without holding the lock
|
||||
groups := [][]fileLocation{}
|
||||
used := make(map[int]bool)
|
||||
|
||||
for i := 0; i < len(locations); i++ {
|
||||
if used[i] || metadata[i].name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
group := []fileLocation{locations[i]}
|
||||
used[i] = true
|
||||
baseSize := metadata[i].size
|
||||
|
||||
baseName1 := metadata[i].name
|
||||
ext1 := strings.ToLower(filepath.Ext(baseName1))
|
||||
filename1 := normalizeFilename(baseName1)
|
||||
|
||||
for j := i + 1; j < len(locations); j++ {
|
||||
if used[j] || metadata[j].name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// CRITICAL: Ensure exact size match
|
||||
if metadata[j].size != baseSize {
|
||||
continue
|
||||
}
|
||||
|
||||
baseName2 := metadata[j].name
|
||||
ext2 := strings.ToLower(filepath.Ext(baseName2))
|
||||
|
||||
// CRITICAL: Extensions must match exactly (case-insensitive)
|
||||
if ext1 != ext2 {
|
||||
continue
|
||||
}
|
||||
|
||||
filename2 := normalizeFilename(baseName2)
|
||||
|
||||
// Check if filenames are similar enough
|
||||
if filenamesSimilar(filename1, filename2) {
|
||||
group = append(group, locations[j])
|
||||
used[j] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(group) >= 2 {
|
||||
groups = append(groups, group)
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// normalizeFilename converts filename to lowercase and removes special characters
|
||||
// to make fuzzy matching more effective
|
||||
func normalizeFilename(filename string) string {
|
||||
// Convert to lowercase
|
||||
filename = strings.ToLower(filename)
|
||||
|
||||
// Remove file extension for comparison
|
||||
if idx := strings.LastIndex(filename, "."); idx > 0 {
|
||||
filename = filename[:idx]
|
||||
}
|
||||
|
||||
// Remove non-alphanumeric characters except spaces and hyphens
|
||||
var result strings.Builder
|
||||
for _, r := range filename {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ' || r == '-' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
// filenamesSimilar checks if two normalized filenames are similar enough to be considered duplicates
|
||||
func filenamesSimilar(name1, name2 string) bool {
|
||||
// Exact match
|
||||
if name1 == name2 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Empty names don't match
|
||||
if len(name1) == 0 || len(name2) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Calculate Levenshtein distance
|
||||
distance := levenshteinDistance(name1, name2)
|
||||
maxLen := max(len(name1), len(name2))
|
||||
|
||||
// Require at least 70% similarity
|
||||
similarity := 1.0 - float64(distance)/float64(maxLen)
|
||||
return similarity >= 0.7
|
||||
}
|
||||
|
||||
// levenshteinDistance calculates the edit distance between two strings
|
||||
func levenshteinDistance(s1, s2 string) int {
|
||||
if len(s1) == 0 {
|
||||
return len(s2)
|
||||
}
|
||||
if len(s2) == 0 {
|
||||
return len(s1)
|
||||
}
|
||||
|
||||
// Create matrix
|
||||
matrix := make([][]int, len(s1)+1)
|
||||
for i := range matrix {
|
||||
matrix[i] = make([]int, len(s2)+1)
|
||||
}
|
||||
|
||||
// Initialize first row and column
|
||||
for i := 0; i <= len(s1); i++ {
|
||||
matrix[i][0] = i
|
||||
}
|
||||
for j := 0; j <= len(s2); j++ {
|
||||
matrix[0][j] = j
|
||||
}
|
||||
|
||||
// Fill matrix
|
||||
for i := 1; i <= len(s1); i++ {
|
||||
for j := 1; j <= len(s2); j++ {
|
||||
cost := 0
|
||||
if s1[i-1] != s2[j-1] {
|
||||
cost = 1
|
||||
}
|
||||
|
||||
matrix[i][j] = min(
|
||||
matrix[i-1][j]+1, // deletion
|
||||
matrix[i][j-1]+1, // insertion
|
||||
matrix[i-1][j-1]+cost, // substitution
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[len(s1)][len(s2)]
|
||||
}
|
||||
|
||||
// min returns the minimum of three integers
|
||||
func min(a, b, c int) int {
|
||||
if a < b {
|
||||
if a < c {
|
||||
return a
|
||||
}
|
||||
return c
|
||||
}
|
||||
if b < c {
|
||||
return b
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// max returns the maximum of two integers
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
*
|
||||
@@ -1,9 +1,12 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/events"
|
||||
@@ -26,7 +29,7 @@ func (msgr messenger) sendEvent(eventType, message string) error {
|
||||
}
|
||||
|
||||
func sseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !d.user.Permissions.Realtime {
|
||||
if !(d.user.Permissions.Realtime || d.user.Permissions.Admin) {
|
||||
return http.StatusForbidden, fmt.Errorf("realtime is disabled for this user")
|
||||
}
|
||||
|
||||
@@ -80,3 +83,93 @@ func sseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OnlyOfficeLogContext stores context for OnlyOffice operations
|
||||
type OnlyOfficeLogContext struct {
|
||||
Username string
|
||||
SessionID string
|
||||
DocumentID string
|
||||
FilePath string
|
||||
Source string
|
||||
ShareHash string
|
||||
isAdmin bool
|
||||
StartTime time.Time
|
||||
}
|
||||
|
||||
// OnlyOfficeLogEvent represents a log event for SSE
|
||||
type OnlyOfficeLogEvent struct {
|
||||
EventType string `json:"eventType"`
|
||||
DocumentID string `json:"documentId"`
|
||||
Username string `json:"username"`
|
||||
SessionID string `json:"sessionId"`
|
||||
LogLevel string `json:"logLevel"`
|
||||
Message string `json:"message"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Component string `json:"component"`
|
||||
}
|
||||
|
||||
// Global map to store OnlyOffice log contexts
|
||||
var onlyOfficeContexts = make(map[string]*OnlyOfficeLogContext)
|
||||
var onlyOfficeContextsMutex sync.RWMutex
|
||||
|
||||
// Helper functions for OnlyOffice log context management
|
||||
func createOnlyOfficeLogContext(username, sessionID, documentID, filePath, source, shareHash string, isAdmin bool) *OnlyOfficeLogContext {
|
||||
return &OnlyOfficeLogContext{
|
||||
Username: username,
|
||||
SessionID: sessionID,
|
||||
DocumentID: documentID,
|
||||
FilePath: filePath,
|
||||
Source: source,
|
||||
ShareHash: shareHash,
|
||||
StartTime: time.Now(),
|
||||
isAdmin: isAdmin,
|
||||
}
|
||||
}
|
||||
|
||||
func storeOnlyOfficeLogContext(documentID string, context *OnlyOfficeLogContext) {
|
||||
onlyOfficeContextsMutex.Lock()
|
||||
defer onlyOfficeContextsMutex.Unlock()
|
||||
onlyOfficeContexts[documentID] = context
|
||||
}
|
||||
|
||||
func getOnlyOfficeLogContext(documentID string) *OnlyOfficeLogContext {
|
||||
onlyOfficeContextsMutex.RLock()
|
||||
defer onlyOfficeContextsMutex.RUnlock()
|
||||
return onlyOfficeContexts[documentID]
|
||||
}
|
||||
|
||||
func removeOnlyOfficeLogContext(documentID string) {
|
||||
onlyOfficeContextsMutex.Lock()
|
||||
defer onlyOfficeContextsMutex.Unlock()
|
||||
delete(onlyOfficeContexts, documentID)
|
||||
}
|
||||
|
||||
func sendOnlyOfficeLogEvent(context *OnlyOfficeLogContext, level, component, message string) {
|
||||
if context == nil {
|
||||
return
|
||||
}
|
||||
if !context.isAdmin {
|
||||
return
|
||||
}
|
||||
|
||||
// Create the log event message
|
||||
logData := map[string]interface{}{
|
||||
"documentId": context.DocumentID,
|
||||
"username": context.Username,
|
||||
"sessionId": context.SessionID,
|
||||
"logLevel": level,
|
||||
"message": message,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"component": component,
|
||||
}
|
||||
|
||||
// Convert to JSON
|
||||
jsonData, err := json.Marshal(logData)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to marshal OnlyOffice log event: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send to specific user only (not broadcast to all users)
|
||||
events.SendToUsers("onlyOfficeLog", string(jsonData), []string{context.Username})
|
||||
}
|
||||
+32
-13
@@ -8,7 +8,6 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
@@ -45,14 +44,19 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
store = storage
|
||||
config = &settings.Config
|
||||
var err error
|
||||
// --- START: ADD THIS DECRYPTION LOGIC ---
|
||||
if settings.Config.Server.EmbeddedFs {
|
||||
// Determine filesystem mode and set asset paths
|
||||
if settings.Env.EmbeddedFs {
|
||||
// Embedded mode: Serve files from the embedded assets
|
||||
assetFs, err = fs.Sub(assets, "embed")
|
||||
if err != nil {
|
||||
logger.Fatal("Could not embed frontend. Does dist exist?")
|
||||
logger.Fatalf("fs.Sub failed: %v", err)
|
||||
}
|
||||
entries, err := fs.ReadDir(assetFs, ".")
|
||||
if err != nil || len(entries) == 0 {
|
||||
logger.Fatalf("Could not embed frontend. Does dist exist? %v", err)
|
||||
}
|
||||
} else {
|
||||
// Dev mode: Serve files from http/dist directory
|
||||
assetFs = dirFS{Dir: http.Dir("http/dist")}
|
||||
}
|
||||
|
||||
@@ -64,14 +68,12 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
return string(a), err
|
||||
},
|
||||
})
|
||||
|
||||
devMode := os.Getenv("FILEBROWSER_DEVMODE") == "true"
|
||||
if !devMode {
|
||||
if !settings.Env.IsDevMode {
|
||||
templates = template.Must(templates.ParseFS(assetFs, "public/index.html"))
|
||||
}
|
||||
templateRenderer = &TemplateRenderer{
|
||||
templates: templates,
|
||||
devMode: devMode,
|
||||
devMode: settings.Env.IsDevMode,
|
||||
}
|
||||
|
||||
router := http.NewServeMux()
|
||||
@@ -116,6 +118,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
// Access routes
|
||||
api.HandleFunc("GET /access", withAdmin(accessGetHandler))
|
||||
api.HandleFunc("POST /access", withAdmin(accessPostHandler))
|
||||
api.HandleFunc("PATCH /access", withAdmin(accessPatchHandler))
|
||||
api.HandleFunc("DELETE /access", withAdmin(accessDeleteHandler))
|
||||
api.HandleFunc("GET /access/groups", withAdmin(groupGetHandler))
|
||||
api.HandleFunc("POST /access/group", withAdmin(groupPostHandler))
|
||||
@@ -126,6 +129,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
api.HandleFunc("GET /share/direct", withPermShare(shareDirectDownloadHandler))
|
||||
api.HandleFunc("GET /share", withPermShare(shareGetHandler))
|
||||
api.HandleFunc("POST /share", withPermShare(sharePostHandler))
|
||||
api.HandleFunc("PATCH /share", withPermShare(sharePatchHandler))
|
||||
api.HandleFunc("DELETE /share", withPermShare(shareDeleteHandler))
|
||||
|
||||
// Create API sub-router for public API endpoints
|
||||
@@ -140,6 +144,10 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
publicAPI.HandleFunc("GET /onlyoffice/callback", withHashFile(onlyofficeCallbackHandler))
|
||||
publicAPI.HandleFunc("GET /onlyoffice/config", withHashFile(onlyofficeClientConfigGetHandler))
|
||||
publicAPI.HandleFunc("POST /resources", withHashFile(publicUploadHandler))
|
||||
publicAPI.HandleFunc("PUT /resources", withHashFile(publicPutHandler))
|
||||
publicAPI.HandleFunc("DELETE /resources", withHashFile(publicDeleteHandler))
|
||||
publicAPI.HandleFunc("PATCH /resources", withHashFile(publicPatchHandler))
|
||||
publicAPI.HandleFunc("GET /shareinfo", withOrWithoutUser(shareInfoHandler))
|
||||
|
||||
// Settings routes
|
||||
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
|
||||
@@ -158,6 +166,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
api.HandleFunc("GET /onlyoffice/callback", withUser(onlyofficeCallbackHandler))
|
||||
|
||||
api.HandleFunc("GET /search", withUser(searchHandler))
|
||||
api.HandleFunc("GET /duplicates", withUser(duplicatesHandler))
|
||||
// Mount the public API sub-router
|
||||
publicRoutes.Handle("/api/", http.StripPrefix("/api", publicAPI))
|
||||
|
||||
@@ -168,6 +177,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
router.Handle(publicPath+"/", http.StripPrefix(publicPath, publicRoutes))
|
||||
|
||||
// Frontend share route redirect (DEPRECATED - maintain for backwards compatibility)
|
||||
// Playwright tests need updating to remove this redirect.
|
||||
router.HandleFunc(fmt.Sprintf("GET %vshare/", config.Server.BaseURL), withOrWithoutUser(redirectToShare))
|
||||
|
||||
// New frontend share route handler - handle share page and any subpaths
|
||||
@@ -175,8 +185,13 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
|
||||
// Static and index file handlers
|
||||
staticPrefix := config.Server.BaseURL + "static/"
|
||||
router.Handle(staticPrefix, http.StripPrefix(staticPrefix, http.HandlerFunc(staticFilesHandler)))
|
||||
publicRoutes.Handle("GET /static/", http.StripPrefix("/static/", http.HandlerFunc(staticFilesHandler)))
|
||||
router.Handle(staticPrefix, http.HandlerFunc(staticAssetHandler))
|
||||
publicRoutes.Handle("GET /static/", http.HandlerFunc(staticAssetHandler))
|
||||
|
||||
// Standard browser favicon and manifest routes
|
||||
router.HandleFunc("GET /favicon.ico", http.HandlerFunc(staticAssetHandler))
|
||||
router.HandleFunc("GET /site.webmanifest", http.HandlerFunc(staticAssetHandler))
|
||||
router.HandleFunc("GET /manifest.json", http.HandlerFunc(staticAssetHandler))
|
||||
|
||||
router.HandleFunc(config.Server.BaseURL, withOrWithoutUser(indexHandler))
|
||||
router.HandleFunc(fmt.Sprintf("GET %vhealth", config.Server.BaseURL), healthHandler)
|
||||
@@ -192,9 +207,13 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
var scheme string
|
||||
port := ""
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%v", config.Server.Port),
|
||||
Addr: fmt.Sprintf("%v:%v", config.Server.ListenAddress, config.Server.Port),
|
||||
Handler: muxWithMiddleware(router),
|
||||
}
|
||||
listenAddress := config.Server.ListenAddress
|
||||
if listenAddress == "0.0.0.0" {
|
||||
listenAddress = "localhost"
|
||||
}
|
||||
go func() {
|
||||
// Determine whether to use HTTPS (TLS) or HTTP
|
||||
if config.Server.TLSCert != "" && config.Server.TLSKey != "" {
|
||||
@@ -217,7 +236,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
}
|
||||
|
||||
// Build the full URL with host and port
|
||||
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
|
||||
fullURL := fmt.Sprintf("%s://%s%s%s", scheme, listenAddress, port, config.Server.BaseURL)
|
||||
logger.Infof("Running at : %s", fullURL)
|
||||
|
||||
// Create a TLS listener and serve
|
||||
@@ -236,7 +255,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
}
|
||||
|
||||
// Build the full URL with host and port
|
||||
fullURL := fmt.Sprintf("%s://localhost%s%s", scheme, port, config.Server.BaseURL)
|
||||
fullURL := fmt.Sprintf("%s://%s%s%s", scheme, listenAddress, port, config.Server.BaseURL)
|
||||
logger.Infof("Running at : %s", fullURL)
|
||||
|
||||
// Start HTTP server
|
||||
|
||||
+40
-34
@@ -103,7 +103,6 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
file, err := FileInfoFasterFunc(utils.FileOptions{
|
||||
Path: utils.JoinPathAsUnix(link.Path, path),
|
||||
Source: link.Source,
|
||||
Modify: false,
|
||||
Expand: true,
|
||||
Content: getContent,
|
||||
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
|
||||
@@ -115,7 +114,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
file.Token = link.Token
|
||||
file.Source = ""
|
||||
file.Hash = link.Hash
|
||||
if !link.EnableOnlyOffice || !link.DisableFileViewer || reachedDownloadsLimit {
|
||||
if !link.EnableOnlyOffice || link.DisableFileViewer || reachedDownloadsLimit {
|
||||
file.OnlyOfficeId = ""
|
||||
}
|
||||
if getContent && file.Content != "" {
|
||||
@@ -127,7 +126,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
|
||||
link.IncrementUserDownload(data.user.Username)
|
||||
}
|
||||
}
|
||||
file.Path = "/" + strings.TrimPrefix(strings.TrimPrefix(file.Path, link.Path), "/")
|
||||
file.Path = utils.AddTrailingSlashIfNotExists(path)
|
||||
// Set the file info in the `data` object
|
||||
data.fileInfo = *file
|
||||
// Call the next handler with the data
|
||||
@@ -158,15 +157,6 @@ func extractUserFromExpiredToken(r *http.Request, data *requestContext) *users.U
|
||||
return user
|
||||
}
|
||||
|
||||
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
|
||||
if config.Auth.Methods.ProxyAuth.Enabled && proxyUser != "" {
|
||||
user, err := setupProxyUser(r, data, proxyUser)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
keyFunc := func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(config.Auth.Key), nil
|
||||
}
|
||||
@@ -305,14 +295,7 @@ func userWithoutOTPhelper(fn handleFunc) handleFunc {
|
||||
// Call the actual handler function with the updated context
|
||||
username := r.URL.Query().Get("username")
|
||||
password := r.Header.Get("X-Password")
|
||||
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
|
||||
if config.Auth.Methods.ProxyAuth.Enabled && proxyUser != "" {
|
||||
user, err := setupProxyUser(r, &requestContext{}, proxyUser)
|
||||
if err != nil {
|
||||
return 401, errors.ErrUnauthorized
|
||||
}
|
||||
d.user = user
|
||||
} else if username == "" || password == "" {
|
||||
if username == "" || password == "" {
|
||||
return withUserHelper(fn)(w, r, d)
|
||||
} else {
|
||||
if !config.Auth.Methods.PasswordAuth.Enabled {
|
||||
@@ -354,39 +337,34 @@ func withUserHelper(fn handleFunc) handleFunc {
|
||||
return fn(w, r, data)
|
||||
}
|
||||
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
|
||||
if config.Auth.Methods.ProxyAuth.Enabled && proxyUser != "" {
|
||||
user, err := setupProxyUser(r, data, proxyUser)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
data.user = user
|
||||
setUserInResponseWriter(w, data.user)
|
||||
if fn == nil {
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
return fn(w, r, data)
|
||||
}
|
||||
isProxyUser := config.Auth.Methods.ProxyAuth.Enabled && proxyUser != ""
|
||||
keyFunc := func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(config.Auth.Key), nil
|
||||
}
|
||||
tokenString, err := extractToken(r)
|
||||
if err != nil {
|
||||
if err != nil && !isProxyUser {
|
||||
return http.StatusUnauthorized, err
|
||||
}
|
||||
data.token = tokenString
|
||||
var tk users.AuthToken
|
||||
token, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc)
|
||||
if err != nil {
|
||||
if isProxyUser {
|
||||
return getProxyUser(w, r, data, fn, proxyUser)
|
||||
}
|
||||
return http.StatusUnauthorized, fmt.Errorf("error processing token, %v", err)
|
||||
}
|
||||
if !token.Valid {
|
||||
return http.StatusUnauthorized, fmt.Errorf("invalid token")
|
||||
}
|
||||
if auth.IsRevokedApiKey(data.token) || tk.Expires < time.Now().Unix() {
|
||||
if isProxyUser {
|
||||
return getProxyUser(w, r, data, fn, proxyUser)
|
||||
}
|
||||
return http.StatusUnauthorized, fmt.Errorf("token expired or revoked")
|
||||
}
|
||||
// Check if the token is about to expire and send a header to renew it
|
||||
if tk.Expires < time.Now().Add(time.Hour).Unix() {
|
||||
if tk.Expires < time.Now().Add(time.Minute*30).Unix() {
|
||||
w.Header().Add("X-Renew-Token", "true")
|
||||
} // Retrieve the user from the store and store it in the context
|
||||
data.user, err = store.Users.Get(tk.BelongsTo)
|
||||
@@ -406,6 +384,34 @@ func withUserHelper(fn handleFunc) handleFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func getProxyUser(w http.ResponseWriter, r *http.Request, data *requestContext, fn handleFunc, proxyUser string) (int, error) {
|
||||
// proxy user logic
|
||||
user, err := setupProxyUser(r, data, proxyUser)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
data.user = user
|
||||
setUserInResponseWriter(w, data.user)
|
||||
if data.user.Username == "" {
|
||||
return http.StatusForbidden, errors.ErrUnauthorized
|
||||
}
|
||||
// Generate a token for proxy users if they don't have one
|
||||
if data.token == "" {
|
||||
expires := time.Hour * time.Duration(config.Auth.TokenExpirationHours)
|
||||
signed, err := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to generate token for proxy user %s: %v", proxyUser, err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to generate token")
|
||||
}
|
||||
data.token = signed.Key
|
||||
}
|
||||
// Call the handler function, passing in the context (or return OK if no handler)
|
||||
if fn == nil {
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
return fn(w, r, data)
|
||||
}
|
||||
|
||||
// Middleware to ensure the user is either the requested user or an admin
|
||||
func withSelfOrAdminHelper(fn handleFunc) handleFunc {
|
||||
return withUserHelper(func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
|
||||
|
||||
@@ -117,7 +117,7 @@ func TestWithAdminHelper(t *testing.T) {
|
||||
t.Fatalf("Error creating request: %v", err)
|
||||
}
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: "auth",
|
||||
Name: "filebrowser_quantum_jwt",
|
||||
Value: token.Key,
|
||||
})
|
||||
|
||||
|
||||
+64
-83
@@ -20,18 +20,14 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// userInfo struct to hold user claims from either UserInfo or ID token
|
||||
// userInfo holds all claims dynamically, plus pre-parsed Groups.
|
||||
type userInfo struct {
|
||||
Name string `json:"name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Sub string `json:"sub"`
|
||||
Phone string `json:"phone_number"`
|
||||
Groups []string `json:"-"` // Handled manually by userInfoUnmarshaller
|
||||
Claims map[string]interface{}
|
||||
Groups []string
|
||||
}
|
||||
|
||||
// userInfoUnmarshaller is a custom unmarshaller that handles configurable groups claim
|
||||
// userInfoUnmarshaller handles unmarshalling all claims dynamically,
|
||||
// while optionally parsing a configurable groups claim.
|
||||
type userInfoUnmarshaller struct {
|
||||
userInfo *userInfo
|
||||
groupsClaim string
|
||||
@@ -39,41 +35,36 @@ type userInfoUnmarshaller struct {
|
||||
|
||||
// UnmarshalJSON implements the json.Unmarshaler interface
|
||||
func (u *userInfoUnmarshaller) UnmarshalJSON(data []byte) error {
|
||||
// First, unmarshal the basic userInfo fields
|
||||
if err := json.Unmarshal(data, u.userInfo); err != nil {
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Parse the JSON to access the groups claim field
|
||||
var rawData map[string]interface{}
|
||||
if err := json.Unmarshal(data, &rawData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Look for the groups claim using the configured field name
|
||||
if groupsValue, exists := rawData[u.groupsClaim]; exists {
|
||||
switch v := groupsValue.(type) {
|
||||
case []interface{}:
|
||||
// It's already an array, convert to []string
|
||||
groups := make([]string, len(v))
|
||||
for i, group := range v {
|
||||
if str, ok := group.(string); ok {
|
||||
groups[i] = str
|
||||
// Extract groups if configured
|
||||
if u.groupsClaim != "" {
|
||||
if v, ok := raw[u.groupsClaim]; ok {
|
||||
switch val := v.(type) {
|
||||
case []interface{}:
|
||||
groups := make([]string, len(val))
|
||||
for i, g := range val {
|
||||
if s, ok := g.(string); ok {
|
||||
groups[i] = strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
u.userInfo.Groups = groups
|
||||
case string:
|
||||
// It's a string, split by commas
|
||||
if v != "" {
|
||||
u.userInfo.Groups = strings.Split(v, ",")
|
||||
// Trim whitespace from each group
|
||||
for i, group := range u.userInfo.Groups {
|
||||
u.userInfo.Groups[i] = strings.TrimSpace(group)
|
||||
u.userInfo.Groups = groups
|
||||
case string:
|
||||
if val != "" {
|
||||
parts := strings.Split(val, ",")
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
u.userInfo.Groups = parts
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
u.userInfo.Claims = raw
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -100,7 +91,7 @@ func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
ClientSecret: oidcCfg.ClientSecret,
|
||||
Endpoint: oidcCfg.Provider.Endpoint(),
|
||||
RedirectURL: fmt.Sprintf("%s%sapi/auth/oidc/callback", origin, config.Server.BaseURL),
|
||||
Scopes: strings.Split(oidcCfg.Scopes, " "),
|
||||
Scopes: strings.Fields(oidcCfg.Scopes),
|
||||
}
|
||||
|
||||
nonce := utils.InsecureRandomIdentifier(16)
|
||||
@@ -157,7 +148,7 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
ClientSecret: oidcCfg.ClientSecret,
|
||||
Endpoint: oidcCfg.Provider.Endpoint(), // Use endpoint from discovered provider
|
||||
RedirectURL: redirectURL, // Use the dynamically determined redirect URL
|
||||
Scopes: strings.Split(oidcCfg.Scopes, " "),
|
||||
Scopes: strings.Fields(oidcCfg.Scopes),
|
||||
}
|
||||
|
||||
// Exchange the authorization code for tokens
|
||||
@@ -186,10 +177,10 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
|
||||
// Verify the ID token
|
||||
// This uses the verifier initialized with the provider's JWKS endpoint and client ID
|
||||
idToken, err := oidcCfg.Verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
idToken, verify_err := oidcCfg.Verifier.Verify(ctx, rawIDToken)
|
||||
if verify_err != nil {
|
||||
// this might not be necessary for certain providers like authentik
|
||||
logger.Debugf("failed to verify ID token: %v. This might be expected, falling back to UserInfo endpoint.", err)
|
||||
logger.Debugf("failed to verify ID token: %v. This might be expected, falling back to UserInfo endpoint.", verify_err)
|
||||
// Verification failed, claimsFromIDToken remains false
|
||||
} else {
|
||||
// Decode the ID token claims using custom unmarshaller
|
||||
@@ -203,30 +194,13 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
|
||||
// Decide if we rely on ID token claims or still need UserInfo
|
||||
// Even if parsing succeeded, if essential claims are missing, use UserInfo
|
||||
switch oidcCfg.UserIdentifier {
|
||||
case "email":
|
||||
if userdata.Email != "" {
|
||||
claimsFromIDToken = true
|
||||
loginUsername = userdata.Email
|
||||
}
|
||||
case "username":
|
||||
if userdata.Username != "" {
|
||||
claimsFromIDToken = true
|
||||
loginUsername = userdata.Username
|
||||
}
|
||||
case "preferred_username":
|
||||
if userdata.PreferredUsername != "" {
|
||||
claimsFromIDToken = true
|
||||
loginUsername = userdata.PreferredUsername
|
||||
}
|
||||
case "phone":
|
||||
if userdata.Phone != "" {
|
||||
claimsFromIDToken = true
|
||||
loginUsername = userdata.Phone
|
||||
}
|
||||
if _, ok := userdata.Claims[oidcCfg.UserIdentifier]; ok {
|
||||
claimsFromIDToken = true
|
||||
}
|
||||
}
|
||||
logger.Debugf("Failed to verify ID token: %v", verify_err)
|
||||
}
|
||||
|
||||
} else {
|
||||
logger.Debug("No ID token found in token response or it was empty. Falling back to UserInfo endpoint.")
|
||||
// claimsFromIDToken remains false
|
||||
@@ -247,22 +221,17 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
|
||||
logger.Errorf("failed to decode user info from endpoint: %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to decode user info from endpoint: %v", err)
|
||||
}
|
||||
// Decide if we rely on ID token claims or still need UserInfo
|
||||
// Even if parsing succeeded, if essential claims are missing, use UserInfo
|
||||
switch oidcCfg.UserIdentifier {
|
||||
case "email":
|
||||
loginUsername = userdata.Email
|
||||
case "username":
|
||||
loginUsername = userdata.Username
|
||||
case "preferred_username":
|
||||
loginUsername = userdata.PreferredUsername
|
||||
case "phone":
|
||||
loginUsername = userdata.Phone
|
||||
}
|
||||
|
||||
// --- Determine login username dynamically ---
|
||||
if val, ok := userdata.Claims[oidcCfg.UserIdentifier]; ok {
|
||||
if v, ok := val.(string); ok {
|
||||
loginUsername = v
|
||||
}
|
||||
}
|
||||
if loginUsername == "" {
|
||||
logger.Errorf("No valid username found for identifier '%v' in ID token or UserInfo response.", oidcCfg.UserIdentifier)
|
||||
return http.StatusInternalServerError, fmt.Errorf("no valid username found in ID token or UserInfo response from claims")
|
||||
logger.Errorf("No valid username found for identifier '%v' in claims.", oidcCfg.UserIdentifier)
|
||||
return http.StatusInternalServerError, fmt.Errorf("no valid username found for identifier '%v'", oidcCfg.UserIdentifier)
|
||||
}
|
||||
|
||||
// Proceed to log the user in with the OIDC data
|
||||
@@ -300,7 +269,7 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
|
||||
if isAdmin {
|
||||
user.Permissions.Admin = true
|
||||
}
|
||||
err = storage.CreateUser(*user)
|
||||
err = storage.CreateUser(*user, user.Permissions)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
@@ -329,8 +298,10 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
|
||||
if err = store.Access.SyncUserGroups(username, groups); err != nil {
|
||||
logger.Warningf("failed to sync oidc user %s groups: %v", username, err)
|
||||
}
|
||||
|
||||
expires := time.Hour * time.Duration(config.Auth.TokenExpirationHours)
|
||||
// Generate a signed token for the user
|
||||
signed, err2 := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*time.Duration(config.Auth.TokenExpirationHours), user.Permissions)
|
||||
signed, err2 := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions)
|
||||
if err2 != nil {
|
||||
// Handle potential errors during token generation
|
||||
if strings.Contains(err2.Error(), "key already exists with same name") {
|
||||
@@ -339,15 +310,25 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
|
||||
return http.StatusInternalServerError, fmt.Errorf("failed to generate authentication token for user %s: %v", username, err)
|
||||
}
|
||||
|
||||
// Add 30 minutes buffer so expired token doesn't get automatically deleted by the browser
|
||||
// This allows backend to identify expired sessions and provide better user feedback
|
||||
expiresTime := time.Now().Add(expires).Add(time.Minute * 30)
|
||||
|
||||
// Set the authentication token as an HTTP cookie
|
||||
// Get the correct domain for cookie - prefer X-Forwarded-Host from reverse proxy
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
cookie := &http.Cookie{
|
||||
Name: "auth", // The name of your auth cookie
|
||||
Value: signed.Key, // The generated token value
|
||||
Domain: strings.Split(r.Host, ":")[0], // Set domain to the host without port
|
||||
Path: "/", // Make the cookie available to the whole site
|
||||
// Secure: true, // Recommended: Set to true in production with HTTPS
|
||||
// HttpOnly: true, // Recommended: Set to true to prevent client-side script access
|
||||
Expires: time.Now().Add(time.Hour * time.Duration(config.Auth.TokenExpirationHours)), // Set cookie expiration
|
||||
Name: "filebrowser_quantum_jwt",
|
||||
Value: signed.Key,
|
||||
Domain: strings.Split(host, ":")[0], // Set domain to the host without port
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteLaxMode, // Lax mode allows cookie on navigation from OIDC provider
|
||||
Expires: expiresTime,
|
||||
// HttpOnly: true, // Cannot use HttpOnly since frontend needs to read cookie for renew operations
|
||||
// Secure: true, // Enable this in production with HTTPS
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
|
||||
|
||||
+24
-10
@@ -18,8 +18,11 @@ func TestUserInfoUnmarshaller(t *testing.T) {
|
||||
jsonData: `{"name":"John","email":"john@example.com","groups":["admin","users"]}`,
|
||||
groupsClaim: "groups",
|
||||
expected: userInfo{
|
||||
Name: "John",
|
||||
Email: "john@example.com",
|
||||
Claims: map[string]interface{}{
|
||||
"name": "John",
|
||||
"email": "john@example.com",
|
||||
"groups": []interface{}{"admin","users"},
|
||||
},
|
||||
Groups: []string{"admin", "users"},
|
||||
},
|
||||
},
|
||||
@@ -28,8 +31,11 @@ func TestUserInfoUnmarshaller(t *testing.T) {
|
||||
jsonData: `{"name":"Jane","email":"jane@example.com","roles":["admin","users"]}`,
|
||||
groupsClaim: "roles",
|
||||
expected: userInfo{
|
||||
Name: "Jane",
|
||||
Email: "jane@example.com",
|
||||
Claims: map[string]interface{}{
|
||||
"name": "Jane",
|
||||
"email": "jane@example.com",
|
||||
"roles": []interface{}{"admin","users"},
|
||||
},
|
||||
Groups: []string{"admin", "users"},
|
||||
},
|
||||
},
|
||||
@@ -38,8 +44,11 @@ func TestUserInfoUnmarshaller(t *testing.T) {
|
||||
jsonData: `{"name":"Bob","email":"bob@example.com","groups":"admin, users, guests"}`,
|
||||
groupsClaim: "groups",
|
||||
expected: userInfo{
|
||||
Name: "Bob",
|
||||
Email: "bob@example.com",
|
||||
Claims: map[string]interface{}{
|
||||
"name": "Bob",
|
||||
"email": "bob@example.com",
|
||||
"groups": "admin, users, guests",
|
||||
},
|
||||
Groups: []string{"admin", "users", "guests"},
|
||||
},
|
||||
},
|
||||
@@ -48,8 +57,10 @@ func TestUserInfoUnmarshaller(t *testing.T) {
|
||||
jsonData: `{"name":"Alice","email":"alice@example.com"}`,
|
||||
groupsClaim: "groups",
|
||||
expected: userInfo{
|
||||
Name: "Alice",
|
||||
Email: "alice@example.com",
|
||||
Claims: map[string]interface{}{
|
||||
"name": "Alice",
|
||||
"email": "alice@example.com",
|
||||
},
|
||||
Groups: nil,
|
||||
},
|
||||
},
|
||||
@@ -58,8 +69,11 @@ func TestUserInfoUnmarshaller(t *testing.T) {
|
||||
jsonData: `{"name":"Charlie","email":"charlie@example.com","groups":[]}`,
|
||||
groupsClaim: "groups",
|
||||
expected: userInfo{
|
||||
Name: "Charlie",
|
||||
Email: "charlie@example.com",
|
||||
Claims: map[string]interface{}{
|
||||
"name": "Charlie",
|
||||
"email": "charlie@example.com",
|
||||
"groups": []interface{}{},
|
||||
},
|
||||
Groups: []string{},
|
||||
},
|
||||
},
|
||||
|
||||
+336
-86
@@ -22,18 +22,36 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
onlyOfficeStatusDocumentBeingEdited = 1
|
||||
onlyOfficeStatusDocumentClosedWithChanges = 2
|
||||
onlyOfficeStatusDocumentSavingError = 3
|
||||
onlyOfficeStatusDocumentClosedWithNoChanges = 4
|
||||
onlyOfficeStatusForceSaveWhileDocumentStillOpen = 6
|
||||
onlyOfficeStatusForceSaveError = 7
|
||||
)
|
||||
|
||||
type OnlyOfficeCallback struct {
|
||||
ChangesURL string `json:"changesurl,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Status int `json:"status,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Users []string `json:"users,omitempty"`
|
||||
UserData string `json:"userdata,omitempty"`
|
||||
Actions []OnlyOfficeAction `json:"actions,omitempty"`
|
||||
ChangesURL string `json:"changesurl,omitempty"`
|
||||
FileType string `json:"filetype,omitempty"`
|
||||
ForceSaveType int `json:"forcesavetype,omitempty"`
|
||||
FormsDataURL string `json:"formsdataurl,omitempty"`
|
||||
History *OnlyOfficeHistory `json:"history,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Status int `json:"status,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
UserData string `json:"userdata,omitempty"`
|
||||
Users []string `json:"users,omitempty"`
|
||||
}
|
||||
|
||||
type OnlyOfficeAction struct {
|
||||
Type int `json:"type"`
|
||||
UserID string `json:"userid"`
|
||||
}
|
||||
|
||||
type OnlyOfficeHistory struct {
|
||||
Changes interface{} `json:"changes"`
|
||||
ServerVersion string `json:"serverVersion"`
|
||||
}
|
||||
|
||||
// OnlyOfficeJWTPayload represents the JWT payload structure for OnlyOffice callbacks
|
||||
@@ -54,18 +72,19 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
|
||||
// Extract clean parameters from request
|
||||
source := r.URL.Query().Get("source")
|
||||
path := r.URL.Query().Get("path")
|
||||
providedPath := r.URL.Query().Get("path")
|
||||
hash := r.URL.Query().Get("hash")
|
||||
|
||||
// Validate required parameters
|
||||
if (path == "" || source == "") && d.fileInfo.Hash == "" {
|
||||
logger.Errorf("OnlyOffice callback missing required parameters: source=%s, path=%s", source, path)
|
||||
if (providedPath == "" || source == "") && hash == "" {
|
||||
logger.Errorf("OnlyOffice callback missing required parameters: source=%s, path=%s", source, providedPath)
|
||||
return http.StatusBadRequest, errors.New("missing required parameters: path + source/hash are required")
|
||||
}
|
||||
themeMode := utils.Ternary(d.user.DarkMode, "dark", "light")
|
||||
var sourceInfo *settings.Source
|
||||
var ok bool
|
||||
if d.fileInfo.Hash != "" {
|
||||
sourceInfo, ok = settings.Config.Server.SourceMap[source]
|
||||
if hash != "" {
|
||||
sourceInfo, ok = settings.Config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
logger.Error("OnlyOffice: source not found")
|
||||
return http.StatusInternalServerError, fmt.Errorf("source not found")
|
||||
@@ -77,7 +96,9 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
return http.StatusInternalServerError, fmt.Errorf("source not found")
|
||||
}
|
||||
}
|
||||
if d.fileInfo.Hash == "" {
|
||||
source = sourceInfo.Name
|
||||
path := providedPath
|
||||
if hash == "" {
|
||||
// Build file info based on whether this is a share or regular request
|
||||
// Regular user request - need to resolve scope
|
||||
userScope, scopeErr := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
@@ -85,23 +106,22 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
logger.Errorf("OnlyOffice: source %s not available for user %s: %v", source, d.user.Username, scopeErr)
|
||||
return http.StatusForbidden, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
indexPath := utils.JoinPathAsUnix(userScope, path)
|
||||
logger.Debugf("OnlyOffice user request: resolved path=%s", indexPath)
|
||||
path = utils.JoinPathAsUnix(userScope, path)
|
||||
logger.Debugf("OnlyOffice user request: resolved path=%s", path)
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Path: indexPath,
|
||||
Modify: d.user.Permissions.Modify,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
Username: d.user.Username,
|
||||
Path: path,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
}, store.Access)
|
||||
if err != nil {
|
||||
logger.Errorf("OnlyOffice: failed to get file info for source=%s, path=%s: %v", source, indexPath, err)
|
||||
logger.Errorf("OnlyOffice: failed to get file info for source=%s, path=%s: %v", source, path, err)
|
||||
return errToStatus(err), err
|
||||
}
|
||||
d.fileInfo = *fileInfo
|
||||
} else {
|
||||
source = sourceInfo.Name
|
||||
// path is index path, so we build from share path
|
||||
path = utils.JoinPathAsUnix(d.share.Path, path)
|
||||
path = utils.JoinPathAsUnix(d.share.Path, providedPath)
|
||||
if d.share.EnforceDarkLightMode == "dark" {
|
||||
themeMode = "dark"
|
||||
}
|
||||
@@ -113,23 +133,19 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
|
||||
// Determine file type and editing permissions
|
||||
fileType := strings.TrimPrefix(filepath.Ext(d.fileInfo.Name), ".")
|
||||
canEdit := iteminfo.CanEditOnlyOffice(d.user.Permissions.Modify, fileType)
|
||||
canEditMode := utils.Ternary(canEdit, "edit", "view")
|
||||
if d.fileInfo.Hash != "" {
|
||||
if d.share.EnableOnlyOfficeEditing {
|
||||
canEditMode = "edit"
|
||||
}
|
||||
}
|
||||
// For shares, we need to keep track of the original relative path for the callback URL
|
||||
var callbackPath string
|
||||
if d.fileInfo.Hash != "" {
|
||||
// For shares, use the original path parameter (relative to share)
|
||||
callbackPath = r.URL.Query().Get("path")
|
||||
|
||||
// Determine modify permissions based on whether this is a share or regular request
|
||||
var modifyPerms bool
|
||||
if d.fileInfo.Hash != "" && d.share != nil {
|
||||
// Share request - check share permissions
|
||||
modifyPerms = d.share.AllowModify
|
||||
} else {
|
||||
// For regular requests, use the processed path
|
||||
callbackPath = path
|
||||
// Regular user request - check user permissions
|
||||
modifyPerms = d.user.Permissions.Modify
|
||||
}
|
||||
|
||||
canEdit := iteminfo.CanEditOnlyOffice(modifyPerms, fileType)
|
||||
canEditMode := utils.Ternary(canEdit, "edit", "view")
|
||||
// Generate document ID for OnlyOffice
|
||||
documentId, err := getOnlyOfficeId(d.fileInfo.RealPath)
|
||||
if err != nil {
|
||||
@@ -137,11 +153,36 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
return http.StatusNotFound, fmt.Errorf("failed to generate document ID: %v", err)
|
||||
}
|
||||
|
||||
// Create and store log context for this OnlyOffice session
|
||||
sessionID := r.URL.Query().Get("sessionId")
|
||||
if sessionID == "" {
|
||||
sessionID = "unknown"
|
||||
}
|
||||
|
||||
shareHash := ""
|
||||
if d.fileInfo.Hash != "" {
|
||||
shareHash = d.fileInfo.Hash
|
||||
}
|
||||
|
||||
logContext := createOnlyOfficeLogContext(
|
||||
d.user.Username,
|
||||
sessionID,
|
||||
documentId,
|
||||
path,
|
||||
source,
|
||||
shareHash,
|
||||
d.user.Permissions.Admin,
|
||||
)
|
||||
storeOnlyOfficeLogContext(documentId, logContext)
|
||||
|
||||
// Send initial log event with detailed path information
|
||||
sendOnlyOfficeLogEvent(logContext, "INFO", "config", fmt.Sprintf("OnlyOffice session started for document: %s ", path))
|
||||
|
||||
// Build download URL that OnlyOffice server will use
|
||||
downloadURL := buildOnlyOfficeDownloadURL(source, callbackPath, d.fileInfo.Hash, d.token)
|
||||
downloadURL := buildOnlyOfficeDownloadURL(r, source, providedPath, d.fileInfo.Hash, d.token)
|
||||
|
||||
// Build callback URL for OnlyOffice to notify us of changes
|
||||
callbackURL := buildOnlyOfficeCallbackURL(source, callbackPath, d.fileInfo.Hash, d.token)
|
||||
callbackURL := buildOnlyOfficeCallbackURL(r, source, providedPath, d.fileInfo.Hash, d.token)
|
||||
|
||||
// Build OnlyOffice client configuration
|
||||
clientConfig := map[string]interface{}{
|
||||
@@ -187,14 +228,37 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
|
||||
}
|
||||
|
||||
// buildOnlyOfficeDownloadURL constructs the download URL that OnlyOffice server will use to fetch the file
|
||||
func buildOnlyOfficeDownloadURL(source, path, hash, token string) string {
|
||||
func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token string) string {
|
||||
// Determine base URL (internal URL takes priority for OnlyOffice server communication)
|
||||
baseURL := settings.Config.Server.BaseURL
|
||||
var baseURL string
|
||||
if settings.Config.Server.InternalUrl != "" {
|
||||
// Ensure proper URL joining without double slashes
|
||||
// InternalUrl is a full URL (e.g., http://localhost:8080), so use it directly
|
||||
internalURL := strings.TrimSuffix(settings.Config.Server.InternalUrl, "/")
|
||||
baseURLPath := strings.TrimPrefix(settings.Config.Server.BaseURL, "/")
|
||||
baseURL = internalURL + "/" + baseURLPath
|
||||
if baseURLPath != "" {
|
||||
baseURL = internalURL + "/" + baseURLPath
|
||||
} else {
|
||||
baseURL = internalURL
|
||||
}
|
||||
} else {
|
||||
// Extract scheme and host from request (respecting X-Forwarded-* headers)
|
||||
var host string
|
||||
var scheme string
|
||||
|
||||
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
|
||||
host = forwardedHost
|
||||
// Use X-Forwarded-Proto if available, otherwise default to https for proxied requests
|
||||
if forwardedProto := r.Header.Get("X-Forwarded-Proto"); forwardedProto != "" {
|
||||
scheme = forwardedProto
|
||||
} else {
|
||||
scheme = "https"
|
||||
}
|
||||
} else {
|
||||
// Fallback to simple approach
|
||||
host = r.Host
|
||||
scheme = getScheme(r)
|
||||
}
|
||||
baseURL = fmt.Sprintf("%s://%s%s", scheme, host, settings.Config.Server.BaseURL)
|
||||
}
|
||||
|
||||
var downloadURL string
|
||||
@@ -214,13 +278,37 @@ func buildOnlyOfficeDownloadURL(source, path, hash, token string) string {
|
||||
}
|
||||
|
||||
// buildOnlyOfficeCallbackURL constructs the callback URL that OnlyOffice server will use to notify us of changes
|
||||
func buildOnlyOfficeCallbackURL(source, path, hash, token string) string {
|
||||
baseURL := settings.Config.Server.BaseURL
|
||||
func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token string) string {
|
||||
// Determine base URL (internal URL takes priority for OnlyOffice server communication)
|
||||
var baseURL string
|
||||
if settings.Config.Server.InternalUrl != "" {
|
||||
// Ensure proper URL joining without double slashes
|
||||
// InternalUrl is a full URL (e.g., http://localhost:8080), so use it directly
|
||||
internalURL := strings.TrimSuffix(settings.Config.Server.InternalUrl, "/")
|
||||
baseURLPath := strings.TrimPrefix(settings.Config.Server.BaseURL, "/")
|
||||
baseURL = internalURL + "/" + baseURLPath
|
||||
if baseURLPath != "" {
|
||||
baseURL = internalURL + "/" + baseURLPath
|
||||
} else {
|
||||
baseURL = internalURL
|
||||
}
|
||||
} else {
|
||||
// Extract scheme and host from request (respecting X-Forwarded-* headers)
|
||||
var host string
|
||||
var scheme string
|
||||
|
||||
if forwardedHost := r.Header.Get("X-Forwarded-Host"); forwardedHost != "" {
|
||||
host = forwardedHost
|
||||
// Use X-Forwarded-Proto if available, otherwise default to https for proxied requests
|
||||
if forwardedProto := r.Header.Get("X-Forwarded-Proto"); forwardedProto != "" {
|
||||
scheme = forwardedProto
|
||||
} else {
|
||||
scheme = "https"
|
||||
}
|
||||
} else {
|
||||
// Fallback to simple approach
|
||||
host = r.Host
|
||||
scheme = getScheme(r)
|
||||
}
|
||||
baseURL = fmt.Sprintf("%s://%s%s", scheme, host, settings.Config.Server.BaseURL)
|
||||
}
|
||||
|
||||
var callbackURL string
|
||||
@@ -256,21 +344,21 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
// Validate required parameters
|
||||
if (path == "" || source == "") && d.fileInfo.Hash == "" {
|
||||
logger.Errorf("OnlyOffice callback missing required parameters: source=%s, path=%s", source, path)
|
||||
return http.StatusBadRequest, errors.New("missing required parameters: path + source/hash are required")
|
||||
return returnOnlyOfficeError(w, r, 400, "missing required parameters: path + source/hash are required")
|
||||
}
|
||||
var sourceInfo *settings.Source
|
||||
var ok bool
|
||||
if d.fileInfo.Hash != "" {
|
||||
sourceInfo, ok = settings.Config.Server.SourceMap[source]
|
||||
sourceInfo, ok = settings.Config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
logger.Error("OnlyOffice: source not found")
|
||||
return http.StatusInternalServerError, fmt.Errorf("source not found")
|
||||
logger.Error("OnlyOffice: share source not found")
|
||||
return returnOnlyOfficeError(w, r, 404, "source not found")
|
||||
}
|
||||
} else {
|
||||
sourceInfo, ok = settings.Config.Server.NameToSource[source]
|
||||
if !ok {
|
||||
logger.Error("OnlyOffice: source not found")
|
||||
return http.StatusInternalServerError, fmt.Errorf("source not found")
|
||||
return returnOnlyOfficeError(w, r, 404, "source not found")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +367,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
userScope, scopeErr := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if scopeErr != nil {
|
||||
logger.Errorf("OnlyOffice callback: source %s not available for user %s: %v", source, d.user.Username, scopeErr)
|
||||
return http.StatusForbidden, fmt.Errorf("source %s is not available", source)
|
||||
return returnOnlyOfficeError(w, r, 403, "source not available")
|
||||
}
|
||||
path = utils.JoinPathAsUnix(userScope, path)
|
||||
} else {
|
||||
@@ -297,65 +385,185 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
|
||||
// When the document is fully closed by all editors,
|
||||
// the document key should no longer be re-used.
|
||||
deleteOfficeId(source, path)
|
||||
|
||||
// Send log event for document closure and clean up log context
|
||||
if logContext := getOnlyOfficeLogContext(data.Key); logContext != nil {
|
||||
statusMsg := "Document closed with changes"
|
||||
if data.Status == onlyOfficeStatusDocumentClosedWithNoChanges {
|
||||
statusMsg = "Document closed with no changes"
|
||||
}
|
||||
sendOnlyOfficeLogEvent(logContext, "INFO", "callback", statusMsg)
|
||||
removeOnlyOfficeLogContext(data.Key)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle document save operations
|
||||
if data.Status == onlyOfficeStatusDocumentClosedWithChanges ||
|
||||
data.Status == onlyOfficeStatusForceSaveWhileDocumentStillOpen {
|
||||
// Handle document being edited (status 1) - just log for now
|
||||
if data.Status == onlyOfficeStatusDocumentBeingEdited {
|
||||
logger.Debugf("OnlyOffice callback: document being edited, key=%s, users=%v", data.Key, data.Users)
|
||||
|
||||
// Verify user has modify permissions
|
||||
if !d.user.Permissions.Modify {
|
||||
logger.Warningf("OnlyOffice callback: user %s lacks modify permissions for source=%s, path=%s",
|
||||
d.user.Username, source, path)
|
||||
return http.StatusForbidden, nil
|
||||
// Send log event for document being edited
|
||||
if logContext := getOnlyOfficeLogContext(data.Key); logContext != nil {
|
||||
sendOnlyOfficeLogEvent(logContext, "DEBUG", "callback", fmt.Sprintf("Document being edited, users: %v", data.Users))
|
||||
}
|
||||
|
||||
// Handle actions if present
|
||||
for _, action := range data.Actions {
|
||||
actionMsg := ""
|
||||
switch action.Type {
|
||||
case 0: // User disconnects
|
||||
actionMsg = fmt.Sprintf("User ID %s disconnected from document", action.UserID)
|
||||
logger.Debugf("OnlyOffice callback: user ID %s disconnected from document", action.UserID)
|
||||
case 1: // New user connects
|
||||
actionMsg = fmt.Sprintf("User ID %s connected to document", action.UserID)
|
||||
logger.Debugf("OnlyOffice callback: user ID %s connected to document", action.UserID)
|
||||
case 2: // User clicked forcesave button
|
||||
actionMsg = fmt.Sprintf("User ID %s clicked forcesave button", action.UserID)
|
||||
logger.Debugf("OnlyOffice callback: user ID %s clicked forcesave button", action.UserID)
|
||||
default:
|
||||
actionMsg = fmt.Sprintf("Unknown action type %d for user ID %s", action.Type, action.UserID)
|
||||
logger.Debugf("OnlyOffice callback: unknown action type %d for user ID %s", action.Type, action.UserID)
|
||||
}
|
||||
|
||||
// Send log event for action
|
||||
if logContext := getOnlyOfficeLogContext(data.Key); logContext != nil {
|
||||
sendOnlyOfficeLogEvent(logContext, "DEBUG", "callback", actionMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle document save operations (status 2, 3, 6, 7)
|
||||
if data.Status == onlyOfficeStatusDocumentClosedWithChanges ||
|
||||
data.Status == onlyOfficeStatusDocumentSavingError ||
|
||||
data.Status == onlyOfficeStatusForceSaveWhileDocumentStillOpen ||
|
||||
data.Status == onlyOfficeStatusForceSaveError {
|
||||
|
||||
// Log the save operation details
|
||||
statusDesc := ""
|
||||
switch data.Status {
|
||||
case onlyOfficeStatusDocumentClosedWithChanges:
|
||||
statusDesc = "document closed with changes"
|
||||
case onlyOfficeStatusDocumentSavingError:
|
||||
statusDesc = "document saving error"
|
||||
case onlyOfficeStatusForceSaveWhileDocumentStillOpen:
|
||||
statusDesc = "force save while document still open"
|
||||
case onlyOfficeStatusForceSaveError:
|
||||
statusDesc = "force save error"
|
||||
}
|
||||
|
||||
logger.Debugf("OnlyOffice callback: processing save operation - %s, key=%s, url=%s, forcesavetype=%d",
|
||||
statusDesc, data.Key, data.URL, data.ForceSaveType)
|
||||
|
||||
// Send log event for save operation
|
||||
if logContext := getOnlyOfficeLogContext(data.Key); logContext != nil {
|
||||
sendOnlyOfficeLogEvent(logContext, "INFO", "callback", fmt.Sprintf("Processing save operation: %s", statusDesc))
|
||||
}
|
||||
|
||||
// Handle history and changes URL if present
|
||||
if data.History != nil {
|
||||
logger.Debugf("OnlyOffice callback: received history data with serverVersion=%s", data.History.ServerVersion)
|
||||
if logContext := getOnlyOfficeLogContext(data.Key); logContext != nil {
|
||||
sendOnlyOfficeLogEvent(logContext, "DEBUG", "callback", fmt.Sprintf("Received history data with serverVersion=%s", data.History.ServerVersion))
|
||||
}
|
||||
}
|
||||
if data.ChangesURL != "" {
|
||||
logger.Debugf("OnlyOffice callback: received changes URL: %s", data.ChangesURL)
|
||||
if logContext := getOnlyOfficeLogContext(data.Key); logContext != nil {
|
||||
sendOnlyOfficeLogEvent(logContext, "DEBUG", "callback", "Received changes URL for document history")
|
||||
}
|
||||
}
|
||||
|
||||
// For status 3 (saving error), don't attempt to save the file
|
||||
if data.Status == onlyOfficeStatusDocumentSavingError {
|
||||
logger.Warningf("OnlyOffice callback: document saving error occurred, not attempting to save")
|
||||
return returnOnlyOfficeSuccess(w, r)
|
||||
}
|
||||
|
||||
// Check share permissions first if this is a share request
|
||||
if d.fileInfo.Hash != "" {
|
||||
if !d.share.AllowModify {
|
||||
logger.Errorf("OnlyOffice callback: edit permission not allowed for this share")
|
||||
return returnOnlyOfficeError(w, r, 403, "edit permission not allowed for this share")
|
||||
}
|
||||
} else {
|
||||
// Verify user has modify permissions
|
||||
if !d.user.Permissions.Modify {
|
||||
logger.Errorf("OnlyOffice callback: user %s lacks modify permissions for path=%s",
|
||||
d.user.Username, path)
|
||||
return returnOnlyOfficeError(w, r, 403, "user lacks modify permissions")
|
||||
}
|
||||
}
|
||||
|
||||
// Download the updated document from OnlyOffice server
|
||||
doc, err := http.Get(data.URL)
|
||||
if err != nil {
|
||||
logger.Errorf("OnlyOffice callback: failed to download updated document: %v", err)
|
||||
return http.StatusInternalServerError, err
|
||||
return returnOnlyOfficeError(w, r, 500, "failed to download updated document")
|
||||
}
|
||||
defer doc.Body.Close()
|
||||
|
||||
// Resolve file path for writing (same logic as in config handler)
|
||||
var resolvedPath string
|
||||
if d.fileInfo.Hash == "" {
|
||||
// Regular user request - need to resolve scope
|
||||
userScope, scopeErr := settings.GetScopeFromSourceName(d.user.Scopes, source)
|
||||
if scopeErr != nil {
|
||||
logger.Errorf("OnlyOffice callback: source %s not available for user %s: %v",
|
||||
source, d.user.Username, scopeErr)
|
||||
return http.StatusForbidden, fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
resolvedPath = utils.JoinPathAsUnix(userScope, path)
|
||||
// Check if the download was successful
|
||||
if doc.StatusCode != 200 {
|
||||
logger.Errorf("OnlyOffice callback: failed to download document, status code: %d", doc.StatusCode)
|
||||
return returnOnlyOfficeError(w, r, 500, "failed to download document from OnlyOffice server")
|
||||
}
|
||||
|
||||
// Write the updated document
|
||||
fileOpts := utils.FileOptions{
|
||||
Path: resolvedPath,
|
||||
logger.Debugf("OnlyOffice callback: saving document to path=%s",
|
||||
path)
|
||||
|
||||
// Send detailed log event for file saving with path information
|
||||
if logContext := getOnlyOfficeLogContext(data.Key); logContext != nil {
|
||||
sendOnlyOfficeLogEvent(logContext, "INFO", "callback", fmt.Sprintf("Saving document to path: %s", path))
|
||||
}
|
||||
|
||||
// CRITICAL: Validate that the original file still exists before saving
|
||||
// This prevents creating duplicate files if the original was renamed/moved
|
||||
_, err = files.FileInfoFaster(utils.FileOptions{
|
||||
Source: source,
|
||||
}
|
||||
writeErr := files.WriteFile(fileOpts, doc.Body)
|
||||
if writeErr != nil {
|
||||
logger.Errorf("OnlyOffice callback: failed to write updated document: %v", writeErr)
|
||||
return http.StatusInternalServerError, writeErr
|
||||
Path: path,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
logger.Errorf("OnlyOffice callback: original file no longer exists at path=%s: %v",
|
||||
path, err)
|
||||
|
||||
// Send error log event with path information
|
||||
if logContext := getOnlyOfficeLogContext(data.Key); logContext != nil {
|
||||
sendOnlyOfficeLogEvent(logContext, "ERROR", "callback",
|
||||
fmt.Sprintf("Original file no longer exists at path: %s - %v -- was it renamed or moved?", path, err))
|
||||
}
|
||||
|
||||
return returnOnlyOfficeError(w, r, 404, "original file no longer exists - it may have been renamed or moved")
|
||||
}
|
||||
|
||||
writeErr := files.WriteFile(source, path, doc.Body)
|
||||
if writeErr != nil {
|
||||
logger.Errorf("OnlyOffice callback: failed to write updated document to path=%s: %v",
|
||||
path, writeErr)
|
||||
|
||||
// Send error log event with path information
|
||||
if logContext := getOnlyOfficeLogContext(data.Key); logContext != nil {
|
||||
sendOnlyOfficeLogEvent(logContext, "ERROR", "callback", fmt.Sprintf("Failed to save document to path: %s - %v", path, writeErr))
|
||||
}
|
||||
|
||||
return returnOnlyOfficeError(w, r, 500, "failed to save document")
|
||||
}
|
||||
|
||||
logger.Infof("OnlyOffice callback: successfully saved document to path=%s",
|
||||
path)
|
||||
|
||||
// Send success log event with detailed path information
|
||||
if logContext := getOnlyOfficeLogContext(data.Key); logContext != nil {
|
||||
sendOnlyOfficeLogEvent(logContext, "INFO", "callback", fmt.Sprintf("Document saved successfully to path: %s", path))
|
||||
}
|
||||
}
|
||||
|
||||
// Return success response to OnlyOffice server
|
||||
resp := map[string]int{
|
||||
"error": 0,
|
||||
}
|
||||
return renderJSON(w, r, resp)
|
||||
return returnOnlyOfficeSuccess(w, r)
|
||||
}
|
||||
|
||||
func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// Parse callback data based on request method
|
||||
var callbackData *OnlyOfficeCallback
|
||||
var err error
|
||||
|
||||
if r.Method == "GET" {
|
||||
// OnlyOffice sends callback data in Authorization header as JWT
|
||||
callbackData, err = parseOnlyOfficeCallbackFromJWT(r)
|
||||
@@ -363,12 +571,17 @@ func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *reques
|
||||
// OnlyOffice sends callback data in request body as JSON
|
||||
callbackData, err = parseOnlyOfficeCallbackFromJSON(r)
|
||||
} else {
|
||||
return http.StatusMethodNotAllowed, fmt.Errorf("unsupported method: %s", r.Method)
|
||||
return returnOnlyOfficeError(w, r, 405, fmt.Sprintf("unsupported method: %s", r.Method))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Errorf("OnlyOffice callback: failed to parse callback data: %v", err)
|
||||
return http.StatusBadRequest, err
|
||||
return returnOnlyOfficeError(w, r, 400, "failed to parse callback data")
|
||||
}
|
||||
|
||||
if callbackData == nil {
|
||||
logger.Errorf("OnlyOffice callback: parsed callback data is nil")
|
||||
return returnOnlyOfficeError(w, r, 400, "parsed callback data is nil")
|
||||
}
|
||||
|
||||
// Process the callback data using shared logic
|
||||
@@ -493,3 +706,40 @@ func parseOnlyOfficeJWT(tokenString string) (*OnlyOfficeCallback, error) {
|
||||
|
||||
return callback, nil
|
||||
}
|
||||
|
||||
// returnOnlyOfficeSuccess returns a success response to OnlyOffice server
|
||||
func returnOnlyOfficeSuccess(w http.ResponseWriter, r *http.Request) (int, error) {
|
||||
resp := map[string]int{
|
||||
"error": 0,
|
||||
}
|
||||
return renderJSON(w, r, resp)
|
||||
}
|
||||
|
||||
// returnOnlyOfficeError returns an error response to OnlyOffice server with proper status code
|
||||
func returnOnlyOfficeError(w http.ResponseWriter, r *http.Request, statusCode int, message string) (int, error) {
|
||||
// OnlyOffice expects specific error codes in the response body
|
||||
errorCode := 0
|
||||
switch statusCode {
|
||||
case 400:
|
||||
errorCode = 1 // Bad request
|
||||
case 403:
|
||||
errorCode = 1 // Forbidden (treated as bad request by OnlyOffice)
|
||||
case 404:
|
||||
errorCode = 1 // Not found (treated as bad request by OnlyOffice)
|
||||
case 500:
|
||||
errorCode = 1 // Internal server error (treated as bad request by OnlyOffice)
|
||||
default:
|
||||
errorCode = 1 // Default to bad request
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"error": errorCode,
|
||||
}
|
||||
|
||||
// Log the error for debugging
|
||||
logger.Errorf("OnlyOffice callback error (HTTP %d): %s", statusCode, message)
|
||||
|
||||
// Set the appropriate HTTP status code
|
||||
w.WriteHeader(statusCode)
|
||||
return renderJSON(w, r, resp)
|
||||
}
|
||||
+21
-9
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/access"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/preview"
|
||||
@@ -78,7 +79,7 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
Username: d.user.Username,
|
||||
Path: utils.JoinPathAsUnix(userscope, path),
|
||||
Source: source,
|
||||
Metadata: true,
|
||||
AlbumArt: true, // Extract album art for audio previews
|
||||
}, store.Access)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
@@ -109,13 +110,13 @@ func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.Extend
|
||||
// getDirectoryPreview finds a valid preview file within a directory.
|
||||
// It iterates through files, checking if they should bubble up to folder previews,
|
||||
// and returns the first valid, non-corrupted file suitable for preview.
|
||||
func getDirectoryPreview(r *http.Request, d *requestContext) (*iteminfo.ExtendedFileInfo, error) {
|
||||
func getDirectoryPreview(r *http.Request, d *requestContext, accessStore *access.Storage) (*iteminfo.ExtendedFileInfo, error) {
|
||||
var lastErr error
|
||||
|
||||
for _, item := range d.fileInfo.Files {
|
||||
// Only use files that should bubble up to folder previews (images, videos, audio)
|
||||
// Exclude text files, office documents, and PDFs
|
||||
if !item.HasPreview || !iteminfo.ShouldBubbleUpToFolderPreview(item) {
|
||||
if !item.HasPreview || !iteminfo.ShouldBubbleUpToFolderPreview(item.ItemInfo) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -137,8 +138,8 @@ func getDirectoryPreview(r *http.Request, d *requestContext) (*iteminfo.Extended
|
||||
Username: d.user.Username,
|
||||
Path: path,
|
||||
Source: source,
|
||||
Metadata: true,
|
||||
}, store.Access)
|
||||
AlbumArt: true, // Extract album art for audio previews
|
||||
}, accessStore)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue // Try next file if this one fails
|
||||
@@ -150,9 +151,16 @@ func getDirectoryPreview(r *http.Request, d *requestContext) (*iteminfo.Extended
|
||||
cancel()
|
||||
|
||||
if previewErr != nil {
|
||||
// File might be corrupted, try next one
|
||||
logger.Debugf("Skipping corrupted preview file in directory '%s': %s (error: %v)",
|
||||
d.fileInfo.Name, item.Name, previewErr)
|
||||
// Skip context errors (timeout or cancellation) - they're not corruption issues
|
||||
if !errors.Is(previewErr, context.Canceled) && !errors.Is(previewErr, context.DeadlineExceeded) {
|
||||
// File might be corrupted, try next one
|
||||
logger.Debugf("Skipping preview file in directory '%s': %s (error: %v)",
|
||||
d.fileInfo.Name, item.Name, previewErr)
|
||||
} else {
|
||||
// if it is a context error, return the error
|
||||
// don't keep trying
|
||||
return nil, previewErr
|
||||
}
|
||||
lastErr = previewErr
|
||||
continue
|
||||
}
|
||||
@@ -177,9 +185,13 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
|
||||
if !d.fileInfo.HasPreview {
|
||||
return http.StatusBadRequest, fmt.Errorf("this item does not have a preview")
|
||||
}
|
||||
accessStore := store.Access
|
||||
if d.share != nil {
|
||||
accessStore = nil
|
||||
}
|
||||
if d.fileInfo.Type == "directory" {
|
||||
// Get extended file info of first previewable item in directory
|
||||
fileInfo, err := getDirectoryPreview(r, d)
|
||||
fileInfo, err := getDirectoryPreview(r, d, accessStore)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
+191
-15
@@ -8,8 +8,12 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
|
||||
"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/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/preview"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
|
||||
_ "github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
|
||||
@@ -37,6 +41,11 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
return http.StatusNotImplemented, fmt.Errorf("downloads are disabled for upload shares")
|
||||
}
|
||||
|
||||
// Check DisableDownload permission for normal shares
|
||||
if d.share.DisableDownload {
|
||||
return http.StatusForbidden, fmt.Errorf("downloads are not allowed for this share")
|
||||
}
|
||||
|
||||
// Check global download limit (if not using per-user limits)
|
||||
if !d.share.PerUserDownloadLimit && d.share.DownloadsLimit > 0 && d.share.Downloads >= d.share.DownloadsLimit {
|
||||
return http.StatusForbidden, fmt.Errorf("share downloads limit reached")
|
||||
@@ -103,6 +112,9 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
var status int
|
||||
status, err = rawFilesHandler(w, r, d, fileList)
|
||||
if err != nil {
|
||||
if err == errors.ErrDownloadNotAllowed {
|
||||
return http.StatusForbidden, errors.ErrDownloadNotAllowed
|
||||
}
|
||||
logger.Errorf("public share handler: error processing filelist: '%v' with error %v", f, err)
|
||||
return status, fmt.Errorf("error processing filelist: %v", f)
|
||||
}
|
||||
@@ -122,7 +134,7 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
|
||||
// @Failure 404 {object} map[string]string "Share not found or file not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Failure 501 {object} map[string]string "Browsing disabled for upload shares"
|
||||
// @Router /public/api/share [get]
|
||||
// @Router /public/api/resources [get]
|
||||
func publicGetResourceHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if d.share.ShareType == "upload" {
|
||||
return http.StatusNotImplemented, fmt.Errorf("browsing is disabled for upload shares")
|
||||
@@ -150,22 +162,28 @@ func publicGetResourceHandler(w http.ResponseWriter, r *http.Request, d *request
|
||||
// @Failure 501 {object} map[string]string "Uploading disabled for non-upload shares"
|
||||
// @Router /public/api/resources [post]
|
||||
func publicUploadHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if d.share.ShareType != "upload" {
|
||||
return http.StatusNotImplemented, fmt.Errorf("uploading is disabled for non-upload shares")
|
||||
if d.share.ShareType != "upload" && !d.share.AllowCreate {
|
||||
return http.StatusForbidden, fmt.Errorf("uploading is disabled for this share")
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(d.share.Path, r.URL.Query().Get("targetPath"))
|
||||
if !d.share.AllowReplacements && r.URL.Query().Get("action") == "override" {
|
||||
return http.StatusForbidden, fmt.Errorf("cannot overwrite files for this share")
|
||||
}
|
||||
path, err := url.QueryUnescape(r.URL.Query().Get("targetPath"))
|
||||
if err != nil {
|
||||
logger.Debugf("invalid path encoding: %v", err)
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
fullPath := filepath.Join(d.share.Path, path)
|
||||
source := config.Server.SourceMap[d.share.Source].Name
|
||||
logger.Infof("public upload handler: fullPath: '%v' for share: '%v' with source: '%v'", fullPath, d.share.Source, source)
|
||||
// adjust query params to match resourcePostHandler
|
||||
q := r.URL.Query()
|
||||
q.Set("source", source)
|
||||
q.Set("path", fullPath)
|
||||
r.URL.RawQuery = q.Encode()
|
||||
|
||||
status, err := resourcePostHandler(w, r, d)
|
||||
if err != nil {
|
||||
logger.Errorf("public upload handler: error uploading: '%v' with error %v", d.fileInfo, err)
|
||||
logger.Errorf("public upload handler: error uploading with error %v", err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("upload failure occured on backend")
|
||||
}
|
||||
return status, err
|
||||
@@ -215,15 +233,19 @@ func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
|
||||
// Restore source name from share for preview generation
|
||||
// The middleware clears file.Source for security, but we need it for index lookups
|
||||
if d.fileInfo.Source == "" && d.share != nil {
|
||||
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
|
||||
if !ok {
|
||||
// Don't expose internal errors to share users
|
||||
return http.StatusNotFound, fmt.Errorf("resource not available")
|
||||
}
|
||||
d.fileInfo.Source = sourceInfo.Name
|
||||
source, err := d.share.GetSourceName()
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source not available")
|
||||
}
|
||||
|
||||
fileInfo, err := FileInfoFasterFunc(utils.FileOptions{
|
||||
Path: utils.JoinPathAsUnix(d.share.Path, d.fileInfo.Path),
|
||||
Source: source,
|
||||
Metadata: true,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("resource not available")
|
||||
}
|
||||
d.fileInfo = *fileInfo
|
||||
status, err := previewHelperFunc(w, r, d)
|
||||
if err != nil {
|
||||
// Obfuscate errors for shares to prevent information leakage
|
||||
@@ -231,3 +253,157 @@ func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestCont
|
||||
}
|
||||
return status, err
|
||||
}
|
||||
|
||||
// publicPutHandler handles the PUT request for a public share.
|
||||
// @Summary Update a file in a public share
|
||||
// @Description Updates the content of a file in a public share.
|
||||
// @Tags Public Shares
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param hash query string true "Share hash for authentication"
|
||||
// @Param path query string true "Path to the file to update"
|
||||
// @Param content body string true "New content for the file"
|
||||
// @Success 200 {object} map[string]string "File updated successfully"
|
||||
// @Failure 400 {object} map[string]string "Invalid request or parameters"
|
||||
// @Failure 403 {object} map[string]string "Share unavailable or update not allowed"
|
||||
// @Failure 404 {object} map[string]string "Share not found or file not found"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /public/api/resources [put]
|
||||
func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
// update path to be the source path
|
||||
if !d.share.AllowModify {
|
||||
return http.StatusForbidden, fmt.Errorf("create is not allowed for this share")
|
||||
}
|
||||
source, err := d.share.GetSourceName()
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source not available")
|
||||
}
|
||||
|
||||
if !d.share.AllowModify {
|
||||
return http.StatusForbidden, fmt.Errorf("edit permission not allowed for this share")
|
||||
}
|
||||
path := r.URL.Query().Get("path")
|
||||
|
||||
// Decode the URL-encoded path
|
||||
path, err = url.QueryUnescape(path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
resolvedPath := utils.JoinPathAsUnix(d.share.Path, path)
|
||||
err = files.WriteFile(source, resolvedPath, r.Body)
|
||||
return errToStatus(err), err
|
||||
}
|
||||
|
||||
func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !d.share.AllowDelete {
|
||||
return http.StatusForbidden, fmt.Errorf("delete is not allowed for this share")
|
||||
}
|
||||
|
||||
// TODO source := r.URL.Query().Get("source")
|
||||
encodedPath := r.URL.Query().Get("path")
|
||||
|
||||
// Decode the URL-encoded path
|
||||
path, err := url.QueryUnescape(encodedPath)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
indexPath := utils.JoinPathAsUnix(d.share.Path, path)
|
||||
source, err := d.share.GetSourceName()
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source not available")
|
||||
}
|
||||
|
||||
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
|
||||
Username: d.user.Username,
|
||||
Path: indexPath,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("resource not available")
|
||||
}
|
||||
|
||||
// delete thumbnails
|
||||
preview.DelThumbs(r.Context(), *fileInfo)
|
||||
|
||||
err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath), fileInfo.Type == "directory")
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, fmt.Errorf("an error occured while deleting the resource")
|
||||
}
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
|
||||
func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
if !d.share.AllowModify {
|
||||
return http.StatusForbidden, fmt.Errorf("edit permission not allowed for this share")
|
||||
}
|
||||
// The middleware clears file.Source for security, but we need it for index lookups
|
||||
source, err := d.share.GetSourceName()
|
||||
if err != nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source not available")
|
||||
}
|
||||
action := r.URL.Query().Get("action")
|
||||
encodedFrom := r.URL.Query().Get("from")
|
||||
// Decode the URL-encoded path
|
||||
src, err := url.QueryUnescape(encodedFrom)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
dst := r.URL.Query().Get("destination")
|
||||
dst, err = url.QueryUnescape(dst)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid path encoding: %v", err)
|
||||
}
|
||||
|
||||
idx := indexing.GetIndex(source)
|
||||
if idx == nil {
|
||||
return http.StatusNotFound, fmt.Errorf("source for share not found")
|
||||
}
|
||||
|
||||
// get full paths for both locations
|
||||
srcFullPath := utils.JoinPathAsUnix(d.share.Path, src)
|
||||
if srcFullPath == "/" {
|
||||
return http.StatusForbidden, fmt.Errorf("an error occured accessing the share")
|
||||
}
|
||||
dstFullPath := utils.JoinPathAsUnix(d.share.Path, dst)
|
||||
if dstFullPath == "/" {
|
||||
return http.StatusForbidden, fmt.Errorf("an error occured accessing the share")
|
||||
}
|
||||
|
||||
// get real source and isDir
|
||||
realSrc, isSrcDir, err := idx.GetRealPath(srcFullPath)
|
||||
if err != nil {
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
// get real destination and isDir parent
|
||||
parentRealDest, _, err := idx.GetRealPath(filepath.Dir(dstFullPath))
|
||||
if err != nil {
|
||||
return http.StatusNotFound, err
|
||||
}
|
||||
dstFullPath = parentRealDest + "/" + filepath.Base(dst)
|
||||
rename := r.URL.Query().Get("rename") == "true"
|
||||
if rename {
|
||||
dstFullPath = addVersionSuffix(dstFullPath)
|
||||
}
|
||||
// Validate move/rename operation to prevent circular references
|
||||
if action == "rename" || action == "move" {
|
||||
err = validateMoveOperation(realSrc, dstFullPath, isSrcDir)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid move or rename operation")
|
||||
}
|
||||
}
|
||||
err = patchAction(r.Context(), patchActionParams{
|
||||
action: action,
|
||||
srcIndex: source,
|
||||
dstIndex: source,
|
||||
src: realSrc,
|
||||
dst: dstFullPath,
|
||||
d: d,
|
||||
isSrcDir: isSrcDir,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Debugf("Could not run patch action. src=%v dst=%v err=%v", realSrc, dstFullPath, err)
|
||||
return http.StatusInternalServerError, fmt.Errorf("an error occured while processing the request")
|
||||
}
|
||||
return http.StatusOK, nil
|
||||
}
|
||||
+122
-5
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
|
||||
"github.com/gtsteffaniak/go-logger/logger"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
@@ -110,12 +111,20 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
|
||||
if idx == nil {
|
||||
return fmt.Errorf("source %s is not available", source)
|
||||
}
|
||||
|
||||
// Check access control directly for each file and silently skip if access is denied
|
||||
if d.share == nil && store.Access != nil {
|
||||
if !store.Access.Permitted(idx.Path, path, d.user.Username) {
|
||||
return nil // Silently skip this file/folder
|
||||
}
|
||||
}
|
||||
|
||||
// Verify file exists
|
||||
_, err = files.FileInfoFaster(utils.FileOptions{
|
||||
Username: d.user.Username,
|
||||
Path: path,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
}, store.Access)
|
||||
Path: path,
|
||||
Source: source,
|
||||
Expand: false,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -149,6 +158,23 @@ func addFile(path string, d *requestContext, tarWriter *tar.Writer, zipWriter *z
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check access control for each file/folder during walk
|
||||
if d.share == nil {
|
||||
// Construct the index-relative path for this file/folder
|
||||
// relPath is relative to realPath, so we need to join it with the original path
|
||||
indexRelPath := filepath.Join(path, relPath)
|
||||
indexRelPath = filepath.ToSlash(indexRelPath) // Normalize separators
|
||||
|
||||
if !store.Access.Permitted(idx.Path, indexRelPath, d.user.Username) {
|
||||
// Skip this file/folder silently
|
||||
if fileInfo.IsDir() {
|
||||
// Skip the entire directory by returning filepath.SkipDir
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend base folder name unless flatten is true
|
||||
if !flatten {
|
||||
relPath = filepath.Join(baseName, relPath)
|
||||
@@ -232,6 +258,9 @@ func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWrite
|
||||
}
|
||||
|
||||
func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext, fileList []string) (int, error) {
|
||||
if !d.user.Permissions.Download && d.share == nil {
|
||||
return http.StatusForbidden, fmt.Errorf("user is not allowed to download")
|
||||
}
|
||||
splitFile := strings.Split(fileList[0], "::")
|
||||
if len(splitFile) != 2 {
|
||||
return http.StatusBadRequest, fmt.Errorf("invalid file in files request: %v", fileList[0])
|
||||
@@ -243,9 +272,31 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
var err error
|
||||
var userscope string
|
||||
fileName := filepath.Base(firstFilePath)
|
||||
|
||||
// Check if this is an OnlyOffice file early for error logging
|
||||
isOnlyOffice := isOnlyOfficeCompatibleFile(fileName) && config.Integrations.OnlyOffice.Url != ""
|
||||
var documentId string
|
||||
var logContext *OnlyOfficeLogContext
|
||||
|
||||
if d.share == nil {
|
||||
userscope, err = settings.GetScopeFromSourceName(d.user.Scopes, firstFileSource)
|
||||
if err != nil {
|
||||
// Send OnlyOffice error log if this was an OnlyOffice file
|
||||
if isOnlyOffice {
|
||||
// Try to get document ID for error logging
|
||||
idx := indexing.GetIndex(firstFileSource)
|
||||
if idx != nil {
|
||||
tempPath := utils.JoinPathAsUnix(userscope, firstFilePath)
|
||||
if realPath, _, realErr := idx.GetRealPath(tempPath); realErr == nil {
|
||||
if docId, _ := getOnlyOfficeId(realPath); docId != "" {
|
||||
if ctx := getOnlyOfficeLogContext(docId); ctx != nil {
|
||||
sendOnlyOfficeLogEvent(ctx, "ERROR", "download",
|
||||
fmt.Sprintf("OnlyOffice download failed - source not available: %s - %v", firstFilePath, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
firstFilePath = utils.JoinPathAsUnix(userscope, firstFilePath)
|
||||
@@ -253,10 +304,24 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
// For shares, the path is already correctly resolved by publicRawHandler
|
||||
idx := indexing.GetIndex(firstFileSource)
|
||||
if idx == nil {
|
||||
// Send OnlyOffice error log if this was an OnlyOffice file
|
||||
if isOnlyOffice {
|
||||
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
|
||||
fmt.Sprintf("OnlyOffice download failed - source index not available: %s", firstFileSource))
|
||||
}
|
||||
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", firstFileSource)
|
||||
}
|
||||
realPath, isDir, err := idx.GetRealPath(firstFilePath)
|
||||
if err != nil {
|
||||
// Send OnlyOffice error log if this was an OnlyOffice file
|
||||
if isOnlyOffice {
|
||||
if docId, _ := getOnlyOfficeId(realPath); docId != "" {
|
||||
if ctx := getOnlyOfficeLogContext(docId); ctx != nil {
|
||||
sendOnlyOfficeLogEvent(ctx, "ERROR", "download",
|
||||
fmt.Sprintf("OnlyOffice download failed - could not resolve path: %s - %v", firstFilePath, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
// Compute estimated download size
|
||||
@@ -266,8 +331,34 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
}
|
||||
// ** Single file download with Content-Length **
|
||||
if len(fileList) == 1 && !isDir {
|
||||
// Get document ID and log context for OnlyOffice downloads
|
||||
if isOnlyOffice {
|
||||
documentId, _ = getOnlyOfficeId(realPath)
|
||||
if documentId != "" {
|
||||
logContext = getOnlyOfficeLogContext(documentId)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify access control before opening the file (direct rule check)
|
||||
if d.share == nil && store.Access != nil {
|
||||
if !store.Access.Permitted(idx.Path, firstFilePath, d.user.Username) {
|
||||
logger.Debugf("user %s denied access to path %s", d.user.Username, firstFilePath)
|
||||
// Send OnlyOffice error log if this was an OnlyOffice download
|
||||
if isOnlyOffice && logContext != nil {
|
||||
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
|
||||
fmt.Sprintf("OnlyOffice download failed - access denied by rule: %s", firstFilePath))
|
||||
}
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to path %s", firstFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
fd, err2 := os.Open(realPath)
|
||||
if err2 != nil {
|
||||
// Send OnlyOffice error log if this was an OnlyOffice download
|
||||
if isOnlyOffice && logContext != nil {
|
||||
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
|
||||
fmt.Sprintf("OnlyOffice download failed - could not open file: %s - %v", firstFilePath, err2))
|
||||
}
|
||||
return http.StatusInternalServerError, err2
|
||||
}
|
||||
defer fd.Close()
|
||||
@@ -275,9 +366,23 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
|
||||
// Get file size
|
||||
fileInfo, err2 := fd.Stat()
|
||||
if err2 != nil {
|
||||
// Send OnlyOffice error log if this was an OnlyOffice download
|
||||
if isOnlyOffice && logContext != nil {
|
||||
sendOnlyOfficeLogEvent(logContext, "ERROR", "download",
|
||||
fmt.Sprintf("OnlyOffice download failed - could not get file info: %s - %v", firstFilePath, err2))
|
||||
}
|
||||
return http.StatusInternalServerError, err2
|
||||
}
|
||||
|
||||
// Send success log for OnlyOffice downloads
|
||||
if isOnlyOffice && logContext != nil {
|
||||
logger.Infof("OnlyOffice Server is downloading file: %s (documentId: %s)",
|
||||
firstFilePath, documentId)
|
||||
|
||||
sendOnlyOfficeLogEvent(logContext, "INFO", "download",
|
||||
fmt.Sprintf("OnlyOffice Server downloading file: %s", firstFilePath))
|
||||
}
|
||||
|
||||
// Set headers
|
||||
setContentDisposition(w, r, fileName)
|
||||
w.Header().Set("Cache-Control", "private")
|
||||
@@ -405,6 +510,12 @@ func computeArchiveSize(fileList []string, d *requestContext) (int64, error) {
|
||||
return 0, fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
|
||||
}
|
||||
path = utils.JoinPathAsUnix(userScope, path)
|
||||
|
||||
// Check access control for each file in the archive
|
||||
// Silently skip if access is denied (as if the file doesn't exist)
|
||||
if store.Access != nil && !store.Access.Permitted(idx.Path, path, d.user.Username) {
|
||||
continue // Skip this file and continue with the next one
|
||||
}
|
||||
}
|
||||
// For shares, the path is already correctly resolved by publicRawHandler
|
||||
realPath, isDir, err := idx.GetRealPath(path)
|
||||
@@ -437,6 +548,7 @@ func createZip(d *requestContext, tmpDirPath string, filenames ...string) error
|
||||
for _, fname := range filenames {
|
||||
err := addFile(fname, d, nil, zipWriter, false)
|
||||
if err != nil {
|
||||
// Access control failures return nil, so any error here is a real error
|
||||
logger.Errorf("Failed to add %s to ZIP: %v", fname, err)
|
||||
return err
|
||||
}
|
||||
@@ -467,3 +579,8 @@ func createTarGz(d *requestContext, tmpDirPath string, filenames ...string) erro
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isOnlyOfficeCompatibleFile checks if a file extension is supported by OnlyOffice
|
||||
func isOnlyOfficeCompatibleFile(fileName string) bool {
|
||||
return iteminfo.IsOnlyOffice(fileName)
|
||||
}
|
||||
Loaded 100 of 378 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user