Compare commits

..
Author SHA1 Message Date
Graham Steffaniak 19d60a8a24 updated dependencies (#2168) 2026-03-21 15:21:54 -05:00
Graham Steffaniak d583359f4e Update CHANGELOG.md 2026-03-21 13:50:17 -05:00
Graham Steffaniak 2c0f9f26d6 Update CHANGELOG.md 2026-03-21 13:49:57 -05:00
Graham Steffaniak efe10ec6d5 Update CHANGELOG.md 2026-03-21 13:37:57 -05:00
Graham Steffaniak 0f37b8120f Update CHANGELOG.md 2026-03-21 13:37:34 -05:00
Graham Steffaniak c55107e31b Add search filters (#2164) 2026-03-21 11:55:18 -05:00
Kevin B. ee0307d80a add option to prefer editor first and improve markdown viewer (#2160) 2026-03-21 09:40:27 -05:00
Graham Steffaniak e64179d6d2 fix external share banner (#2162) 2026-03-21 09:05:10 -05:00
Graham Steffaniak 68f0f0204e updated with real ip (#2163) 2026-03-21 09:02:00 -05:00
Graham Steffaniak 9e18c99b05 updated with ldap changes (#2161) 2026-03-20 19:08:26 -05:00
Graham Steffaniak cdbc7a35f7 Merge branch 'dev/v1.3.2' of github.com:gtsteffaniak/filebrowser into dev/v1.3.2 2026-03-20 10:00:14 -05:00
Graham Steffaniak a18f268547 updated with maxArchiveSize settings (#2155) 2026-03-19 19:24:35 -05:00
Graham Steffaniak 28edf9ce0e updated with maxArchiveSize settings 2026-03-19 18:42:33 -05:00
Graham Steffaniak e99b22349d Fix put api folders (#2153) 2026-03-19 18:19:00 -05:00
Graham Steffaniak 9775cc03fd updated logging and concurrency (#2152) 2026-03-19 12:58:39 -05:00
Graham Steffaniak 18333e21fd updated rule inheritance (#2151) 2026-03-18 20:07:43 -05:00
Shane Chiu ad3d184811 fix: Prevent root directory deletion and fix bulk delete path bug with added tests (#2146) 2026-03-18 20:06:02 -05:00
Graham Steffaniak d47c2d9c0d pwa changes for public icons (#2150) 2026-03-18 16:46:17 -05:00
Graham Steffaniak 04e4324981 Merge branch 'main' into dev/v1.3.2 2026-03-18 15:21:10 -05:00
Kevin B. eabdfd9aa2 fix wrong username in share settings (#2148) 2026-03-18 15:14:01 -05:00
Graham Steffaniak 44edfcafa1 Merge branch 'main' into dev/v1.3.2 2026-03-18 14:28:52 -05:00
Kevin B. be2e78f729 Fix delete and fileTree navigation issues (#2142) 2026-03-18 14:27:43 -05:00
Graham Steffaniak c22fc59035 fix user scope, download, and edit issue for onlyoffice (#2145) 2026-03-18 14:27:02 -05:00
Graham Steffaniak af08800667 Username enumeration fix (#2144) 2026-03-17 18:06:26 -05:00
Graham Steffaniak ad82de28e8 Merge branch 'main' into dev/v1.3.2 2026-03-17 12:58:40 -05:00
Graham Steffaniak 319b2d06e6 upgrade ffmpeg to version 8.1 (#2139) 2026-03-17 12:57:34 -05:00
Graham Steffaniak 2d2b3ade62 deprecate maxArchiveSizeGb (#2137) 2026-03-17 11:22:46 -05:00
Graham Steffaniak 0481052308 Merge branch 'main' into dev/v1.3.2 2026-03-16 17:47:54 -05:00
janakoram 513946bdbf changed folder downloads to stream archive directly (#2125) 2026-03-16 17:39:38 -05:00
Peter Dave Hello 86ed0891e0 Improve zh-TW Traditional Chinese locale (#2115) 2026-03-10 11:03:16 -05:00
Graham Steffaniak d8bf9422c2 Update README.md (#2116) 2026-03-10 11:03:07 -05:00
Stephan Paternotte a7169e8dad Update nl.json for Beta/v1.3.1 (#2111) 2026-03-10 09:10:37 -05:00
Graham Steffaniak 87d241d166 added preview for pdf timeout (#2114) 2026-03-09 19:08:37 -05:00
Mickael 72f9fcbd64 fix (docs): incorrect Filestash feature listing (#2109) 2026-03-09 18:59:18 -05:00
092c97f9ae Rename prompts functions (#2108)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Gabriel Górski <me@glaeqen.eu>
Co-authored-by: Joel Godfrey <82655889+BaccanoMob@users.noreply.github.com>
2026-03-09 18:54:17 -05:00
d192ede484 Beta/v1.3.1 (#2104)
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Gabriel Górski <me@glaeqen.eu>
Co-authored-by: Joel Godfrey <82655889+BaccanoMob@users.noreply.github.com>
2026-03-07 17:24:57 -05:00
dependabot[bot] da0c8bd0f1 Bump the npm_and_yarn group across 1 directory with 2 updates (#2046)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-28 09:22:53 -06:00
Graham Steffaniak 5108330405 an new announcement (#2049) 2026-02-28 07:56:40 -06:00
1c7e3c2ad2 Beta/v1.3.0 (#2045)
Signed-off-by: Joel Godfrey <m.joelgodfrey@gmail.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: zarev <60230221+zarevskaya@users.noreply.github.com>
Co-authored-by: Joel Godfrey <82655889+BaccanoMob@users.noreply.github.com>
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
2026-02-27 18:45:59 -06:00
Graham Steffaniak 2eb352c2d3 fix delete item issue while viewing (#2043) 2026-02-27 16:29:49 -06:00
Graham Steffaniak 43bb4a36b4 stable/v1.2.1 (#2041) 2026-02-27 13:13:18 -06:00
Stephan Paternotte c67abefc7b Update nl.json (#2032) 2026-02-25 12:26:39 -06:00
Graham Steffaniak 2ecb9036aa Beta/v1.2.7 (#2027) 2026-02-24 12:55:04 -06:00
314 changed files with 28022 additions and 11497 deletions

No files matched your search

+2
View File
@@ -1,4 +1,6 @@
backend/**.db
**.crt
**.key
backend/tmp
backend/dist
backend/backend*
+6
View File
@@ -80,6 +80,12 @@ jobs:
dockerfile: ./_docker/Dockerfile.playwright-no-config
- name: settings
dockerfile: ./_docker/Dockerfile.playwright-settings
- name: screenshots
dockerfile: ./_docker/Dockerfile.playwright-screenshots
- name: previews
dockerfile: ./_docker/Dockerfile.playwright-previews
- name: jwt
dockerfile: ./_docker/Dockerfile.playwright-jwt
steps:
- name: Checkout
uses: actions/checkout@v4
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
with:
go-version: 'stable'
- working-directory: backend
run: go test -race -v ./...
run: go test -race -v ./... -timeout 30s
lint-backend:
runs-on: ubuntu-latest
steps:
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ghcr.io/gtsteffaniak/playwright-base
tags: latest
tags: chromium
- name: Build and push playwright-base
uses: docker/build-push-action@v6
with:
+1
View File
@@ -18,6 +18,7 @@ frontend/tsconfig.tsbuildinfo
/frontend/test-results
/frontend/playwright-files
/frontend/screenshots
/frontend/generated
/frontend/tests/playwright-files/demo*
/frontend/loginAuth.json
/backend/vendor
+135
View File
@@ -2,6 +2,141 @@
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.3.2-beta
**Security**:
- Patched Username Enumeration via Authentication Timing Side-Channel GHSA-7789-65hx-f26w
**New Features**:
- Option in settings `userDefaults.preferEditorForMarkdown` to prefer editor first for Markdown files (#2136)
- Copy to clipboard button for code blocks in Markdown Viewer (#2160)
- Add "Last modified" filter in search dialog (#2157)
**Notes**:
- docs preview for text and pdf has a 2 second timeout. If it hangs for whatever reason, the maximum time would be 2 seconds. (#2105) (#2114)
- Downloading multiple files streams the archive creation rather than using cacheDir -- thanks @janakoram (#2125) (#2130)
- `server.maxArchiveSizeGB` now defaults to 20 (GB) and only applies to archive/unarchive actions (not downloads).
- browser download progress bar will no longer show for archive downloads. this is the main drawback to the streaming approach.
- should allow for much higher parallel download support and lower cleanup maintenenance.
- [docker] ffmpeg version upgraded to 8.1
- remote ip in logs now prefers `X-Forwarded-For` if it exists, then `X-Real-IP`, then lastly the standard RemoteAddr. Useful when running behind a proxy to log the public IP of each request. (#2110)
- changed loading spinner style to be more compatible with safari browsers.
**BugFixes**:
- Wrong username in share settings (#2147) (#2148)
- [OnlyOffice] Error when saving a file under a user scope #2133
- Cannot edit shared file in OnlyOffice #2143
- PWA install button disappeared (#2086)
- Deleting a root folder was possible #2128
- PUT resource api errors if action against a folder (#2153)
- LDAP authentication issue if a password caontains @ symbol (#2154)
- Share banner seems to be not working for custom urls (#2120)
## v1.3.1-beta
**Security**:
- Patched Stored XSS in public share page via unsanitized share metadata (text/template misuse) GHSA-r633-fcgp-m532
- Patched Incomplete Remediation of CVE-2026-27611: Password-Protected Share Bypass via /public/api/share/info GHSA-525j-95gf-766f
**New Features**:
- More user options for settings (#2072) (#2067):
- Option to disable thumbnails `userDefaults.preview.audio` and `userDefaults.preview.models` for Audio and 3D Models.
- Option to disable files in the Tree navigation `userDefaults.hideFilesInTree`
- Option to disable source files deletion when creating/extracting archives. `userDefaults.deleteAfterArchive`
**Notes**:
- Share icon does not show in share listing or for shares for other users.
- File Size Analyzer tool max items increased from 100 to 200.
- changed symlink detection logic.
**BugFixes**:
- 405 response code error on Webdav in 1.3.0-beta (#2054)
- Motion Preview setting not saving when changed in profile settings.
- Context menu on tools not working.
## v1.3.0-beta
Note: a potentially breaking change for docker users: \the default user is now "filebrowser" 1000:1000 instead of root.
**New Features**:
- New Sidebar Features
- Sidebar tree navigation (#2006) (#350)
- Source usage to be customized to show os-reported values rather than calculated. This can be changed per source by editing the source link in the sidebar. (#1266) (#982)
- Archive/Unarchive actions in UI (#1252) (#335) (#1569)
- new api to archive/unarchive files on the server
- requires `create` user permissions
- archiving actions respect `server.maxArchiveSize`
- Added share icon to items that are shared (#1420)
- Authentication enhancements
- LDAP login support with OIDC feature parity. (#591)
- userGroup for OIDC and LDAP, only users in a group will get access. (#1964)
- Add JWT token authentication support (#1364)
- Enhanced thumbnail and item previews
- Added ability to show "motion preview" for folders with multiple child items that have previews. cycles through the first 4 images.
- support for reading embedded images from raw image or heic/heif files (#215)
- reorganized and simplified thumbnail settings in profile settings (#1968)
- removed `highQuality` thumbnail option which only affected gallery view. Now its always enabled.
- improved caching for unsupported images, the same file won't be attempted again with the same modtime.
- supports 3d model previews.
- FileWatcher also supports watching directories
- Support previews for 3D model files (STL, OBJ, 3MF, etc.) (#1273)
- supported formats via threejs: GLTF, GLB, OBJ, STL, PLY, DAE (Collada), 3MF, 3DS, USDZ, USD, USDA, USDC, AMF, VRML, WRL, VTK, VTP, PCD, XYZ, VOX, KMZ
- supports animations (for formats that contain them)
- supports embedded textures, external neighboring file textures, or textures in `/textures` subdirectory
- Enhanced prompts
- All prompts have a taskbar with a close button
- Prompts can be freely moved by dragging taskbar
- Prompt styling has been updated
- Clicking outside of prompts no longer automatically closes them.
- add webdav support (#209) -- thanks to @reddac for (#1764)
- see docs on how to use
- requires api an un-customized api token as password
- respects access rules
- requires download permission to view and modify/create/delete permission to modify.
**Notes**:
- Docker images default to `filebrowser` user instead of root
- reorganized api routes
- consolidated tags for swagger to be more accurately grouped
- tools are all behind `/api/tools` routes
- `/api/raw` is deprecated (but functional). The `/api/resources/download` route will be used instead.
- `/api/preview` has been removed and replaced with `/api/resources/preview`
- `/api/onlyoffice` have been replaced with `/api/office`
- `/api/shares` has been moved to `/api/share/list`
- `/api/auth/tokens` has been moved to `/api/auth/token/list` and `/api/auth/token` added to get specific token info
- `PUT /api/token` has been moved to `POST /api/token`
- `/public/api/shareinfo` has been moved to `/public/api/share/info`
- `POST /resources/bulk/delete` api has been moved to `DELETE /resources/bulk` (#1984)
**BugFixes**:
- Long folder names get cut off at top navigation bar (#1934)
## v1.2.2-stable
**Security**:
- Patched Stored XSS in public share page via unsanitized share metadata (text/template misuse)
**New Features**:
- "Divider" option in sidebar links to add a text or divider between links ()
- shares offer a "go to source Location" sidebar link and button when editing a share.
**Notes**:
- Share edit/delete permissions are scoped to the user's shares rather than global (#2050)
**BugFixes**:
- fixed the requirement that the database path needed to be set in the config file, now it loads `FILEBROWSER_DATABASE` value by default, fallback to config file property.
- Error downloading zipped directory: no such file or directory (users with scope) #2015
## v1.2.1-stable
**BugFixes**:
- startup icon generation order issue (#2019)
- login button on shares not showing (#2028)
- removed file watcher link in context menu for shares (#2025)
- Direct download link for single-file shares generates incorrect URL (#2026)
- Deleting files causes error V1.2.0 #2039
## v1.2.7-beta
**BugFixes**:
+32 -44
View File
@@ -17,7 +17,7 @@
## Pinned
:loudspeaker: [What's Coming Soon](https://github.com/gtsteffaniak/filebrowser/discussions/1622)
:loudspeaker: [New Announcement](https://github.com/gtsteffaniak/filebrowser/discussions/2048)
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
@@ -25,7 +25,7 @@
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.
This version is called "Quantum" because it packs tons of advanced features into a tiny and 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.
@@ -35,20 +35,20 @@ Ready to try it out? See [Getting Started Docs](https://filebrowserquantum.com/e
FileBrowser Quantum is a massive fork of the file browser open-source project with the following changes:
1. ✅ Add and configure multiple sources
2. ✅ Login support for OIDC, password + 2FA, and proxy.
1. ✅ Better source configuration - multiple sources, include/exclude rules, and [more](https://filebrowserquantum.com/en/docs/configuration/sources/)
2. ✅ Login support for OIDC, LDAP, JWT, password + 2FA, and proxy.
3. ✅ Beautiful, Responsive, and Customizable user interface.
4. ✅ Streamlined configuration via `config.yaml` config file.
5. ✅ Ultra-efficient [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) and real-time updates
5. ✅ Ultra-efficient Sqlite [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing)
- 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
- Better thumbnail support including **office**, **video**, and **album artwork**
- Better thumbnail support including **office**, **video**, and **album artwork**, and **3D models**.
- Faster and more responsive views with animations.
- Folder sizes are displayed and support for thumbnails
- Folder sizes are displayed and support thumbnails
- Navigating remembers the last scroll position.
7. ✅ Highly configurable and customizable sharing options
7. ✅ Highly configurable and [customizable sharing options](https://filebrowserquantum.com/en/docs/shares/options/)
- share expiration time
- users who can access share (including anonymous)
- styling and themes
@@ -59,20 +59,10 @@ FileBrowser Quantum is a massive fork of the file browser open-source project wi
- A helpful Swagger page is available at `/swagger` endpoint for API enabled users.
Notable features that this fork *does not* have (removed):
- :construction: jobs are not supported yet.
- ❌ shell commands are completely removed and will not be returned.
FileBrowser Quantum differs significantly from the original version. Many of these changes required a significant overhaul. Creating a fork was a necessary process to make the program better. There have been many growing pains, but a stable release is planned and coming soon.
## System Requirements
> [!WARNING]
> Every file and directory in the source gets indexed (by default). This enables powerful features such as instant search, but large source filesystems can increase your system requirements. [See indexing wiki](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) for more info.
- **Memory**: depends on configured source complexity. See [how much RAM does it require?](https://github.com/gtsteffaniak/filebrowser/discussions/787)
- **GPU**: Not currently used (planned)
## The UI
The UI has a simple three-component navigation system:
@@ -104,47 +94,45 @@ Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
webdav support | :warning:* | ❌ | ✅ | ✅ | ❌ | ✅ |
webdav support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
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 | 512 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
has standalone binary | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ |
has standalone binary | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
price | free | free | free | free tier | free tier | $99+ |
rich media preview | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Upload files from the web? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Advanced Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
Indexed Search? | ✅ | ❌ | ❌ | configurable | ✅ | ✅ |
Content-aware search? | ❌ | ❌ | ❌ | configurable | ✅ | ✅ |
Custom job support | :construction: | ✅ | ❌ | ✅ | ❌ | ✅ |
Advanced Search? | ✅ | ❌ | ✅ | configurable | ✅ | ✅ |
Indexed Search? | ✅ | ❌ | ✅ | configurable | ✅ | ✅ |
Content-aware search? | ❌ | ❌ | ✅ | configurable | ✅ | ✅ |
Custom job support | :construction: | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Single sign-on support | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
LDAP sign-on support | :construction: | ❌ | ❌ | ✅ | ❌ | ✅ |
Single sign-on support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
LDAP sign-on support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Long-live API key support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
API documentation page | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Mobile App | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
Mobile App | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ |
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
tags support | :construction: | ❌ | ❌ | ✅ | ❌ | ✅ |
tags support | :construction: | ❌ | ✅ | ✅ | ❌ | ✅ |
shareable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Event-based notifications | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Metrics | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
file space quotas | :construction: | ❌ | ❌ | ❌ | ✅ | ✅ |
Event-based notifications | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
Metrics | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
file space quotas | :construction: | ❌ | ✅ | ❌ | ✅ | ✅ |
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file previews | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
Themes | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
Branding support | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ |
activity log | :construction: | ❌ | ❌ | ✅ | ✅ | ✅ |
Comments support | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
trash support | :construction: | ❌ | ❌ | ✅ | ✅ | ✅ |
Starred/pinned files | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
Themes | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Branding support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
activity log | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
Comments support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
trash support | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
Starred/pinned files | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ |
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
Share collections of files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can archive selected files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can browse archive files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert documents | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can convert videos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
Can convert photos | :construction: | ❌ | ❌ | ❌ | ❌ | ❌ |
*Accessing files from FileBrowser via WebDav is a newly introduced and experimental feature.
Can archive selected files | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can browse archive files | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
Can convert documents | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
Can convert videos | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
Can convert photos | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
+1 -2
View File
@@ -1,4 +1,4 @@
FROM gtstef/ffmpeg:8.0.1-decode AS ffmpeg
FROM gtstef/ffmpeg:8.1-decode AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
@@ -43,7 +43,6 @@ RUN [ "filebrowser", "version" ]
RUN [ "ffmpeg", "-version" ]
RUN [ "ffprobe", "-version" ]
RUN [ "exiftool", "-ver" ]
USER root
# exposing default port for auto discovery.
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
+2 -1
View File
@@ -1,4 +1,5 @@
FROM node:22-slim
WORKDIR /app/frontend
RUN npm i @playwright/test oauth2-mock-server @types/node
RUN npx playwright install --with-deps firefox
RUN npx playwright install --with-deps chromium
+3
View File
@@ -14,6 +14,9 @@ COPY [ "./backend/reduce-rounded-corners.css", "./no-rounded.css" ]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# generate duplicate 1.1MB files
RUN dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1MB.bin bs=1024 count=1126 && dd if=/dev/zero of=/app/frontend/tests/playwright-files/1.1mb.bin bs=1024 count=1126
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser set -u testuser1,testuser1
+14
View File
@@ -0,0 +1,14 @@
FROM ghcr.io/gtsteffaniak/playwright-base:latest
WORKDIR /app
COPY [ "./_docker/src/jwt/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
RUN apt update && apt install nginx -y
RUN mv default.conf /etc/nginx/conf.d/default.conf
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
+14
View File
@@ -0,0 +1,14 @@
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
WORKDIR /app
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
COPY [ "./_docker/src/screenshots/backend/*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
ENTRYPOINT [ "./filebrowser" ]
+17
View File
@@ -0,0 +1,17 @@
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
WORKDIR /app
# Copy setup scripts first to leverage caching
COPY [ "./_docker/src/previews/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+15
View File
@@ -0,0 +1,15 @@
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
WORKDIR /app
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
COPY [ "./_docker/src/screenshots/backend/*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
-1
View File
@@ -35,7 +35,6 @@ 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" ]
USER root
# exposing default port for auto discovery.
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
+35 -1
View File
@@ -1,4 +1,19 @@
services:
local-playwright:
container_name: local-playwright
ports:
- "8080:8080"
build:
dockerfile: ./_docker/Dockerfile.playwright-local
context: ../
local-playwright-screenshots:
container_name: local-playwright
volumes:
- ../frontend/generated:/app/frontend/generated
entrypoint: ["/bin/sh", "-c", "./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots && npx playwright test --project light-screenshots"]
build:
dockerfile: ./_docker/Dockerfile.playwright-local
context: ../
nginx-proxy-auth:
image: nginx
container_name: nginx-proxy-auth
@@ -7,11 +22,30 @@ services:
volumes:
- ./src/proxy/backend/default.conf:/etc/nginx/conf.d/default.conf
- ./src/proxy/backend/.htpasswd:/etc/nginx/conf.d/.htpasswd
nginx-proxy-jwt:
image: nginx
container_name: nginx-proxy-jwt
ports:
- "8081:8081" # Admin user
- "8082:8082" # Regular user
- "8083:8083" # Wrong key
- "8084:8084" # No user
volumes:
- ./src/jwt/backend/default.conf:/etc/nginx/conf.d/default.conf
- ./src/jwt/backend/.htpasswd:/etc/nginx/conf.d/.htpasswd
filebrowser-jwt:
hostname: filebrowser
volumes:
- '../frontend:/home/frontend'
- "./src/jwt/backend/config.yaml:/home/filebrowser/data/config.yaml"
build:
context: ../
dockerfile: ./_docker/Dockerfile.slim
filebrowser:
hostname: filebrowser
volumes:
- '../frontend:/home/frontend'
- "./src/proxy/backend/config.yaml:/home/filebrowser/config.yaml"
- "./src/proxy/backend/config.yaml:/home/filebrowser/data/config.yaml"
build:
context: ../
dockerfile: ./_docker/Dockerfile.slim
+3 -1
View File
@@ -8,8 +8,10 @@ userDefaults:
image: true
popup: true
video: true
audio: true
models: true
office: true
highQuality: true
permissions:
download: true
create: true
create: true
+48
View File
@@ -0,0 +1,48 @@
server:
port: 8080
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
config:
defaultEnabled: true
createUserDir: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
styling:
disableEventThemes: true
auth:
methods:
password:
enabled: false
jwt:
enabled: true
secret: "test-jwt-secret-key-for-development-only-do-not-use-in-production"
algorithm: "HS256"
header: "X-JWT-Assertion"
userIdentifier: "sub"
groupsClaim: "mygroup"
adminGroup: "admin"
userDefaults:
preview:
image: true
popup: true
video: true
office: true
highQuality: true
darkMode: true
disableSettings: false
singleClick: false
permissions:
admin: false
modify: true
share: true
api: false
download: true
create: true
+66
View File
@@ -0,0 +1,66 @@
# Admin user (port 8081)
server {
listen 8081;
server_name localhost 127.0.0.1;
set $admin_token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RhZG1pbkBleGFtcGxlLmNvbSIsImV4cCI6MjA4NzU2NTc2NiwiZ3JvdXBzIjpbImFkbWluIl0sImlhdCI6MTc3MjIwNTc2NiwibmFtZSI6IlRlc3QgQWRtaW4iLCJuYmYiOjE3NzIyMDU3NjYsInN1YiI6InRlc3RhZG1pbiJ9.pxuKGyMmEe94m0xQ7y1QzcUZgU-lZSvqjoKUVoVd3F4";
location / {
proxy_set_header X-JWT-Assertion $admin_token;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://filebrowser:8080;
}
}
# Regular user (port 8082)
server {
listen 8082;
server_name localhost 127.0.0.1;
set $regular_token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJleHAiOjIwODc1NjU2OTIsImdyb3VwcyI6WyJteWdyb3VwIl0sImlhdCI6MTc3MjIwNTY5MiwibmFtZSI6IlRlc3QgVXNlciIsIm5iZiI6MTc3MjIwNTY5Miwic3ViIjoidGVzdHVzZXIifQ.X8fUYbhnZpgvP5CnGdms9eGo8yRLyNhRbj7xUSxEgl8";
location / {
proxy_set_header X-JWT-Assertion $regular_token;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://filebrowser:8080;
}
}
# Valid token but signed with different secret (port 8083)
server {
listen 8083;
server_name localhost 127.0.0.1;
# Valid JWT token structure, but signed with wrong secret - should fail signature verification
set $wrong_key "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RhZG1pbkBleGFtcGxlLmNvbSIsImV4cCI6MjA4NzU2NjAyMywiZ3JvdXBzIjpbImFkbWluIl0sImlhdCI6MTc3MjIwNjAyMywibmFtZSI6IlRlc3QgQWRtaW4iLCJuYmYiOjE3NzIyMDYwMjMsInN1YiI6InRlc3RhZG1pbiJ9.afBobbJ3VQ-fKnUebGr-gxFIqegyICi6E3EgkUSeFNw";
location / {
proxy_set_header X-JWT-Assertion $wrong_key;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://filebrowser:8080;
}
}
# No authentication (port 8084)
server {
listen 8084;
server_name localhost 127.0.0.1;
location / {
proxy_set_header X-JWT-Assertion "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://filebrowser:8080;
}
}
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
// Simple JWT generator without external dependencies
func main() {
// Secret key - must match the one in your config file
secret := "wrong-key"
// Check if custom secret provided via command line
if len(os.Args) > 1 {
secret = os.Args[1]
}
// Create token claims with 10 year expiration
now := time.Now()
expiresAt := now.Add(10 * 365 * 24 * time.Hour) // 10 years
header := map[string]interface{}{
"alg": "HS256",
"typ": "JWT",
}
claims := map[string]interface{}{
"sub": "testadmin", // username (subject)
"email": "testadmin@example.com", // email claim
"name": "Test Admin", // full name
"groups": []string{"admin"}, // groups - includes admin for testing
"iat": now.Unix(), // issued at
"exp": expiresAt.Unix(), // expires at
"nbf": now.Unix(), // not before
}
// Encode header
headerJSON, err := json.Marshal(header)
if err != nil {
fmt.Fprintf(os.Stderr, "Error encoding header: %v\n", err)
os.Exit(1)
}
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
// Encode claims
claimsJSON, err := json.Marshal(claims)
if err != nil {
fmt.Fprintf(os.Stderr, "Error encoding claims: %v\n", err)
os.Exit(1)
}
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
// Create signature
message := headerB64 + "." + claimsB64
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(message))
signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
// Combine to form complete JWT
tokenString := message + "." + signature
// Output the token
fmt.Println("=== JWT Token Generated ===")
fmt.Println()
fmt.Println("Token:")
fmt.Println(tokenString)
fmt.Println()
// Verify token can be decoded
parts := strings.Split(tokenString, ".")
if len(parts) == 3 {
fmt.Println("✓ Token structure is valid (3 parts)")
} else {
fmt.Fprintf(os.Stderr, "✗ Invalid token structure\n")
os.Exit(1)
}
// Save to file for easy access
filename := "test-jwt-token.txt"
if err := os.WriteFile(filename, []byte(tokenString), 0644); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Could not write token to file: %v\n", err)
} else {
fmt.Printf("✓ Token saved to: %s\n", filename)
fmt.Println()
}
// Also save to shell script for easy sourcing
scriptContent := fmt.Sprintf("#!/bin/bash\n# Source this file to set JWT token as environment variable\n# Usage: source jwt-env.sh\nexport TEST_JWT_TOKEN=\"%s\"\necho \"JWT token loaded into TEST_JWT_TOKEN\"\n", tokenString)
if err := os.WriteFile("jwt-env.sh", []byte(scriptContent), 0755); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Could not write shell script: %v\n", err)
} else {
fmt.Println("✓ Shell script saved to: jwt-env.sh")
fmt.Println(" Usage: source jwt-env.sh")
fmt.Println()
}
}
+3
View File
@@ -0,0 +1,3 @@
module test
go 1.25.6
@@ -0,0 +1,66 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/playwright/jwt-setup.ts",
timeout: 5000,
testDir: "./tests/playwright/jwt",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: false,
/* Retry on CI only */
retries: 2,
/* Opt out of parallel tests on CI. */
workers: 1, // required for now! todo parallel some tests
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
actionTimeout: 5000,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for different JWT test scenarios */
projects: [
{
name: "admin-user",
use: {
...devices["Desktop Firefox"],
baseURL: "http://127.0.0.1:8081", // Admin user JWT
},
},
{
name: "regular-user",
use: {
...devices["Desktop Firefox"],
baseURL: "http://127.0.0.1:8082", // Regular user JWT
},
},
{
name: "wrong-key",
use: {
...devices["Desktop Firefox"],
baseURL: "http://127.0.0.1:8083", // Wrong secret key
},
},
{
name: "no-user",
use: {
...devices["Desktop Firefox"],
baseURL: "http://127.0.0.1:8084", // No JWT token
},
},
],
});
+35
View File
@@ -0,0 +1,35 @@
server:
port: 80
baseURL: "/"
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
auth:
methods:
noauth: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
styling:
disableEventThemes: true
userDefaults:
showHidden: true
preview:
image: true
popup: true
video: true
audio: true
office: true
models: true
highQuality: true
permissions:
download: true
create: true
@@ -0,0 +1,54 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/playwright/previews-setup",
timeout: 10000,
testDir: "./tests/playwright/previews",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: false,
/* Retry on CI only */
retries: 2,
workers: 10,
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
launchOptions: {
args: [
'--disable-gpu',
'--use-gl=swiftshader', // CPU-based WebGL renderer built into Chromium
'--enable-webgl',
'--disable-dev-shm-usage', // Overcome limited resource problems in Docker
'--no-sandbox', // Required for Docker
],
},
storageState: "previews.json",
actionTimeout: 5000,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
@@ -11,14 +11,14 @@ import { defineConfig, devices } from "@playwright/test";
*/
export default defineConfig({
globalSetup: "./tests/playwright/proxy-setup.ts",
timeout: 5000,
timeout: 10000,
testDir: "./tests/playwright/proxy",
/* 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: 3,
/* Opt out of parallel tests on CI. */
workers: 1, // required for now! todo parallel some tests
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
@@ -0,0 +1,10 @@
server:
port: 8080
baseURL: "/files/"
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright"
config:
defaultEnabled: true
@@ -0,0 +1,69 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/playwright/screenshots-setup.ts",
timeout: 5000,
testDir: "./tests/playwright/screenshots",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: false,
/* Retry on CI only */
retries: 2,
/* Opt out of parallel tests on CI. */
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
launchOptions: {
args: [
'--disable-gpu',
'--use-gl=swiftshader', // CPU-based WebGL renderer built into Chromium
'--enable-webgl',
'--disable-dev-shm-usage', // Overcome limited resource problems in Docker
'--no-sandbox', // Required for Docker
],
},
actionTimeout: 5000,
storageState: "loginAuth.json",
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:8080",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for major browsers */
projects: [
{
name: "dark-screenshots",
use: {
...devices["Desktop Chrome"],
theme: 'dark',
},
testMatch: /.*screenshots.spec.ts/,
retries: 0,
},
{
name: "light-screenshots",
use: {
...devices["Desktop Chrome"],
theme: 'light',
},
testMatch: /.*screenshots.spec.ts/,
retries: 0,
},
],
});
+217
View File
@@ -1,10 +1,12 @@
package files
import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
@@ -409,3 +411,218 @@ func TestOverrideFileToDirectory(t *testing.T) {
t.Error("Directory 'Test Object' should have been added to Folders")
}
}
// TestDeleteFilesRootProtection tests that DeleteFiles refuses to delete
// the source root directory itself, preventing catastrophic data loss.
// This is a regression test for the safety guard added to prevent accidental
// deletion of the entire source directory.
func TestDeleteFilesRootProtection(t *testing.T) {
// Use a temporary directory for cache
tmpDir := t.TempDir()
originalCacheDir := settings.Config.Server.CacheDir
settings.Config.Server.CacheDir = tmpDir
defer func() {
settings.Config.Server.CacheDir = originalCacheDir
}()
// Ensure fileutils permissions are set
if fileutils.PermDir == 0 {
fileutils.SetFsPermissions(0644, 0755)
}
// Initialize the database
if indexing.GetIndexDB() == nil {
db, _, err := dbsql.NewIndexDB("test_root_protection", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
indexing.SetIndexDBForTesting(db)
}
// Create a real temporary directory to use as source root
realTmpDir := t.TempDir()
// Initialize the index with a real path
indexing.Initialize(&settings.Source{
Name: "test_root_protection",
Path: realTmpDir,
}, false, false) // false for mock mode (real path), false for isNewDb
// Wait for scanner to be ready
idx := indexing.GetIndex("test_root_protection")
if idx == nil {
t.Fatal("Failed to get test index")
}
// Wait for scanner to finish
for i := 0; i < 50; i++ {
time.Sleep(10 * time.Millisecond)
status := idx.GetScannerStatus()
if status["status"] == "ready" || status["status"] == "unavailable" {
break
}
}
// Test: Attempting to delete the root directory should return an error
err := DeleteFiles("test_root_protection", realTmpDir, true)
if err == nil {
t.Error("DeleteFiles should return an error when trying to delete root directory")
}
// Verify the error message contains expected text
expectedErrMsg := "refusing to delete source root directory"
if err != nil && !strings.Contains(err.Error(), expectedErrMsg) {
t.Errorf("Expected error message to contain '%s', got: %v", expectedErrMsg, err.Error())
}
// Test with trailing slash - should still be blocked
err = DeleteFiles("test_root_protection", realTmpDir+"/", true)
if err == nil {
t.Error("DeleteFiles should return an error for root directory with trailing slash")
}
// Test with cleaned path variations
err = DeleteFiles("test_root_protection", realTmpDir+"/.", true)
if err == nil {
t.Error("DeleteFiles should return an error for root directory with /. suffix")
}
}
// TestDeleteFilesSubfolderWithRootName tests that deleting a subfolder
// with the same name as the root directory works correctly and doesn't
// accidentally delete the root.
// Regression test for path handling bug where /srv/srv could be mistaken for /srv.
func TestDeleteFilesSubfolderWithRootName(t *testing.T) {
for iter := 1; iter <= raceStressIterations; iter++ {
iter := iter
t.Run(fmt.Sprintf("stress-iter-%d", iter), func(t *testing.T) {
runDeleteFilesSubfolderWithRaceStress(t, iter)
})
}
}
const (
raceStressIterations = 3
raceStatusPollInterval = 5 * time.Millisecond
waitStatusMaxTries = 100
waitStatusSleep = 50 * time.Millisecond
)
func runDeleteFilesSubfolderWithRaceStress(t *testing.T, iter int) {
t.Helper()
start := time.Now()
t.Logf("iteration %d: start", iter)
cacheDir := t.TempDir()
originalCache := settings.Config.Server.CacheDir
settings.Config.Server.CacheDir = cacheDir
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
if fileutils.PermDir == 0 {
fileutils.SetFsPermissions(0644, 0755)
}
if indexing.GetIndexDB() == nil {
db, _, err := dbsql.NewIndexDB("test_subfolder_rootname", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
indexing.SetIndexDBForTesting(db)
}
realTmpDir := t.TempDir()
subfolderName := filepath.Base(realTmpDir)
subfolderPath := filepath.Join(realTmpDir, subfolderName)
if err := os.Mkdir(subfolderPath, 0755); err != nil {
t.Fatalf("Failed to create subfolder: %v", err)
}
testFile := filepath.Join(subfolderPath, "test.txt")
if err := os.WriteFile(testFile, []byte("test content"), 0644); err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
sourceName := fmt.Sprintf("test_subfolder_rootname-%d", iter)
indexing.Initialize(&settings.Source{
Name: sourceName,
Path: realTmpDir,
}, false, false)
idx := indexing.GetIndex(sourceName)
if idx == nil {
t.Fatalf("Failed to get test index for %s", sourceName)
}
stopPolling := startScannerStatusPoll(t, idx, iter)
defer stopPolling()
waitForScannerReady(t, idx)
err := DeleteFiles(sourceName, subfolderPath, true)
stopPolling()
t.Logf("iteration %d: DeleteFiles finished in %s (err=%v)", iter, time.Since(start), err)
if err != nil {
t.Fatalf("DeleteFiles should succeed for subfolder with root-like name, got error: %v", err)
}
if _, err := os.Stat(subfolderPath); !os.IsNotExist(err) {
t.Fatal("Subfolder should have been deleted")
}
if _, err := os.Stat(realTmpDir); os.IsNotExist(err) {
t.Fatal("Root directory should NOT have been deleted")
}
indexing.StopAllScanners()
// Give scanners time to finish their cleanup (defer blocks in tryAcquireAndScan)
time.Sleep(100 * time.Millisecond)
indexing.ClearTestIndices()
t.Logf("iteration %d: cleaned scanners and indices", iter)
}
func waitForScannerReady(t *testing.T, idx *indexing.Index) {
t.Helper()
// Give the scanners a moment to initialize, but don't wait for full "ready" status
// The initial scan can take longer than the test timeout, but DeleteFiles will work
// as long as the scanners are initialized and running
time.Sleep(500 * time.Millisecond)
// Verify scanners exist
status := idx.GetScannerStatus()
if totalScanners, ok := status["totalScanners"].(int); !ok || totalScanners == 0 {
t.Fatal("No scanners were created")
}
t.Log("Scanners initialized, proceeding with delete operation")
}
func startScannerStatusPoll(t *testing.T, idx *indexing.Index, iter int) func() {
t.Helper()
stop := make(chan struct{})
done := make(chan struct{})
var once sync.Once
go func() {
ticker := time.NewTicker(raceStatusPollInterval)
defer ticker.Stop()
for {
select {
case <-stop:
close(done)
return
case <-ticker.C:
idx.GetScannerStatus()
}
}
}()
return func() {
once.Do(func() {
close(stop)
<-done
t.Logf("iteration %d: poll watcher stopped", iter)
})
}
}
+112 -22
View File
@@ -25,6 +25,9 @@ import (
"github.com/gtsteffaniak/go-logger/logger"
)
// CheckPermissionsFunc allows tests to override CheckPermissions behavior
var CheckPermissionsFunc = checkPermissionsImpl
// processDirectoryMetadata extracts metadata for audio/video files in directories
func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts utils.FileOptions) {
metadataCount := 0
@@ -116,35 +119,41 @@ func finalizeResponse(response *iteminfo.ExtendedFileInfo, info *iteminfo.FileIn
}
}
func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
response := &iteminfo.ExtendedFileInfo{}
// CheckPermissions validates user access and returns the resolved index path and user scope
func CheckPermissions(opts utils.FileOptions, access *access.Storage, user *users.User) (string, string, error) {
return CheckPermissionsFunc(opts, access, user)
}
// checkPermissionsImpl is the actual implementation of CheckPermissions
func checkPermissionsImpl(opts utils.FileOptions, access *access.Storage, user *users.User) (string, string, error) {
if access == nil {
return response, fmt.Errorf("access not provided")
return "", "", fmt.Errorf("access not provided")
}
if user == nil {
return response, fmt.Errorf("user not provided")
return "", "", fmt.Errorf("user not provided")
}
if opts.Path == "" {
return response, fmt.Errorf("path not provided")
return "", "", fmt.Errorf("path not provided")
}
if opts.Source == "" {
return response, fmt.Errorf("source not provided")
return "", "", fmt.Errorf("source not provided")
}
// Get index
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return response, fmt.Errorf("could not get index: %v ", opts.Source)
return "", "", fmt.Errorf("could not get index: %v ", opts.Source)
}
// Resolve user scope
userScope, scopeErr := user.GetScopeForSourcePath(idx.Path)
if scopeErr != nil || userScope == "" {
return response, fmt.Errorf("user has no access to source: %v", opts.Source)
return "", "", fmt.Errorf("user has no access to source: %v", opts.Source)
}
safePath, err := utils.SanitizeUserPath(opts.Path)
if err != nil {
return response, errors.ErrAccessDenied
return "", "", errors.ErrAccessDenied
}
// Combine scope + sanitized path
@@ -152,17 +161,84 @@ func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.
// Layer 1: USER ACCESS CONTROL
// Quick check: Does THIS user have permission?
if !access.Permitted(idx.Path, indexPath, user.Username) {
return response, errors.ErrAccessDenied
return "", "", errors.ErrAccessDenied
}
return indexPath, userScope, nil
}
type Items struct {
Files []string `json:"files,omitempty"`
Folders []string `json:"folders,omitempty"`
}
func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.User) (Items, error) {
items := Items{}
indexPath, _, err := CheckPermissions(opts, access, user)
if err != nil {
return items, err
}
// Get index
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return items, fmt.Errorf("could not get index: %v ", opts.Source)
}
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
ShowHidden: opts.ShowHidden,
Expand: true,
SkipExtendedAttrs: true,
})
if err != nil {
return items, err // Path excluded by index rules OR doesn't exist
}
if info.Type != "directory" {
return items, fmt.Errorf("path is not a directory: %v ", indexPath)
}
if err := access.CheckChildItemAccess(info, idx, user.Username); err != nil {
return items, err
}
if opts.Only == "files" || opts.Only == "" {
for _, file := range info.Files {
items.Files = append(items.Files, file.Name)
}
}
if opts.Only == "folders" || opts.Only == "" {
for _, folder := range info.Folders {
items.Folders = append(items.Folders, folder.Name)
}
}
return items, nil
}
// FileInfoFasterFunc is a variable that can be mocked in tests
var FileInfoFasterFunc = fileInfoFasterImpl
func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
return FileInfoFasterFunc(opts, access, user, share)
}
// fileInfoFasterImpl is the actual implementation of FileInfoFaster
func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
response := &iteminfo.ExtendedFileInfo{}
indexPath, userScope, err := CheckPermissions(opts, access, user)
if err != nil {
return response, err
}
// Get index
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return response, fmt.Errorf("could not get index: %v ", opts.Source)
}
// Layer 2: INDEX RULES (global)
// Get file info using unified entry point (applies IsViewable/ShouldSkip)
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
ShowHidden: opts.ShowHidden,
Expand: opts.Expand,
IsRoutineScan: false, // API call
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
ShowHidden: opts.ShowHidden,
Expand: opts.Expand,
SkipExtendedAttrs: opts.SkipExtendedAttrs,
})
if err != nil {
return response, err // Path excluded by index rules OR doesn't exist
@@ -176,25 +252,34 @@ func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.
// Layer 3: FILTER CHILDREN (user access)
// Remove child items THIS user can't access
if info.Type == "directory" {
if err := access.CheckChildItemAccess(response, idx, user.Username); err != nil {
if err := access.CheckChildItemAccess(info, idx, user.Username); err != nil {
return response, err
}
}
if share != nil && user.Permissions.Share && opts.ShowSharedAttr {
for i := range response.Files {
file := &response.Files[i]
file.IsShared = share.IsShared(response.Path+file.Name, idx.Path, user.ID)
}
for i := range response.Folders {
folder := &response.Folders[i]
folder.IsShared = share.IsShared(response.Path+folder.Name, idx.Path, user.ID)
}
response.IsShared = share.IsShared(response.Path, idx.Path, user.ID)
}
defer finalizeResponse(response, info, response.RealPath, user, userScope)
if opts.SkipExtendedAttrs {
return response, nil
}
// Process directory metadata if requested
if info.Type == "directory" {
processDirectoryMetadata(response, idx, opts)
}
// Process single file content/metadata
isAudioVideo := strings.HasPrefix(info.Type, "audio") || strings.HasPrefix(info.Type, "video")
if opts.Content || opts.Metadata || isAudioVideo {
processContent(response, idx, opts)
}
// Finalize response (OnlyOffice ID, scope stripping)
finalizeResponse(response, info, response.RealPath, user, userScope)
return response, nil
}
@@ -372,6 +457,11 @@ func DeleteFiles(source, absPath string, isDir bool) error {
if index == nil {
return fmt.Errorf("could not get index: %v ", source)
}
// Safety guard: never allow deletion of the source root directory itself
cleanAbs := filepath.Clean(absPath)
if cleanAbs == filepath.Clean(index.Path) {
return fmt.Errorf("refusing to delete source root directory: %s", absPath)
}
if !index.Config.DisableIndexing {
indexPath := index.MakeIndexPath(absPath, isDir)
+61 -6
View File
@@ -149,7 +149,10 @@ var types = map[string]string{
".es": "application/x-esrehber",
".etx": "text/x-setext",
".evy": "application/envoy",
".exe": "application/octet-stream",
".exe": "application/x-msdownload",
".dll": "application/x-msdownload",
".bat": "application/x-msdownload",
".msi": "application/x-msdownload",
".f77": "text/x-fortran",
".f90": "text/x-fortran",
".f": "text/x-fortran",
@@ -243,8 +246,8 @@ var types = map[string]string{
".la": "audio/nspaudio",
".lam": "audio/x-liveaudio",
".latex": "application/x-latex",
".lha": "application/lha",
".lhx": "application/octet-stream",
".lha": "application/x-lzh-compressed",
".lhx": "application/x-lzh-compressed",
".list": ContentTextHeaderValue,
".lma": "audio/nspaudio",
".log": ContentTextHeaderValue,
@@ -257,7 +260,7 @@ var types = map[string]string{
".m1v": "video/mpeg",
".m2a": "audio/mpeg",
".m2v": "video/mpeg",
".m3u": "audio/x-mpegurl",
".m3u": "text/x-mpegurl", // .m3u and .m3u8 are playlist files - They contain paths, we set it as text to allow edition.
".m": "text/x-m",
".man": "application/x-troff-man",
".manifest": "text/cache-manifest",
@@ -375,7 +378,7 @@ var types = map[string]string{
".pre": "application/x-freelance",
".prt": "application/pro_eng",
".ps": "application/postscript",
".psd": "application/octet-stream",
".psd": "image/vnd.adobe.photoshop",
".pvu": "paleovu/x-pv",
".pwz": "application/vndms-powerpoint",
".py": "text/x-scriptphyton",
@@ -597,11 +600,63 @@ var types = map[string]string{
".ogx": "application/ogg",
".m4v": "video/mp4",
".mkv": "video/x-matroska",
".webm": "video/webm",
".weba": "audio/webm",
".caf": "audio/x-caf",
".m3u8": "application/x-mpegURL",
".m3u8": "text/x-mpegURL",
".mpd": "application/dash+xml",
".webp": "image/webp",
".epub": "application/epub+zip",
// the ones below are some from: https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types
".adp": "audio/adpcm",
".mp4a": "audio/mp4",
".mp2a": "audio/mpeg",
".m3a": "audio/mpeg",
".sil": "audio/silk",
".uva": "audio/vnd.dece.audio",
".uvva": "audio/vnd.dece.audio",
".eol": "audio/vnd.digital-winds",
".dra": "audio/vnd.dra",
".dts": "audio/vnd.dts",
".dtshd": "audio/vnd.dts.hd",
".lvp": "audio/vnd.lucent.voice",
".pya": "audio/vnd.ms-playready.media.pya",
".rip": "audio/vnd.rip",
".mka": "audio/x-matroska",
".h261": "video/h261",
".h263": "video/h263",
".h264": "video/h264",
".jpgv": "video/jpeg",
".jpm": "video/jpm",
".jpgm": "video/jpm",
".mj2": "video/mj2",
".mjp2": "video/mj2",
".m2t": "video/mp2t",
".m2ts": "video/mp2t",
".mts": "video/mp2t",
".mp4v": "video/mp4",
".mpg4": "video/mp4",
".uvh": "video/vnd.dece.hd",
".uvvh": "video/vnd.dece.hd",
".uvm": "video/vnd.dece.mobile",
".uvvm": "video/vnd.dece.mobile",
".uvp": "video/vnd.dece.pd",
".uvvp": "video/vnd.dece.pd",
".uvs": "video/vnd.dece.sd",
".uvvs": "video/vnd.dece.sd",
".uvv": "video/vnd.dece.video",
".uvvv": "video/vnd.dece.video",
".dvb": "video/vnd.dvb.file",
".fvt": "video/vnd.fvt",
".mxu": "video/vnd.mpegurl",
".pyv": "video/vnd.ms-playready.media.pyv",
".uvu": "video/vnd.uvvu.mp4",
".uvvu": "video/vnd.uvvu.mp4",
".f4v": "video/x-f4v",
".mk3d": "video/x-matroska",
".mks": "video/x-matroska",
".vob": "video/x-ms-vob",
".smv": "video/x-smv",
}
//nolint:gochecknoinits
@@ -29,3 +29,16 @@ func GetFreeSpace(path string) (uint64, error) {
free := uint64(stat.Bavail) * uint64(stat.Bsize)
return free, nil
}
// GetPartitionUsed returns the used space for Unix systems (total - free)
func GetPartitionUsed(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
total := uint64(stat.Blocks) * uint64(stat.Bsize)
free := uint64(stat.Bavail) * uint64(stat.Bsize)
used := total - free
return used, nil
}
@@ -33,3 +33,18 @@ func GetFreeSpace(path string) (uint64, error) {
}
return freeBytes, nil
}
// GetPartitionUsed returns the used space for Windows systems (total - free)
func GetPartitionUsed(path string) (uint64, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, err
}
var freeBytes, totalBytes, totalFreeBytes uint64
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytes, &totalBytes, &totalFreeBytes)
if err != nil {
return 0, err
}
used := totalBytes - freeBytes
return used, nil
}
+48 -17
View File
@@ -1,34 +1,65 @@
package auth
import (
"fmt"
"net/http"
"sync"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
var (
revokedApiKeyList map[string]struct{}
revokeMu sync.Mutex
)
func init() {
revokedApiKeyList = make(map[string]struct{})
}
// Auther is the authentication interface.
type Auther interface {
// Auth is called to authenticate a request.
Auth(r *http.Request, userStore *users.Storage) (*users.User, error)
}
func IsRevokedApiKey(key string) bool {
_, exists := revokedApiKeyList[key]
return exists
const FB_ISSUER = "FileBrowser Quantum" // don't change
// IsRevokedApiToken checks if a token is in the revoked list.
func IsRevokedApiToken(accessStore *access.Storage, token string) bool {
if accessStore == nil {
return false
}
return accessStore.IsTokenRevoked(token)
}
func RevokeAPIKey(key string) {
revokeMu.Lock()
defer revokeMu.Unlock()
revokedApiKeyList[key] = struct{}{}
// RevokeApiToken adds a token to the revoked list.
func RevokeApiToken(accessStore *access.Storage, token string) error {
if accessStore == nil {
return fmt.Errorf("access storage not available")
}
return accessStore.RevokeToken(token)
}
func MakeSignedTokenAPI(user *users.User, name string, duration time.Duration, perms users.Permissions, minimal bool) (string, users.AuthToken, error) {
if _, ok := user.Tokens[name]; ok {
return "", users.AuthToken{}, fmt.Errorf("key already exists with same name %v ", name)
}
now := time.Now()
expires := now.Add(duration)
// Create minimal token with only JWT standard claims
claim := users.AuthToken{
MinimalAuthToken: users.MinimalAuthToken{
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
Issuer: FB_ISSUER,
},
},
}
if !minimal {
claim.Permissions = perms
claim.BelongsTo = user.ID
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claim)
tokenString, err := token.SignedString([]byte(settings.Config.Auth.Key))
if err != nil {
return "", users.AuthToken{}, err
}
return tokenString, claim, nil
}
+12 -5
View File
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
@@ -42,15 +43,21 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
}
}
user, err := userStore.Get(username)
if err != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
user, getErr := userStore.Get(username)
var passwordHash string
if getErr != nil {
passwordHash = utils.InvalidPasswordHash
} else {
passwordHash = user.Password
}
err = users.CheckPwd(password, user.Password)
// always run checkPwd to prevent timing attacks
err = utils.CheckPwd(password, passwordHash)
if err != nil {
return nil, err
}
if getErr != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
}
// check for OTP for password
if user.TOTPSecret != "" {
if totpCode == "" {
+375
View File
@@ -0,0 +1,375 @@
// Package auth_test contains timing attack tests for authentication.
//
// These tests verify that the JSON authentication implementation is protected
// against timing attacks that could leak information about valid usernames.
//
// Timing Attack Background:
// A timing attack is a side-channel attack where an attacker measures how long
// authentication operations take to determine if a username exists in the system.
// If authentication fails faster for non-existent users than for valid users
// with wrong passwords, an attacker can enumerate valid usernames.
//
// Protection Mechanism:
// The json.go Auth() implementation protects against timing attacks by:
// 1. Always running the bcrypt password comparison, even for non-existent users
// 2. Using utils.InvalidPasswordHash (a pre-computed bcrypt hash) when the user doesn't exist
// 3. Ensuring both valid and invalid user authentication paths take similar time
//
// Test Coverage:
// - TestJSONAuth_NoTimingAttack: Statistical analysis of timing differences
// - TestJSONAuth_InvalidPasswordHashInitialized: Verifies protection is in place
// - TestJSONAuth_ValidUserVsInvalidUserDirectComparison: Direct comparison test
package auth_test
import (
"fmt"
"math"
"net/http"
"net/url"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// mockUserBackend is a simple in-memory user storage for testing.
type mockUserBackend struct {
usersByUsername map[string]*users.User
usersByID map[uint]*users.User
}
func newMockUserBackend() *mockUserBackend {
return &mockUserBackend{
usersByUsername: make(map[string]*users.User),
usersByID: make(map[uint]*users.User),
}
}
func (m *mockUserBackend) GetBy(id interface{}) (*users.User, error) {
switch v := id.(type) {
case string:
if user, ok := m.usersByUsername[v]; ok {
return user, nil
}
return nil, fmt.Errorf("user not found: %s", v)
case uint:
if user, ok := m.usersByID[v]; ok {
return user, nil
}
return nil, fmt.Errorf("user not found: %d", v)
default:
return nil, fmt.Errorf("invalid id type")
}
}
func (m *mockUserBackend) Gets() ([]*users.User, error) {
result := make([]*users.User, 0, len(m.usersByID))
for _, user := range m.usersByID {
result = append(result, user)
}
return result, nil
}
func (m *mockUserBackend) Save(u *users.User, changePass bool, disableScopeChange bool) error {
m.usersByUsername[u.Username] = u
m.usersByID[u.ID] = u
return nil
}
func (m *mockUserBackend) Update(u *users.User, adminActor bool, fields ...string) error {
m.usersByUsername[u.Username] = u
m.usersByID[u.ID] = u
return nil
}
func (m *mockUserBackend) DeleteByID(id uint) error {
if user, ok := m.usersByID[id]; ok {
delete(m.usersByUsername, user.Username)
delete(m.usersByID, id)
return nil
}
return fmt.Errorf("user not found: %d", id)
}
func (m *mockUserBackend) DeleteByUsername(username string) error {
if user, ok := m.usersByUsername[username]; ok {
delete(m.usersByID, user.ID)
delete(m.usersByUsername, username)
return nil
}
return fmt.Errorf("user not found: %s", username)
}
// setupTestUsers creates a user storage with 2 valid test users
func setupTestUsers(t *testing.T) *users.Storage {
t.Helper()
// Reduce bcrypt cost for faster tests (default is 10, min is 4)
// This significantly speeds up password hashing during tests
originalCost := utils.BcryptCost
utils.BcryptCost = 4 // Minimum bcrypt cost for testing
t.Cleanup(func() {
utils.BcryptCost = originalCost // Restore original cost after test
})
// Initialize InvalidPasswordHash to prevent timing attacks
// This simulates the application startup initialization
err := utils.SetInvalidPasswordHash()
if err != nil {
t.Fatalf("Failed to set invalid password hash: %v", err)
}
backend := newMockUserBackend()
storage := users.NewStorage(backend)
// Create two valid users with hashed passwords
password1Hash, err := utils.HashPwd("password123")
if err != nil {
t.Fatalf("Failed to hash password: %v", err)
}
password2Hash, err := utils.HashPwd("securepass456")
if err != nil {
t.Fatalf("Failed to hash password: %v", err)
}
user1 := &users.User{
ID: 1,
Username: "admin",
NonAdminEditable: users.NonAdminEditable{
Password: password1Hash,
},
LoginMethod: users.LoginMethodPassword,
}
user2 := &users.User{
ID: 2,
Username: "testuser",
NonAdminEditable: users.NonAdminEditable{
Password: password2Hash,
},
LoginMethod: users.LoginMethodPassword,
}
err = storage.Save(user1, false, false)
if err != nil {
t.Fatalf("Failed to save user1: %v", err)
}
err = storage.Save(user2, false, false)
if err != nil {
t.Fatalf("Failed to save user2: %v", err)
}
return storage
}
// measureAuthTime measures the time taken to authenticate with given credentials
func measureAuthTime(t *testing.T, auther auth.JSONAuth, userStore *users.Storage, username, password string) time.Duration {
t.Helper()
// Create a mock HTTP request
req, err := http.NewRequest("POST", "/api/auth/login?username="+url.QueryEscape(username), nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
req.Header.Set("X-Password", url.QueryEscape(password))
start := time.Now()
_, _ = auther.Auth(req, userStore)
elapsed := time.Since(start)
return elapsed
}
// TestJSONAuth_NoTimingAttack tests that authentication timing does not leak user existence
func TestJSONAuth_NoTimingAttack(t *testing.T) {
storage := setupTestUsers(t)
auther := auth.JSONAuth{}
// Test cases: mix of valid and invalid usernames
testCases := []struct {
username string
isValid bool
}{
{"admin", true},
{"testuser", true},
{"nonexistent1", false},
{"invaliduser", false},
{"notauser", false},
{"fakeadmin", false},
}
// Number of samples per test case for statistical analysis
samplesPerCase := 5
// Collect timing measurements
measurements := make(map[string][]time.Duration)
for _, tc := range testCases {
measurements[tc.username] = make([]time.Duration, 0, samplesPerCase)
}
// Run multiple iterations to gather statistical data
for i := 0; i < samplesPerCase; i++ {
// Randomize order to avoid cache effects
for _, tc := range testCases {
elapsed := measureAuthTime(t, auther, storage, tc.username, "wrongpassword123")
measurements[tc.username] = append(measurements[tc.username], elapsed)
}
}
// Calculate statistics for each test case
type stats struct {
mean time.Duration
stddev time.Duration
min time.Duration
max time.Duration
}
calculateStats := func(durations []time.Duration) stats {
if len(durations) == 0 {
return stats{}
}
// Calculate mean
var sum time.Duration
for _, d := range durations {
sum += d
}
mean := sum / time.Duration(len(durations))
// Calculate standard deviation
var variance float64
for _, d := range durations {
diff := float64(d - mean)
variance += diff * diff
}
variance /= float64(len(durations))
stddev := time.Duration(math.Sqrt(variance))
// Find min and max
min := durations[0]
max := durations[0]
for _, d := range durations {
if d < min {
min = d
}
if d > max {
max = d
}
}
return stats{mean: mean, stddev: stddev, min: min, max: max}
}
// Analyze results
validUserStats := make([]stats, 0)
invalidUserStats := make([]stats, 0)
t.Log("\n=== Timing Analysis Results ===")
for _, tc := range testCases {
s := calculateStats(measurements[tc.username])
t.Logf("%s (valid=%v): mean=%.4fms, stddev=%.4fms, min=%.4fms, max=%.4fms",
tc.username, tc.isValid,
float64(s.mean.Microseconds())/1000.0,
float64(s.stddev.Microseconds())/1000.0,
float64(s.min.Microseconds())/1000.0,
float64(s.max.Microseconds())/1000.0)
if tc.isValid {
validUserStats = append(validUserStats, s)
} else {
invalidUserStats = append(invalidUserStats, s)
}
}
// Calculate overall statistics for valid vs invalid users
var validMeanSum, invalidMeanSum time.Duration
for _, s := range validUserStats {
validMeanSum += s.mean
}
for _, s := range invalidUserStats {
invalidMeanSum += s.mean
}
validAvgMean := validMeanSum / time.Duration(len(validUserStats))
invalidAvgMean := invalidMeanSum / time.Duration(len(invalidUserStats))
t.Logf("\n=== Summary ===")
t.Logf("Valid users average: %.4fms", float64(validAvgMean.Microseconds())/1000.0)
t.Logf("Invalid users average: %.4fms", float64(invalidAvgMean.Microseconds())/1000.0)
// Calculate the percentage difference
diff := float64(validAvgMean - invalidAvgMean)
percentDiff := math.Abs(diff) / float64(validAvgMean) * 100.0
t.Logf("Difference: %.4fms (%.2f%%)", diff/1e6, percentDiff)
// Assert: The timing difference should be less than 20%
// Bcrypt's constant-time comparison should make both cases take similar time
// We allow 20% threshold to account for system noise and variability
threshold := 20.0 // percent
if percentDiff > threshold {
t.Errorf("TIMING ATTACK VULNERABILITY DETECTED: Valid vs invalid user timing differs by %.2f%% (threshold: %.2f%%)",
percentDiff, threshold)
t.Errorf("This difference could allow an attacker to enumerate valid usernames!")
} else {
t.Logf("✓ No timing attack vulnerability detected (difference: %.2f%% < threshold: %.2f%%)",
percentDiff, threshold)
}
// Additional check: Ensure no individual case is an obvious outlier
// Check if any single user's timing is significantly faster than bcrypt operations
allStats := append(validUserStats, invalidUserStats...)
var allMeansSum time.Duration
for _, s := range allStats {
allMeansSum += s.mean
}
overallMean := allMeansSum / time.Duration(len(allStats))
for _, tc := range testCases {
s := calculateStats(measurements[tc.username])
deviation := math.Abs(float64(s.mean-overallMean)) / float64(overallMean) * 100.0
// If any user is more than 30% faster/slower than average, it's suspicious
if deviation > 30.0 {
t.Errorf("OUTLIER DETECTED: %s deviates by %.2f%% from overall mean (%.4fms vs %.4fms)",
tc.username, deviation,
float64(s.mean.Microseconds())/1000.0,
float64(overallMean.Microseconds())/1000.0)
}
}
}
// TestJSONAuth_InvalidPasswordHashInitialized verifies that InvalidPasswordHash is properly set
func TestJSONAuth_InvalidPasswordHashInitialized(t *testing.T) {
// This test ensures that the InvalidPasswordHash is initialized
// If it's empty, timing attacks would be possible because invalid users
// would skip bcrypt comparison entirely
// Use lower bcrypt cost for faster testing
originalCost := utils.BcryptCost
utils.BcryptCost = 4
t.Cleanup(func() {
utils.BcryptCost = originalCost
})
err := utils.SetInvalidPasswordHash()
if err != nil {
t.Fatalf("Failed to set invalid password hash: %v", err)
}
if utils.InvalidPasswordHash == "" {
t.Fatal("InvalidPasswordHash is not initialized - timing attack vulnerability exists!")
}
// Verify that the invalid hash can be used for password comparison
// This should fail (wrong password) but take the same time as a real bcrypt comparison
err = utils.CheckPwd("anypassword", utils.InvalidPasswordHash)
if err == nil {
t.Fatal("CheckPwd should fail with InvalidPasswordHash")
}
t.Log("✓ InvalidPasswordHash is properly initialized")
}
+134
View File
@@ -0,0 +1,134 @@
package auth
import (
"encoding/json"
"fmt"
"net/http"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// MethodJwtAuth is used to identify JWT external authentication.
const MethodJwtAuth = "jwt"
// JwtAuth is an external JWT implementation of an auther.
// It verifies JWT tokens signed with a shared secret and extracts user identity.
type JwtAuth struct {
Secret string `json:"secret"` // shared secret for verifying JWT signatures
Algorithm string `json:"algorithm"` // JWT signing algorithm (HS256, HS384, HS512, RS256, ES256, etc.)
}
// ExternalJwtClaims represents the expected claims in an external JWT token
type ExternalJwtClaims struct {
jwt.RegisteredClaims
Username string `json:"sub"` // subject claim (username)
Email string `json:"email,omitempty"` // email claim
Name string `json:"name,omitempty"` // full name claim
Groups []string `json:"groups,omitempty"` // groups claim
Custom map[string]interface{} `json:"-"` // catch-all for custom claims
}
// Auth authenticates the user via an external JWT token.
// The token is verified using the configured secret and algorithm.
func (a JwtAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
// This should not be called directly - JWT auth is handled in middleware
// because we need access to the full config (header name, user identifier field, etc.)
return nil, fmt.Errorf("JWT auth must be handled in middleware")
}
// VerifyExternalJWT verifies an external JWT token and extracts the username claim
func VerifyExternalJWT(tokenString string, secret string, algorithm string, userIdentifierField string) (string, map[string]interface{}, error) {
// Parse the token with the secret
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Verify the signing method matches what we expect
expectedMethod := jwt.GetSigningMethod(algorithm)
if expectedMethod == nil {
return nil, fmt.Errorf("unsupported signing algorithm: %s", algorithm)
}
if token.Method.Alg() != expectedMethod.Alg() {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
// Return the secret key for HMAC algorithms
// For RSA/ECDSA, this would need to be a public key
return []byte(secret), nil
})
if err != nil {
logger.Debugf("JWT token verification failed: %v", err)
return "", nil, fmt.Errorf("invalid JWT token: %w", err)
}
if !token.Valid {
return "", nil, fmt.Errorf("JWT token is invalid")
}
// Extract claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return "", nil, fmt.Errorf("failed to parse JWT claims")
}
// Check if token is expired
if err := claims.Valid(); err != nil {
logger.Debugf("JWT claims validation failed: %v", err)
return "", nil, fmt.Errorf("JWT token is expired or invalid: %w", err)
}
// Extract username from the configured field (default: "sub")
var username string
if userVal, ok := claims[userIdentifierField]; ok {
if userStr, ok := userVal.(string); ok {
username = userStr
}
}
if username == "" {
return "", nil, fmt.Errorf("JWT token missing required claim: %s", userIdentifierField)
}
// Convert claims to map for additional processing
claimsMap := make(map[string]interface{})
for k, v := range claims {
claimsMap[k] = v
}
logger.Debugf("Successfully verified JWT token for user: %s", username)
return username, claimsMap, nil
}
// ExtractGroupsFromClaims extracts groups from JWT claims based on the configured groups claim field
func ExtractGroupsFromClaims(claims map[string]interface{}, groupsClaimField string) []string {
var groups []string
if groupsVal, ok := claims[groupsClaimField]; ok {
switch v := groupsVal.(type) {
case []interface{}:
// Groups as array of interfaces
for _, g := range v {
if groupStr, ok := g.(string); ok {
groups = append(groups, groupStr)
}
}
case []string:
// Groups as array of strings
groups = v
case string:
// Single group as string
groups = []string{v}
default:
// Try to unmarshal as JSON array
if jsonBytes, err := json.Marshal(v); err == nil {
var groupsArray []string
if err := json.Unmarshal(jsonBytes, &groupsArray); err == nil {
groups = groupsArray
}
}
}
}
return groups
}
+221
View File
@@ -0,0 +1,221 @@
package auth_test
import (
"testing"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/auth"
)
func TestVerifyExternalJWT_ValidToken(t *testing.T) {
secret := "test-secret-key"
algorithm := "HS256"
username := "testuser"
// Create a valid JWT token
claims := jwt.MapClaims{
"sub": username,
"email": "test@example.com",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("Failed to create test token: %v", err)
}
// Verify the token
extractedUsername, extractedClaims, err := auth.VerifyExternalJWT(tokenString, secret, algorithm, "sub")
if err != nil {
t.Fatalf("VerifyExternalJWT() failed: %v", err)
}
if extractedUsername != username {
t.Errorf("Expected username %s, got %s", username, extractedUsername)
}
if extractedClaims["email"] != "test@example.com" {
t.Errorf("Expected email test@example.com, got %v", extractedClaims["email"])
}
}
func TestVerifyExternalJWT_ExpiredToken(t *testing.T) {
secret := "test-secret-key"
algorithm := "HS256"
// Create an expired JWT token
claims := jwt.MapClaims{
"sub": "testuser",
"exp": time.Now().Add(-time.Hour).Unix(), // Expired 1 hour ago
"iat": time.Now().Add(-2 * time.Hour).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("Failed to create test token: %v", err)
}
// Verify the token - should fail
_, _, err = auth.VerifyExternalJWT(tokenString, secret, algorithm, "sub")
if err == nil {
t.Fatal("VerifyExternalJWT() should fail for expired token")
}
}
func TestVerifyExternalJWT_InvalidSignature(t *testing.T) {
secret := "test-secret-key"
wrongSecret := "wrong-secret-key"
algorithm := "HS256"
// Create a token with one secret
claims := jwt.MapClaims{
"sub": "testuser",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("Failed to create test token: %v", err)
}
// Try to verify with a different secret - should fail
_, _, err = auth.VerifyExternalJWT(tokenString, wrongSecret, algorithm, "sub")
if err == nil {
t.Fatal("VerifyExternalJWT() should fail for invalid signature")
}
}
func TestVerifyExternalJWT_MissingUsernameClaim(t *testing.T) {
secret := "test-secret-key"
algorithm := "HS256"
// Create a token without the 'sub' claim
claims := jwt.MapClaims{
"email": "test@example.com",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("Failed to create test token: %v", err)
}
// Verify the token - should fail due to missing 'sub' claim
_, _, err = auth.VerifyExternalJWT(tokenString, secret, algorithm, "sub")
if err == nil {
t.Fatal("VerifyExternalJWT() should fail for missing username claim")
}
}
func TestVerifyExternalJWT_CustomUserIdentifier(t *testing.T) {
secret := "test-secret-key"
algorithm := "HS256"
username := "testuser"
// Create a token with custom username field
claims := jwt.MapClaims{
"username": username,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("Failed to create test token: %v", err)
}
// Verify the token with custom user identifier field
extractedUsername, _, err := auth.VerifyExternalJWT(tokenString, secret, algorithm, "username")
if err != nil {
t.Fatalf("VerifyExternalJWT() failed: %v", err)
}
if extractedUsername != username {
t.Errorf("Expected username %s, got %s", username, extractedUsername)
}
}
func TestExtractGroupsFromClaims_ArrayOfStrings(t *testing.T) {
claims := map[string]interface{}{
"groups": []string{"admin", "users"},
}
groups := auth.ExtractGroupsFromClaims(claims, "groups")
if len(groups) != 2 {
t.Fatalf("Expected 2 groups, got %d", len(groups))
}
if groups[0] != "admin" || groups[1] != "users" {
t.Errorf("Expected groups [admin, users], got %v", groups)
}
}
func TestExtractGroupsFromClaims_ArrayOfInterfaces(t *testing.T) {
claims := map[string]interface{}{
"groups": []interface{}{"admin", "users"},
}
groups := auth.ExtractGroupsFromClaims(claims, "groups")
if len(groups) != 2 {
t.Fatalf("Expected 2 groups, got %d", len(groups))
}
if groups[0] != "admin" || groups[1] != "users" {
t.Errorf("Expected groups [admin, users], got %v", groups)
}
}
func TestExtractGroupsFromClaims_SingleString(t *testing.T) {
claims := map[string]interface{}{
"groups": "admin",
}
groups := auth.ExtractGroupsFromClaims(claims, "groups")
if len(groups) != 1 {
t.Fatalf("Expected 1 group, got %d", len(groups))
}
if groups[0] != "admin" {
t.Errorf("Expected group admin, got %s", groups[0])
}
}
func TestExtractGroupsFromClaims_MissingClaim(t *testing.T) {
claims := map[string]interface{}{
"email": "test@example.com",
}
groups := auth.ExtractGroupsFromClaims(claims, "groups")
if len(groups) != 0 {
t.Fatalf("Expected 0 groups, got %d", len(groups))
}
}
func TestExtractGroupsFromClaims_CustomClaimName(t *testing.T) {
claims := map[string]interface{}{
"roles": []string{"admin", "editor"},
}
groups := auth.ExtractGroupsFromClaims(claims, "roles")
if len(groups) != 2 {
t.Fatalf("Expected 2 groups, got %d", len(groups))
}
if groups[0] != "admin" || groups[1] != "editor" {
t.Errorf("Expected groups [admin, editor], got %v", groups)
}
}
+13 -4
View File
@@ -89,6 +89,11 @@ func StartFilebrowser() {
settings.Env.EmbeddedFs = os.IsNotExist(err)
}
err = utils.SetInvalidPasswordHash()
if err != nil {
logger.Fatalf("Failed to set security hash: %v", err)
}
sourceList := []string{}
for path, source := range settings.Config.Server.SourceMap {
sourceList = append(sourceList, fmt.Sprintf("%v: %v", source.Name, path))
@@ -182,7 +187,7 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
cacheDir := settings.Config.Server.CacheDir
numWorkers := settings.Config.Server.NumImageProcessors
ffmpeg.SetFFmpegPaths()
// Initialize asset filesystem before starting services
if settings.Env.EmbeddedFs {
embeddedAssets := fbhttp.GetEmbeddedAssets()
@@ -194,7 +199,7 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
} else {
fileutils.InitAssetFS(nil, false)
}
// Start preview service
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
if err != nil {
@@ -202,12 +207,16 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
}
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
logger.Debugf("Exiftool Enabled : %v", settings.Config.Integrations.Media.ExiftoolPath != "")
// Generate PWA icons after preview service is initialized
if err := icons.GeneratePWAIcons(); err != nil {
logger.Warningf("Failed to generate PWA icons: %v", err)
}
// Initialize PWA manifest after icons are generated
icons.InitializePWAManifest()
fbhttp.StartHttp(ctx, store, shutdownComplete)
return nil
}
+23 -4
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"reflect"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
@@ -40,6 +41,9 @@ func validateUserInfo(newDB bool) {
if updateSidebarLinks(user) {
updateUser = true
}
if updateTokens(user) {
updateUser = true
}
adminUser := settings.Config.Auth.AdminUsername
adminPass := settings.Config.Auth.AdminPassword
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
@@ -119,7 +123,7 @@ func updateShowFirstLogin(user *users.User) bool {
// func to convert legacy user with perm key to permissions
func updatePermissions(user *users.User) bool {
if user.Version == 1 {
if user.Version >= 1 {
return false
}
updateUser := true
@@ -165,7 +169,7 @@ func updatePermissions(user *users.User) bool {
user.Permissions.Delete = true
updateUser = true
}
user.Version = 1
user.Version = 2
if updateUser {
createBackup = true
}
@@ -197,7 +201,7 @@ func updateSidebarLinks(user *users.User) bool {
validSourceLinksCount := 0
for _, link := range user.SidebarLinks {
if link.Category == "source" {
if strings.HasPrefix(link.Category, "source") {
sourceLinksCount++
// Check if this source still exists
if link.SourceName != "" {
@@ -220,7 +224,7 @@ func updateSidebarLinks(user *users.User) bool {
// Remove all existing source links
newLinks := []users.SidebarLink{}
for _, link := range user.SidebarLinks {
if link.Category != "source" {
if !strings.HasPrefix(link.Category, "source") {
newLinks = append(newLinks, link)
}
}
@@ -246,3 +250,18 @@ func updateSidebarLinks(user *users.User) bool {
// User has at least one valid source link, no update needed
return false
}
func updateTokens(user *users.User) bool {
if user.Version >= 2 {
return false
}
if user.ApiKeys != nil {
user.Tokens = make(map[string]users.AuthToken)
for name, token := range user.ApiKeys {
token.Token = token.Key
user.Tokens[name] = token
}
}
user.Version = 2
return true
}
+1
View File
@@ -22,6 +22,7 @@ var (
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not yet configured")
ErrUnauthorized = errors.New("user unauthorized")
ErrNotIndexed = errors.New("directory or item excluded from indexing")
ErrNotViewable = errors.New("directory or item is not viewable")
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")
+180 -17
View File
@@ -6,8 +6,11 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
ldap "github.com/go-ldap/ldap/v3"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -29,6 +32,18 @@ func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
return t.base.RoundTrip(reqCopy)
}
// AuthCommon contains fields shared across multiple authentication methods
type AuthCommon struct {
Enabled bool `json:"enabled"` // whether to enable this authentication method.
AdminGroup string `json:"adminGroup"` // if set, users in this group will be granted admin privileges
UserGroups []string `json:"userGroups"` // if set, only users in these groups are allowed to log in. Blocks all other users even with valid credentials.
GroupsClaim string `json:"groupsClaim"` // the JSON field name to read groups from. Default is "groups"
UserIdentifier string `json:"userIdentifier"` // the field value to use as the username. Default is "preferred_username" in oidc, "sub" in jwt. Other common values are "email" or "username", or "phone"
DisableVerifyTLS bool `json:"disableVerifyTLS"` // disable TLS verification (insecure, for testing only)
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
CreateUser bool `json:"createUser"` // deprecated: always true for supported authentication methods
}
type Auth struct {
TokenExpirationHours int `json:"tokenExpirationHours"` // time in hours each web UI session token is valid for. Default is 2 hours.
Methods LoginMethods `json:"methods"`
@@ -44,6 +59,8 @@ type LoginMethods struct {
NoAuth bool `json:"noauth"` // if set to true, overrides all other auth methods and disables authentication
PasswordAuth PasswordAuthConfig `json:"password" validate:"omitempty"`
OidcAuth OidcConfig `json:"oidc" validate:"omitempty"`
LdapAuth LdapConfig `json:"ldap" validate:"omitempty"`
JwtAuth JwtAuthConfig `json:"jwt" validate:"omitempty"`
}
type PasswordAuthConfig struct {
@@ -55,10 +72,8 @@ type PasswordAuthConfig struct {
}
type ProxyAuthConfig struct {
Enabled bool `json:"enabled"`
CreateUser bool `json:"createUser"` // create user if not exists
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
AuthCommon `json:",inline"`
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
}
type Recaptcha struct {
@@ -69,19 +84,65 @@ type Recaptcha struct {
// OpenID OAuth2.0
type OidcConfig struct {
Enabled bool `json:"enabled"` // whether to enable OIDC authentication
ClientID string `json:"clientId"` // secret: client id of the OIDC application
ClientSecret string `json:"clientSecret"` // secret: client secret of the OIDC application
IssuerUrl string `json:"issuerUrl"` // authorization URL of the OIDC provider
Scopes string `json:"scopes"` // scopes to request from the OIDC provider
UserIdentifier string `json:"userIdentifier"` // the field value to use as the username. Default is "preferred_username", can also be "email" or "username", or "phone"
DisableVerifyTLS bool `json:"disableVerifyTLS"` // disable TLS verification for the OIDC provider. This is insecure and should only be used for testing.
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
CreateUser bool `json:"createUser"` // create user if not exists
AdminGroup string `json:"adminGroup"` // if set, users in this group will be granted admin privileges.
GroupsClaim string `json:"groupsClaim"` // the JSON field name to read groups from. Default is "groups"
Provider *oidc.Provider `json:"-"` // OIDC provider
Verifier *oidc.IDTokenVerifier `json:"-"` // OIDC verifier
AuthCommon `json:",inline"`
ClientID string `json:"clientId"` // secret: client id of the OIDC application
ClientSecret string `json:"clientSecret"` // secret: client secret of the OIDC application
IssuerUrl string `json:"issuerUrl"` // authorization URL of the OIDC provider
Scopes string `json:"scopes"` // scopes to request from the OIDC provider
Provider *oidc.Provider `json:"-"` // OIDC provider
Verifier *oidc.IDTokenVerifier `json:"-"` // OIDC verifier
}
type LdapConfig struct {
AuthCommon `json:",inline"`
Server string `json:"server"` // scheme://host:port of the LDAP server (e.g. ldap://localhost:389)
BaseDN string `json:"baseDN"` // LDAP search base DN (e.g. dc=ldap,dc=goauthentik,dc=io)
UserDN string `json:"userDN"` // Bind DN for service account (e.g. cn=admin,ou=users,dc=ldap,dc=goauthentik,dc=io)
UserPassword string `json:"userPassword"` // Bind password for service account
UserFilter string `json:"userFilter"` // Search filter for finding user by username. Default (&(cn=%s)(objectClass=user)); override e.g. (email=%s) or (sAMAccountName=%s) for other directories.
Port int `json:"-"` // derived from server
Scheme string `json:"-"` // derived from server
Host string `json:"-"` // derived from server
}
// JwtAuthConfig configures external JWT token authentication
// Similar to Grafana's JWT auth: accepts external JWT tokens signed with a shared secret
// The query parameter is hardcoded to "jwt" (e.g. ?jwt=<token>)
type JwtAuthConfig struct {
AuthCommon `json:",inline"`
Header string `json:"header"` // HTTP header to look for JWT token (e.g. X-JWT-Assertion). Default is "X-JWT-Assertion"
Secret string `json:"secret"` // secret: shared secret key for verifying JWT token signatures (required)
Algorithm string `json:"algorithm"` // JWT signing algorithm (HS256, HS384, HS512, RS256, ES256). Default is "HS256"
}
// ValidateLdapAuth checks LDAP config and sets defaults. Call when LDAP is enabled.
func ValidateLdapAuth() error {
ldapCfg := &Config.Auth.Methods.LdapAuth
if ldapCfg.Server == "" {
return fmt.Errorf("LDAP server is required when LDAP is enabled")
}
scheme, host, port, err := parseLdapServer(ldapCfg.Server)
if err != nil {
return fmt.Errorf("LDAP server is invalid: %w", err)
}
ldapCfg.Server = fmt.Sprintf("%s://%s:%d", scheme, host, port)
ldapCfg.Scheme = scheme
ldapCfg.Host = host
ldapCfg.Port = port
if ldapCfg.BaseDN == "" {
return fmt.Errorf("LDAP baseDN is required when LDAP is enabled")
}
if ldapCfg.UserDN == "" {
return fmt.Errorf("LDAP userDN (bind DN) is required when LDAP is enabled")
}
if ldapCfg.UserFilter == "" {
ldapCfg.UserFilter = "(&(cn=%s)(objectClass=user))"
}
if err := verifyLdapConnection(); err != nil {
logger.Fatalf("LDAP connection check failed: %v", err)
}
return nil
}
// ValidateOidcAuth processes the OIDC callback and retrieves user identity
@@ -133,3 +194,105 @@ func validateOidcAuth() error {
return nil
}
func parseLdapServer(server string) (scheme, host string, port int, err error) {
parts := strings.Split(server, "://")
if len(parts) != 2 {
return "", "", 0, fmt.Errorf("invalid LDAP server: %s", server)
}
scheme = strings.ToLower(parts[0])
if scheme != "ldap" && scheme != "ldaps" {
return "", "", 0, fmt.Errorf("invalid LDAP scheme: %s", scheme)
}
hostParts := strings.Split(parts[1], ":")
host = strings.TrimSuffix(hostParts[0], "/")
if len(hostParts) > 1 && hostParts[1] != "" {
port, err = strconv.Atoi(strings.TrimSuffix(hostParts[1], "/"))
if err != nil || port == 0 {
return "", "", 0, fmt.Errorf("invalid LDAP port: %s", hostParts[1])
}
} else {
if scheme == "ldaps" {
port = 636
} else {
port = 389
}
}
return scheme, host, port, nil
}
// VerifyLdapConnection tests that the LDAP server is reachable, can bind (if credentials set), and responds to a search.
func verifyLdapConnection() error {
if Config.Auth.Methods.LdapAuth.Server == "" {
return fmt.Errorf("LDAP server is required when LDAP is enabled")
}
if Config.Auth.Methods.LdapAuth.BaseDN == "" {
return fmt.Errorf("LDAP baseDN is required for connection verification")
}
var opts []ldap.DialOpt
scheme := Config.Auth.Methods.LdapAuth.Scheme
host := Config.Auth.Methods.LdapAuth.Host
port := Config.Auth.Methods.LdapAuth.Port
if Config.Auth.Methods.LdapAuth.DisableVerifyTLS && scheme == "ldaps" {
opts = append(opts, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true}))
}
fullURL := fmt.Sprintf("%s://%s:%d", scheme, host, port)
conn, err := ldap.DialURL(fullURL, opts...)
if err != nil {
return fmt.Errorf("LDAP connection failed (is the LDAP outpost running at %s? use port 389 for LDAP, 636 for LDAPS; this is not the HTTP port): %w", fullURL, err)
}
defer conn.Close()
if Config.Auth.Methods.LdapAuth.UserDN != "" && Config.Auth.Methods.LdapAuth.UserPassword != "" {
if err = conn.Bind(Config.Auth.Methods.LdapAuth.UserDN, Config.Auth.Methods.LdapAuth.UserPassword); err != nil {
return fmt.Errorf("LDAP bind failed (check userDN and userPassword): %w", err)
}
defer func() { _ = conn.Unbind() }()
}
// Dummy search to verify the server responds to LDAP search (same path that fails at login if misconfigured).
searchRequest := ldap.NewSearchRequest(
Config.Auth.Methods.LdapAuth.BaseDN,
ldap.ScopeBaseObject,
ldap.NeverDerefAliases,
0,
0,
false,
"(objectClass=*)",
[]string{"1.1"}, // no attributes
nil,
)
_, err = conn.Search(searchRequest)
if err != nil {
return fmt.Errorf("LDAP search test failed (server may require StartTLS or bind before search): %w", err)
}
return nil
}
// ValidateJwtAuth checks JWT config and sets defaults. Call when JWT auth is enabled.
func ValidateJwtAuth() error {
jwtCfg := &Config.Auth.Methods.JwtAuth
if jwtCfg.Secret == "" {
return fmt.Errorf("JWT secret is required when JWT auth is enabled")
}
if jwtCfg.Header == "" {
jwtCfg.Header = "X-JWT-Assertion"
}
if jwtCfg.Algorithm == "" {
jwtCfg.Algorithm = "HS256"
}
if jwtCfg.GroupsClaim == "" {
jwtCfg.GroupsClaim = "groups"
}
if jwtCfg.UserIdentifier == "" {
jwtCfg.UserIdentifier = "sub"
}
// Validate algorithm
validAlgos := map[string]bool{
"HS256": true, "HS384": true, "HS512": true,
"RS256": true, "RS384": true, "RS512": true,
"ES256": true, "ES384": true, "ES512": true,
}
if !validAlgos[jwtCfg.Algorithm] {
return fmt.Errorf("unsupported JWT algorithm: %s. Supported: HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512", jwtCfg.Algorithm)
}
return nil
}
+221
View File
@@ -0,0 +1,221 @@
package settings
import (
"strings"
"testing"
)
func TestParseLdapServer(t *testing.T) {
tests := []struct {
name string
server string
wantScheme string
wantHost string
wantPort int
wantErr bool
}{
{
name: "ldap with host and port",
server: "ldap://localhost:389",
wantScheme: "ldap",
wantHost: "localhost",
wantPort: 389,
wantErr: false,
},
{
name: "ldaps with host and port",
server: "ldaps://ldap.example.com:636",
wantScheme: "ldaps",
wantHost: "ldap.example.com",
wantPort: 636,
wantErr: false,
},
{
name: "ldap with trailing slash",
server: "ldap://host:389/",
wantScheme: "ldap",
wantHost: "host",
wantPort: 389,
wantErr: false,
},
{
name: "ldap port not provided defaults to 389",
server: "ldap://localhost",
wantScheme: "ldap",
wantHost: "localhost",
wantPort: 389,
wantErr: false,
},
{
name: "ldaps port not provided defaults to 636",
server: "ldaps://ldap.example.com",
wantScheme: "ldaps",
wantHost: "ldap.example.com",
wantPort: 636,
wantErr: false,
},
{
name: "port provided but invalid returns error",
server: "ldap://host:bad",
wantErr: true,
},
{
name: "port provided but invalid (ldaps) returns error",
server: "ldaps://host:xyz",
wantErr: true,
},
{
name: "port zero returns error",
server: "ldap://host:0",
wantErr: true,
},
{
name: "empty server",
server: "",
wantErr: true,
},
{
name: "no scheme",
server: "localhost:389",
wantErr: true,
},
{
name: "invalid scheme",
server: "http://localhost:389",
wantErr: true,
},
{
name: "invalid scheme ftp",
server: "ftp://localhost:389",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotScheme, gotHost, gotPort, err := parseLdapServer(tt.server)
if (err != nil) != tt.wantErr {
t.Errorf("parseLdapServer() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
if gotScheme != tt.wantScheme {
t.Errorf("parseLdapServer() scheme = %v, want %v", gotScheme, tt.wantScheme)
}
if gotHost != tt.wantHost {
t.Errorf("parseLdapServer() host = %v, want %v", gotHost, tt.wantHost)
}
if gotPort != tt.wantPort {
t.Errorf("parseLdapServer() port = %v, want %v", gotPort, tt.wantPort)
}
}
})
}
}
func TestVerifyLdapConnection_ValidationOnly(t *testing.T) {
// Save and restore global config so we don't affect other tests
saved := Config.Auth.Methods.LdapAuth
defer func() { Config.Auth.Methods.LdapAuth = saved }()
t.Run("empty server returns error", func(t *testing.T) {
Config.Auth.Methods.LdapAuth = LdapConfig{
AuthCommon: AuthCommon{Enabled: true},
Server: "",
BaseDN: "dc=test,dc=local",
}
err := verifyLdapConnection()
if err == nil {
t.Fatal("verifyLdapConnection() expected error for empty server")
}
if !strings.Contains(err.Error(), "LDAP server is required") {
t.Errorf("verifyLdapConnection() error = %v, want message containing 'LDAP server is required'", err)
}
})
t.Run("empty baseDN returns error", func(t *testing.T) {
Config.Auth.Methods.LdapAuth = LdapConfig{
AuthCommon: AuthCommon{Enabled: true},
Server: "ldap://localhost:389",
Scheme: "ldap",
Host: "localhost",
Port: 389,
BaseDN: "",
}
err := verifyLdapConnection()
if err == nil {
t.Fatal("verifyLdapConnection() expected error for empty baseDN")
}
if !strings.Contains(err.Error(), "baseDN is required") {
t.Errorf("verifyLdapConnection() error = %v, want message containing 'baseDN is required'", err)
}
})
}
func TestValidateLdapAuth_ConfigValidation(t *testing.T) {
saved := Config.Auth.Methods.LdapAuth
defer func() { Config.Auth.Methods.LdapAuth = saved }()
t.Run("empty server returns error", func(t *testing.T) {
Config.Auth.Methods.LdapAuth = LdapConfig{
AuthCommon: AuthCommon{Enabled: true},
Server: "",
BaseDN: "dc=test,dc=local",
UserDN: "cn=admin,dc=test,dc=local",
}
err := ValidateLdapAuth()
if err == nil {
t.Fatal("ValidateLdapAuth() expected error for empty server")
}
if !strings.Contains(err.Error(), "server is required") {
t.Errorf("ValidateLdapAuth() error = %v", err)
}
})
t.Run("invalid server URL returns error", func(t *testing.T) {
Config.Auth.Methods.LdapAuth = LdapConfig{
AuthCommon: AuthCommon{Enabled: true},
Server: "not-a-valid-ldap-url",
BaseDN: "dc=test,dc=local",
UserDN: "cn=admin,dc=test,dc=local",
}
err := ValidateLdapAuth()
if err == nil {
t.Fatal("ValidateLdapAuth() expected error for invalid server URL")
}
if !strings.Contains(err.Error(), "invalid") {
t.Errorf("ValidateLdapAuth() error = %v", err)
}
})
t.Run("empty baseDN returns error", func(t *testing.T) {
Config.Auth.Methods.LdapAuth = LdapConfig{
AuthCommon: AuthCommon{Enabled: true},
Server: "ldap://localhost:389",
BaseDN: "",
UserDN: "cn=admin,dc=test,dc=local",
}
err := ValidateLdapAuth()
if err == nil {
t.Fatal("ValidateLdapAuth() expected error for empty baseDN")
}
if !strings.Contains(err.Error(), "baseDN is required") {
t.Errorf("ValidateLdapAuth() error = %v", err)
}
})
t.Run("empty userDN returns error", func(t *testing.T) {
Config.Auth.Methods.LdapAuth = LdapConfig{
AuthCommon: AuthCommon{Enabled: true},
Server: "ldap://localhost:389",
BaseDN: "dc=test,dc=local",
UserDN: "",
}
err := ValidateLdapAuth()
if err == nil {
t.Fatal("ValidateLdapAuth() expected error for empty userDN")
}
if !strings.Contains(err.Error(), "userDN") {
t.Errorf("ValidateLdapAuth() error = %v", err)
}
})
}
+51 -57
View File
@@ -6,6 +6,7 @@ import (
"io"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
@@ -55,7 +56,7 @@ func Initialize(configFile string) {
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
setupUrls()
setupFrontend(false)
setupMedia()
setupMedia(false)
}
func setupServer() {
@@ -278,11 +279,9 @@ func setupFrontend(generate bool) {
}
// Load custom favicon if configured
loadCustomFavicon()
// Generate PWA icons from custom favicon if needed
generatePWAIcons()
}
func setupMedia() {
func setupMedia(generate bool) {
// Save user's explicit config before applying defaults
userImageConfig := make(map[ImagePreviewType]*bool)
for k, v := range Config.Integrations.Media.Convert.ImagePreview {
@@ -316,6 +315,19 @@ func setupMedia() {
for k, v := range userVideoConfig {
Config.Integrations.Media.Convert.VideoPreview[k] = v
}
// Resolve exiftool path once at startup: validate user path or discover via PATH
if Config.Integrations.Media.ExiftoolPath != "" && !generate {
if err := exec.Command(Config.Integrations.Media.ExiftoolPath, "-ver").Run(); err != nil {
logger.Warningf("exiftool path is invalid or not executable: %q (%v); disabling exiftool", Config.Integrations.Media.ExiftoolPath, err)
Config.Integrations.Media.ExiftoolPath = ""
}
}
if Config.Integrations.Media.ExiftoolPath == "" && !generate {
if path, err := exec.LookPath("exiftool"); err == nil && path != "" {
Config.Integrations.Media.ExiftoolPath = path
}
}
}
func setupSources(generate bool) {
@@ -338,11 +350,7 @@ func setupSources(generate bool) {
if name == "\\" {
name = strings.Split(realPath, ":")[0]
}
if generate {
source.Path = generatorPath // use placeholder path
} else {
source.Path = realPath // use absolute path
}
source.Path = realPath // use absolute path
if source.Name == "" {
_, ok := Config.Server.SourceMap[source.Path]
if ok {
@@ -351,6 +359,10 @@ func setupSources(generate bool) {
source.Name = name
}
}
if generate {
source.Path = generatorPath // use placeholder path
source.Name = "Source Name"
}
modifyExcludeInclude(source)
setConditionals(source)
if source.Config.DefaultUserScope == "" {
@@ -422,20 +434,34 @@ func setupAuth(generate bool) {
if Config.Auth.Methods.ProxyAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "proxy")
}
if Config.Auth.Methods.OidcAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "oidc")
}
if Config.Auth.Methods.NoAuth {
logger.Warning("Configured with no authentication, this is not recommended.")
Config.Auth.AuthMethods = []string{"disabled"}
}
if Config.Auth.Methods.OidcAuth.Enabled || generate {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "oidc")
err := validateOidcAuth()
if err != nil && !generate {
logger.Fatalf("Error validating OIDC auth: %v", err)
}
logger.Info("OIDC Auth configured successfully")
}
if Config.Auth.Methods.LdapAuth.Enabled || generate {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "ldap")
err := ValidateLdapAuth()
if err != nil && !generate {
logger.Fatalf("Error validating LDAP auth: %v", err)
}
logger.Info("LDAP Auth configured successfully")
}
if Config.Auth.Methods.JwtAuth.Enabled || generate {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "jwt")
err := ValidateJwtAuth()
if err != nil && !generate {
logger.Fatalf("Error validating JWT auth: %v", err)
}
logger.Info("JWT Auth configured successfully")
}
// use password auth as default if no auth methods are set
if len(Config.Auth.AuthMethods) == 0 {
@@ -624,6 +650,11 @@ func loadEnvConfig() {
logger.Info("Using ReCaptcha Secret from FILEBROWSER_RECAPTCHA_SECRET environment variable")
}
ldapUserPassword := os.Getenv("FILEBROWSER_LDAP_USER_PASSWORD")
if ldapUserPassword != "" {
Config.Auth.Methods.LdapAuth.UserPassword = ldapUserPassword
logger.Info("Using LDAP bind password from FILEBROWSER_LDAP_USER_PASSWORD environment variable")
}
}
func setDefaults(generate bool) Settings {
@@ -636,14 +667,9 @@ 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`.")
logger.Warning("database file could not be found. If this is unexpected, please set the FILEBROWSER_DATABASE environment variable to the correct path.")
}
s := Settings{
Server: Server{
@@ -653,8 +679,8 @@ func setDefaults(generate bool) Settings {
Database: database,
SourceMap: map[string]*Source{},
NameToSource: map[string]*Source{},
MaxArchiveSizeGB: 50,
CacheDir: "tmp",
MaxArchiveSizeGB: 20,
IndexSqlConfig: IndexSqlConfig{
WalMode: false,
BatchSize: 1000,
@@ -681,8 +707,11 @@ func setDefaults(generate bool) Settings {
Name: "FileBrowser Quantum",
},
UserDefaults: UserDefaults{
DeleteAfterArchive: true,
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
StickySidebar: true,
HideFilesInTree: false,
PreferEditorForMarkdown: false,
LockPassword: false,
ShowHidden: false,
DarkMode: boolPtr(true),
@@ -699,14 +728,15 @@ func setDefaults(generate bool) Settings {
Download: boolPtr(true), // defaults to true
},
Preview: UserDefaultsPreview{
HighQuality: boolPtr(true),
Image: boolPtr(true),
Video: boolPtr(true),
Audio: boolPtr(true),
MotionVideoPreview: boolPtr(true),
Office: boolPtr(true),
PopUp: boolPtr(true),
AutoplayMedia: boolPtr(true),
Folder: boolPtr(true),
Models: boolPtr(true),
},
FileLoading: users.FileLoading{
MaxConcurrent: 10,
@@ -788,23 +818,6 @@ func loadCustomFavicon() {
logger.Infof("Using custom favicon: %s", Env.FaviconPath)
}
// generatePWAIcons sets up PWA icon paths (actual generation happens after preview service starts)
func generatePWAIcons() {
// Set PWA icons directory in cache
Env.PWAIconsDir = filepath.Join(Config.Server.CacheDir, "icons")
// Initialize with default paths (all icons will be generated)
Env.PWAIcon192 = "icons/pwa-icon-192.png"
Env.PWAIcon256 = "icons/pwa-icon-256.png"
Env.PWAIcon512 = "icons/pwa-icon-512.png"
// Create icons directory with configured permissions
// Note: Parent cache directory should already exist from testCacheDirSpeed()
if err := os.MkdirAll(Env.PWAIconsDir, fileutils.PermDir); err != nil {
logger.Warningf("Failed to create PWA icons directory %s: %v", Env.PWAIconsDir, err)
}
}
func loadLoginIcon() {
const imageName = "login icon"
allowedFormats := []string{".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"}
@@ -856,26 +869,7 @@ func setConditionals(config *Source) {
IgnoreAllZeroSizeFolders: false,
IgnoreAllSymlinks: false,
IndexingDisabled: false,
}
// backwards compatibility
if config.Config.Conditionals.Hidden {
logger.Warning("source.conditionals.hidden is deprecated, use source.rules instead")
resolved.IgnoreAllHidden = true
}
// Backwards compatibility: if old format fields are set, treat as global rules
if config.Config.Conditionals.IgnoreHidden {
logger.Warning("source.conditionals.ignoreHidden is deprecated, use source.rules instead")
resolved.IgnoreAllHidden = true
}
if config.Config.DisableIndexing {
logger.Warning("source.disableIndexing is deprecated, use source.rules instead")
resolved.IndexingDisabled = true
}
if config.Config.Conditionals.ZeroSizeFolders {
logger.Warning("source.conditionals.zeroSizeFolders is deprecated, use source.rules instead")
resolved.IgnoreAllZeroSizeFolders = true
NoRules: len(rules) == 0,
}
// Process all rules and infer global flags from root-level rules
+125 -17
View File
@@ -561,6 +561,130 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
currentField := v.Field(i)
// Check if this is an inline/embedded field
yamlTag := sf.Tag.Get("yaml")
jsonTag := sf.Tag.Get("json")
isInline := strings.Contains(yamlTag, ",inline") || strings.Contains(jsonTag, ",inline") || sf.Anonymous
// If field is inline, merge its content directly into parent
if isInline && sf.Type.Kind() == reflect.Struct {
// Get the embedded struct's fields and add them directly to this level
embeddedType := sf.Type
embeddedValue := currentField
embeddedTypeName := embeddedType.Name()
for j := 0; j < embeddedType.NumField(); j++ {
embeddedField := embeddedType.Field(j)
// Skip unexported fields
if embeddedField.PkgPath != "" {
continue
}
if embeddedYamlTag := embeddedField.Tag.Get("yaml"); embeddedYamlTag == "-" {
continue
}
if embeddedJsonTag := embeddedField.Tag.Get("json"); embeddedJsonTag == "-" {
continue
}
// Skip deprecated fields
if len(deprecated) > 0 && deprecated[embeddedTypeName][embeddedField.Name] {
continue
}
embeddedFieldValue := embeddedValue.Field(j)
// If we have defaults, compare and skip if values match
if defaults.IsValid() && i < defaults.NumField() {
defaultInlineField := defaults.Field(i)
if defaultInlineField.Kind() == reflect.Struct && j < defaultInlineField.NumField() {
defaultEmbeddedField := defaultInlineField.Field(j)
embeddedCurrentValue := embeddedFieldValue.Interface()
embeddedDefaultValue := defaultEmbeddedField.Interface()
if reflect.DeepEqual(embeddedCurrentValue, embeddedDefaultValue) {
continue
}
}
}
// Determine key name for embedded field
embeddedYamlTag := embeddedField.Tag.Get("yaml")
embeddedJsonTag := embeddedField.Tag.Get("json")
var embeddedKeyName string
if embeddedYamlTag != "" && embeddedYamlTag != "-" {
embeddedKeyName = strings.Split(embeddedYamlTag, ",")[0]
} else if embeddedJsonTag != "" && embeddedJsonTag != "-" {
embeddedKeyName = strings.Split(embeddedJsonTag, ",")[0]
} else {
embeddedKeyName = embeddedField.Name
}
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: embeddedKeyName}
// Attach comments for embedded field
var parts []string
var comment string
if cm := comm[embeddedTypeName][embeddedField.Name]; cm != "" {
comment = cm
}
if comment != "" {
parts = append(parts, comment)
}
if len(comm) > 0 {
if vt := embeddedField.Tag.Get("validate"); vt != "" {
parts = append(parts, fmt.Sprintf(" validate:%s", vt))
}
}
if len(parts) > 0 {
keyNode.LineComment = strings.Join(parts, " ")
}
// Build value node for embedded field
var valNode *yaml.Node
var err error
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
if secrets[embeddedTypeName][embeddedField.Name] && !generateConfig {
fieldValue := embeddedFieldValue.Interface()
if str, ok := fieldValue.(string); ok && str == "" {
valNode = &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: "",
Style: yaml.DoubleQuotedStyle,
}
} else {
valNode = &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: "**hidden**",
Style: yaml.DoubleQuotedStyle,
}
}
} else {
var defaultEmbeddedField reflect.Value
if defaults.IsValid() && i < defaults.NumField() {
defaultInlineField := defaults.Field(i)
if defaultInlineField.Kind() == reflect.Struct && j < defaultInlineField.NumField() {
defaultEmbeddedField = defaultInlineField.Field(j)
}
}
valNode, err = buildNodeWithDefaults(embeddedFieldValue, comm, defaultEmbeddedField, secrets, deprecated)
if err != nil {
return nil, err
}
}
mapNode.Content = append(mapNode.Content, keyNode, valNode)
}
// Skip adding the embedded struct itself as a field
continue
}
// If we have defaults, compare and skip if values match
if defaults.IsValid() && i < defaults.NumField() {
defaultField := defaults.Field(i)
@@ -575,8 +699,6 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
}
// determine key: yaml tag > json tag > field name
yamlTag := sf.Tag.Get("yaml")
jsonTag := sf.Tag.Get("json")
var keyName string
if yamlTag != "" && yamlTag != "-" {
keyName = strings.Split(yamlTag, ",")[0]
@@ -755,25 +877,11 @@ func GenerateYaml() {
setupLogging()
setupFs()
setupAuth(true)
// Save original paths before setupSources modifies them (for YAML generation)
originalPaths := make(map[*Source]string)
for _, source := range Config.Server.Sources {
originalPaths[source] = source.Path
}
setupSources(true)
setupUrls()
setupMedia()
setupMedia(true)
setupFrontend(true)
// Restore original paths so the YAML output has the correct paths, not the placeholder
for _, source := range Config.Server.Sources {
if originalPath, ok := originalPaths[source]; ok {
source.Path = originalPath
}
}
output := "../frontend/public/config.generated.yaml" // "output YAML file"
// Generate YAML with comments enabled, full config, and deprecated fields filtered
+6 -2
View File
@@ -68,6 +68,8 @@ func AdminPerms() users.Permissions {
func ApplyUserDefaults(u *users.User) {
u.StickySidebar = Config.UserDefaults.StickySidebar
u.DisableSettings = Config.UserDefaults.DisableSettings
u.HideFilesInTree = Config.UserDefaults.HideFilesInTree
u.DeleteAfterArchive = Config.UserDefaults.DeleteAfterArchive
// Handle DarkMode with default - dereference pointer from config
u.DarkMode = boolValueOrDefault(Config.UserDefaults.DarkMode, true)
@@ -88,15 +90,16 @@ func ApplyUserDefaults(u *users.User) {
// 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.Audio = boolValueOrDefault(Config.UserDefaults.Preview.Audio, 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.Preview.Models = boolValueOrDefault(Config.UserDefaults.Preview.Models, true)
u.ShowHidden = Config.UserDefaults.ShowHidden
u.DateFormat = Config.UserDefaults.DateFormat
@@ -108,7 +111,8 @@ func ApplyUserDefaults(u *users.User) {
u.DisableOnlyOfficeExt = Config.UserDefaults.DisableOnlyOfficeExt
u.FileLoading = Config.UserDefaults.FileLoading
u.DisableOfficePreviewExt = Config.UserDefaults.DisableOfficePreviewExt
if len(u.Scopes) == 0 {
u.PreferEditorForMarkdown = Config.UserDefaults.PreferEditorForMarkdown
if len(u.Scopes) == 0 && u.Username != "anonymous" {
for _, source := range Config.Server.Sources {
if source.Config.DefaultEnabled {
u.Scopes = append(u.Scopes, users.SourceScope{
+11 -3
View File
@@ -62,10 +62,10 @@ type Server struct {
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: false)
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
Filesystem Filesystem `json:"filesystem"` // filesystem settings
IndexSqlConfig IndexSqlConfig `json:"indexSqlConfig"` // Index database SQL configuration
DisableWebDAV bool `json:"disableWebDAV"` // disable webdav support (default: false)
// 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
@@ -102,6 +102,7 @@ type Media struct {
Convert FfmpegConvert `json:"convert"` // config for ffmpeg conversion settings
Debug bool `json:"debug"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles"` // extract embedded subtitles from media files
ExiftoolPath string `json:"exiftoolPath"` // path to exiftool executable
}
type FfmpegConvert struct {
@@ -260,6 +261,7 @@ type ResolvedRulesConfig struct {
IgnoreAllZeroSizeFolders bool // Excludes all folders with 0 size
IgnoreAllSymlinks bool // Excludes all symbolic links
IndexingDisabled bool // Excludes all files and folders from indexing
NoRules bool // No rules are configured
}
type Frontend struct {
@@ -272,6 +274,7 @@ type Frontend struct {
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
LoginButtonText string `json:"loginButtonText"` // text to display on the login button
OIDCLoginButtonText string `json:"oidcLoginButtonText"` // text to display on the OIDC login button
}
@@ -301,15 +304,17 @@ type ExternalLink struct {
// 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
HighQuality *bool `json:"highQuality"` // deprecated: always true in v1.3.0+
Image *bool `json:"image"` // show thumbnails for image files
Video *bool `json:"video"` // show thumbnails for video files
Audio *bool `json:"audio"` // show thumbnails for audio 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
Models *bool `json:"models"` // show live thumbnails for 3D models files
}
// UserDefaultsPermissions holds permission settings with pointer types for defaults
@@ -332,6 +337,7 @@ type UserDefaults struct {
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
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
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
@@ -351,10 +357,12 @@ type UserDefaults struct {
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
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
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
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer.
}
+46
View File
@@ -0,0 +1,46 @@
package utils
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"golang.org/x/crypto/bcrypt"
)
var BcryptCost = bcrypt.DefaultCost
var InvalidPasswordHash = ""
// HashPwd hashes a password.
func HashPwd(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
return string(bytes), err
}
// CheckPwd checks if a password is correct.
func CheckPwd(password, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
func SetInvalidPasswordHash() error {
passwordHash, err := HashPwd(InsecureRandomIdentifier(16))
if err != nil {
return err
}
InvalidPasswordHash = passwordHash
return nil
}
func HashSHA256(data string) string {
bytes := sha256.Sum256([]byte(data))
return hex.EncodeToString(bytes[:])
}
func GenerateKey() string {
b := make([]byte, 64)
_, err := rand.Read(b)
if err != nil {
return ""
}
return string(b)
}
+13 -6
View File
@@ -52,12 +52,15 @@ type FileOptions struct {
IsDir bool
Expand 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
ShowHidden bool // whether to show hidden files (true = show, false = hide)
FollowSymlinks bool // whether to follow symlinks
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
ShowHidden bool // whether to show hidden files (true = show, false = hide)
FollowSymlinks bool // whether to follow symlinks
Only string // whether to only get files or folders
SkipExtendedAttrs bool // whether to skip extended attributes
ShowSharedAttr bool // whether to show share attributes
}
// SanitizeUserPath prevents path traversal attacks by cleaning and validating user input.
@@ -77,5 +80,9 @@ func SanitizeUserPath(userPath string) (string, error) {
}
}
if clean == "." {
return "", fmt.Errorf("invalid path: path must standard index path")
}
return clean, nil
}
-17
View File
@@ -2,9 +2,6 @@ package utils
import (
"cmp"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
math "math/rand"
"os"
@@ -23,15 +20,6 @@ func CheckErr(source string, err error) {
}
}
func GenerateKey() string {
b := make([]byte, 64)
_, err := rand.Read(b)
if err != nil {
return ""
}
return string(b)
}
// CapitalizeFirst returns the input string with the first letter capitalized.
func CapitalizeFirst(s string) string {
if len(s) == 0 {
@@ -92,11 +80,6 @@ func GetParentDirectoryPath(path string) string {
return path[:lastSlash]
}
func HashSHA256(data string) string {
bytes := sha256.Sum256([]byte(data))
return hex.EncodeToString(bytes[:])
}
func GetLastComponent(path string) string {
if path == "" {
return ""
+1 -1
View File
@@ -8,4 +8,4 @@ userDefaults:
image: true
popup: true
video: true
highQuality: true
audio: true
+6 -2
View File
@@ -11,6 +11,7 @@ userDefaults:
disableQuickToggles: false # disable the quick toggles in the sidebar
disableSearchOptions: false # disable the search options in the search bar
stickySidebar: true # keep sidebar open when navigating
hideFilesInTree: false # hide files in the sidebar tree navigation, when true, will show only directories.
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
@@ -25,15 +26,16 @@ userDefaults:
disableSettings: false # disable the user from viewing the settings page
preview:
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)
audio: true # show thumbnail preview image for audio 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)
models: true # show live thumbnail preview for 3D Models files (defaults to true if not set)
permissions:
api: false # allow api access
admin: false # allow admin access
@@ -45,7 +47,9 @@ userDefaults:
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
deleteAfterArchive: true # delete source files after successful creation/extraction of archives
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
debugOffice: false # debug onlyoffice editor
preferEditorForMarkdown: false # prefer editor first for markdown files instead of the Markdown Viewer
+96 -13
View File
@@ -37,8 +37,10 @@ type SourceRuleMap map[string]RuleMap
type StringSet map[string]struct{}
type dbStorage struct {
AllRules SourceRuleMap `json:"all_rules"`
Groups GroupMap `json:"groups"`
AllRules SourceRuleMap `json:"all_rules"`
Groups GroupMap `json:"groups"`
RevokedTokens map[string]struct{} `json:"revoked_tokens"` // set of revoked token hashes
HashedTokens map[string]uint `json:"hashed_tokens"` // maps token hash → user ID
}
// RuleSet groups users and groups for allow/deny lists.
@@ -72,21 +74,26 @@ type GroupMap map[string]StringSet
// Storage manages access rules and group membership.
type Storage struct {
mux sync.RWMutex
AllRules SourceRuleMap // AllRules[sourcePath][indexPath] - in-memory authoritative state
Groups GroupMap // key: group name, value: set of usernames - in-memory authoritative state
DB *storm.DB // Optional: DB for persistence
Users *users.Storage // Reference to users storage
mux sync.RWMutex
AllRules SourceRuleMap // AllRules[sourcePath][indexPath] - in-memory authoritative state
Groups GroupMap // key: group name, value: set of usernames - in-memory authoritative state
RevokedTokens map[string]struct{} // set of revoked token hashes - in-memory authoritative state
HashedTokens map[string]uint // maps token hash → user ID - in-memory authoritative state
DB *storm.DB // Optional: DB for persistence
Users *users.Storage // Reference to users storage
}
// SaveToDB persists all rules to the DB if DB is set.
// IMPORTANT: Caller must hold s.mux lock (either read or write).
func (s *Storage) SaveToDB() error {
if s.DB == nil {
return nil
}
data, err := json.Marshal(&dbStorage{
AllRules: s.AllRules,
Groups: s.Groups,
AllRules: s.AllRules,
Groups: s.Groups,
RevokedTokens: s.RevokedTokens,
HashedTokens: s.HashedTokens,
})
if err != nil {
return err
@@ -97,6 +104,8 @@ func (s *Storage) SaveToDB() error {
// Flush persists the current in-memory state to the backing store.
// Call during graceful shutdown to ensure DB matches memory.
func (s *Storage) Flush() error {
s.mux.RLock()
defer s.mux.RUnlock()
return s.SaveToDB()
}
@@ -123,6 +132,18 @@ func (s *Storage) LoadFromDB() error {
if s.Groups == nil {
s.Groups = make(GroupMap)
}
s.RevokedTokens = storage.RevokedTokens
if s.RevokedTokens == nil {
s.RevokedTokens = make(map[string]struct{})
}
s.HashedTokens = storage.HashedTokens
if s.HashedTokens == nil {
s.HashedTokens = make(map[string]uint)
}
s.HashedTokens = storage.HashedTokens
if s.HashedTokens == nil {
s.HashedTokens = make(map[string]uint)
}
s.mux.Unlock()
return nil
}
@@ -136,10 +157,12 @@ func (s *Storage) LoadFromDB() error {
// if err != nil { /* handle error */ }
func NewStorage(db *storm.DB, usersStore *users.Storage) *Storage {
var s = &Storage{
AllRules: make(SourceRuleMap),
Groups: make(GroupMap),
DB: db,
Users: usersStore,
AllRules: make(SourceRuleMap),
Groups: make(GroupMap),
RevokedTokens: make(map[string]struct{}),
HashedTokens: make(map[string]uint),
DB: db,
Users: usersStore,
}
return s
}
@@ -1236,3 +1259,63 @@ func (s *Storage) UpdateRulePath(sourcePath, oldPath, newPath string) error {
logger.Debugf("access rule path updated: source=%s, fromPath=%s, toPath=%s", sourcePath, oldPath, newPath)
return s.SaveToDB()
}
// RevokeToken adds a token hash to the revoked list and persists to DB.
func (s *Storage) RevokeToken(tokenHash string) error {
s.mux.Lock()
defer s.mux.Unlock()
tokenHash = utils.HashSHA256(tokenHash)
s.RevokedTokens[tokenHash] = struct{}{}
// Also remove from HashedTokens to prevent future lookups
delete(s.HashedTokens, tokenHash)
return s.SaveToDB()
}
// IsTokenRevoked checks if a token hash is in the revoked list (memory-only read).
func (s *Storage) IsTokenRevoked(tokenHash string) bool {
s.mux.RLock()
defer s.mux.RUnlock()
tokenHash = utils.HashSHA256(tokenHash)
_, exists := s.RevokedTokens[tokenHash]
return exists
}
// AddHashedToken adds a token hash to user ID mapping.
func (s *Storage) AddApiToken(tokenString string, userID uint) error {
s.mux.Lock()
defer s.mux.Unlock()
tokenHash := utils.HashSHA256(tokenString)
s.HashedTokens[tokenHash] = userID
return s.SaveToDB()
}
// GetUserIDFromToken retrieves the user ID for a given token string (memory-only read).
func (s *Storage) GetUserIDFromToken(tokenString string) (uint, bool) {
s.mux.RLock()
defer s.mux.RUnlock()
tokenHash := utils.HashSHA256(tokenString)
userID, exists := s.HashedTokens[tokenHash]
return userID, exists
}
// GetRevokedTokens returns a copy of all revoked token hashes.
func (s *Storage) GetRevokedTokens() map[string]struct{} {
s.mux.RLock()
defer s.mux.RUnlock()
// Return a copy to prevent external modification
result := make(map[string]struct{}, len(s.RevokedTokens))
for k := range s.RevokedTokens {
result[k] = struct{}{}
}
return result
}
// RemoveApiToken removes a token hash mapping (used when deleting API keys).
func (s *Storage) RemoveApiToken(tokenString string) error {
s.mux.Lock()
defer s.mux.Unlock()
tokenHash := utils.HashSHA256(tokenString)
delete(s.HashedTokens, tokenHash)
return s.SaveToDB()
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func (s *Storage) CheckChildItemAccess(response *iteminfo.ExtendedFileInfo, index *indexing.Index, username string) error {
func (s *Storage) CheckChildItemAccess(response *iteminfo.FileInfo, index *indexing.Index, username string) error {
// Collect all item names to check
allItemNames := make([]string, 0, len(response.Folders)+len(response.Files))
+65 -3
View File
@@ -2,10 +2,13 @@ package share
import (
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// IsSingleFileShare determines if this share is for a single file (not a directory).
@@ -144,10 +147,69 @@ func (l *Link) HasReachedUserLimit(username string) bool {
return count >= l.DownloadsLimit
}
func (l *Link) GetSourceName() (string, error) {
func (l *Link) GetSourceName() string {
sourceInfo, ok := settings.Config.Server.SourceMap[l.Source]
if !ok {
return "", fmt.Errorf("source not found")
return ""
}
return sourceInfo.Name, nil
return sourceInfo.Name
}
func (l *Link) UserCanEdit(user *users.User) bool {
return l.UserID == user.ID || user.Permissions.Admin
}
func (l *Link) SourceURL(user *users.User) string {
sourceName := l.GetSourceName()
// get user scope path from share
userScope, err := user.GetScopeForSourceName(sourceName)
if err != nil {
return ""
}
if !strings.HasPrefix(l.Path, userScope) {
return ""
}
scopedPath := strings.TrimPrefix(l.Path, userScope)
return "/" + filepath.Join("files", sourceName, scopedPath)
}
func (l *Link) BannerURL() string {
_, _, err := l.GetShareImagePartsHelper(true)
if err == nil {
return fmt.Sprintf("%s%spublic/api/share/image?banner=true&hash=%s", settings.Config.Server.ExternalUrl, settings.Config.Server.BaseURL, l.Hash)
}
return l.Banner
}
func (l *Link) FaviconURL() string {
_, _, err := l.GetShareImagePartsHelper(false)
if err == nil {
return fmt.Sprintf("%s%spublic/api/share/image?favicon=true&hash=%s", settings.Config.Server.ExternalUrl, settings.Config.Server.BaseURL, l.Hash)
}
return l.Favicon
}
func (l *Link) GetShareImagePartsHelper(isBanner bool) (string, string, error) {
// Get the asset query string from share
var assetQueryString string
if isBanner {
assetQueryString = l.Banner
} else {
assetQueryString = l.Favicon
}
if assetQueryString == "" {
return "", "", fmt.Errorf("asset not configured for this share")
}
// Parse the query string to extract source and path
assetParams, err := url.ParseQuery(assetQueryString)
if err != nil {
return "", "", fmt.Errorf("invalid asset configuration: %v", err)
}
sourceName := assetParams.Get("source")
assetPath := assetParams.Get("path")
return sourceName, assetPath, nil
}
+4 -2
View File
@@ -43,8 +43,10 @@ type CommonShare struct {
AllowReplacements bool `json:"allowReplacements,omitempty"` // allow replacements of files
SidebarLinks []users.SidebarLink `json:"sidebarLinks"` // customizable sidebar links
HasPassword bool `json:"hasPassword,omitempty"`
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
DisableLoginOption bool `json:"disableLoginOption,omitempty"` // disable login option in share (true = hide, false = show)
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
DisableLoginOption bool `json:"disableLoginOption"` // disable login option in share (true = hide, false = show)
SourceURL string `json:"sourceURL,omitempty"`
CanEditShare bool `json:"canEditShare,omitempty"`
}
type CreateBody struct {
CommonShare
+188 -135
View File
@@ -56,86 +56,179 @@ func (c *crudBackend) DeleteByID(id any) error {
// Storage is a share storage using generics.
type Storage struct {
Generic *crud.Storage[Link]
back StorageBackend
shareCache map[string]*Link
mu sync.RWMutex
users *users.Storage
Generic *crud.Storage[Link]
back StorageBackend
shareByHash map[string]*Link // key: link hash
shareByPath map[string]map[string]string // key: pathKey(Source, Path), value: set of hashes (hash -> "")
mu sync.RWMutex
users *users.Storage
}
// NewStorage creates a share links storage from a backend.
// pathKey returns the cache key for shareByPath (source + path).
func pathKey(source, path string) string {
return source + ":" + path
}
// setCacheLocked updates both shareByHash and shareByPath for the given link.
// Caller must hold s.mu.
func (s *Storage) setCacheLocked(link *Link) {
if link == nil {
return
}
adjustedPath := utils.AddTrailingSlashIfNotExists(link.Path)
adjustedSource := utils.AddTrailingSlashIfNotExists(link.Source)
s.shareByHash[link.Hash] = link
key := pathKey(adjustedSource, adjustedPath)
if s.shareByPath[key] == nil {
s.shareByPath[key] = make(map[string]string)
}
s.shareByPath[key][link.Hash] = ""
}
// setCache updates both caches for the given link. It acquires s.mu.
func (s *Storage) setCache(link *Link) {
s.mu.Lock()
s.setCacheLocked(link)
s.mu.Unlock()
}
// deleteFromCacheLocked removes the link (by hash) from both caches.
// Caller must hold s.mu.
func (s *Storage) deleteFromCacheLocked(hash string) {
link, ok := s.shareByHash[hash]
if !ok {
return
}
adjustedPath := utils.AddTrailingSlashIfNotExists(link.Path)
adjustedSource := utils.AddTrailingSlashIfNotExists(link.Source)
key := pathKey(adjustedSource, adjustedPath)
if inner, ok := s.shareByPath[key]; ok {
delete(inner, hash)
if len(inner) == 0 {
delete(s.shareByPath, key)
}
}
delete(s.shareByHash, hash)
}
// deleteFromCache removes the link (by hash) from both caches. It acquires s.mu.
func (s *Storage) deleteFromCache(hash string) {
s.mu.Lock()
s.deleteFromCacheLocked(hash)
s.mu.Unlock()
}
// setCacheAfterMove updates both caches when a link's Source or Path changed. It acquires s.mu.
func (s *Storage) setCacheAfterMove(link *Link, oldSource, oldPath string) {
s.mu.Lock()
if link == nil {
return
}
adjustedOldPath := utils.AddTrailingSlashIfNotExists(oldPath)
adjustedOldSource := utils.AddTrailingSlashIfNotExists(oldSource)
oldKey := pathKey(adjustedOldSource, adjustedOldPath)
if inner, ok := s.shareByPath[oldKey]; ok {
delete(inner, link.Hash)
if len(inner) == 0 {
delete(s.shareByPath, oldKey)
}
}
s.setCacheLocked(link)
s.mu.Unlock()
}
// NewStorage creates a share links storage from a backend and populates the
// in-memory cache from the database so all reads can be served from cache.
func NewStorage(back StorageBackend, usersStore *users.Storage) *Storage {
return &Storage{
Generic: crud.NewStorage[Link](&crudBackend{back: back}),
back: back,
shareCache: make(map[string]*Link),
users: usersStore,
s := &Storage{
Generic: crud.NewStorage[Link](&crudBackend{back: back}),
back: back,
shareByHash: make(map[string]*Link),
shareByPath: make(map[string]map[string]string),
users: usersStore,
}
s.loadShareCache()
return s
}
// All wraps StorageBackend.All and handles expiry.
func (s *Storage) All() ([]*Link, error) {
// loadShareCache fills shareByHash and shareByPath from the backend and removes expired links from the DB.
// Call once at startup so all reads can be served from cache.
func (s *Storage) loadShareCache() {
links, err := s.back.All()
if err != nil {
return nil, err
if err != nil && err != errors.ErrNotExist {
return
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
if links == nil {
links = []*Link{}
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
for _, link := range links {
if link == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
if link.Expire != 0 && link.Expire <= time.Now().Unix() && !link.KeepAfterExpiration {
_ = s.back.Delete(link.Hash)
continue
}
link.InitUserDownloads()
s.setCacheLocked(link)
}
s.mu.Unlock()
return filtered, nil
}
// FindByUserID wraps StorageBackend.FindByUserID and handles expiry.
func (s *Storage) FindByUserID(id uint) ([]*Link, error) {
links, err := s.back.FindByUserID(id)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
// LoadShareCacheFromDB repopulates the in-memory cache from the database.
// Call at startup (e.g. from InitializeDb) to ensure shares are loaded after the store is ready.
func (s *Storage) LoadShareCacheFromDB() {
s.loadShareCache()
}
// All returns all non-expired shares from the cache (populated at startup and by writes).
func (s *Storage) All() ([]*Link, error) {
s.mu.Lock()
for i, l := range filtered {
result := make([]*Link, 0, len(s.shareByHash))
for _, l := range s.shareByHash {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
}
result = append(result, l)
}
s.mu.Unlock()
return filtered, nil
return result, nil
}
// FindByUserID returns non-expired shares for the user from the cache.
func (s *Storage) FindByUserID(id uint) ([]*Link, error) {
s.mu.Lock()
result := make([]*Link, 0)
for _, l := range s.shareByHash {
if l == nil || l.UserID != id {
continue
}
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
}
result = append(result, l)
}
s.mu.Unlock()
return result, nil
}
// GetByHash wraps StorageBackend.GetByHash and handles expiry.
func (s *Storage) GetByHash(hash string) (*Link, error) {
// return stable in-memory pointer if available
s.mu.RLock()
if link, ok := s.shareCache[hash]; ok && link != nil {
if link, ok := s.shareByHash[hash]; ok && link != nil {
s.mu.RUnlock()
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
_ = s.back.Delete(hash)
s.mu.Lock()
delete(s.shareCache, hash)
s.mu.Unlock()
s.deleteFromCache(hash)
return nil, errors.ErrNotExist
}
return link, nil
@@ -154,9 +247,7 @@ func (s *Storage) GetByHash(hash string) (*Link, error) {
// Initialize UserDownloads map
link.InitUserDownloads()
s.mu.Lock()
s.shareCache[hash] = link
s.mu.Unlock()
s.setCache(link)
return link, nil
}
@@ -164,68 +255,74 @@ func (s *Storage) GetByHash(hash string) (*Link, error) {
func (s *Storage) GetPermanent(path, source string, id uint) (*Link, error) {
l, err := s.back.GetPermanent(path, source, id)
if err == nil && l != nil {
s.mu.Lock()
s.shareCache[l.Hash] = l
s.mu.Unlock()
s.setCache(l)
}
return l, err
}
// Gets wraps StorageBackend.Gets and handles expiry.
// Gets returns shares for the given path, source, and user from the cache.
func (s *Storage) Gets(sourcePath, source string, id uint) ([]*Link, error) {
links, err := s.back.Gets(sourcePath, source, id)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
adjustedPath := utils.AddTrailingSlashIfNotExists(sourcePath)
adjustedSource := utils.AddTrailingSlashIfNotExists(source)
key := pathKey(adjustedSource, adjustedPath)
hashes := s.shareByPath[key]
result := make([]*Link, 0, len(hashes))
for h := range hashes {
l := s.shareByHash[h]
if l == nil || l.UserID != id {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
}
result = append(result, l)
}
s.mu.Unlock()
return filtered, nil
return result, nil
}
// GetBySourcePath wraps StorageBackend.GetBySourcePath and handles expiry.
// GetBySourcePath returns shares for the given path and source from the cache.
func (s *Storage) GetBySourcePath(path, source string) ([]*Link, error) {
links, err := s.back.GetBySourcePath(path, source)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, l := range filtered {
adjustedPath := utils.AddTrailingSlashIfNotExists(path)
adjustedSource := utils.AddTrailingSlashIfNotExists(source)
key := pathKey(adjustedSource, adjustedPath)
hashes := s.shareByPath[key]
result := make([]*Link, 0, len(hashes))
for h := range hashes {
l := s.shareByHash[h]
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
}
result = append(result, l)
}
s.mu.Unlock()
return filtered, nil
return result, nil
}
// IsShared returns whether the given path and source have any shares in the cache.
func (s *Storage) IsShared(path, source string, id uint) bool {
links, _ := s.GetBySourcePath(path, source)
for _, l := range links {
if l.UserID == id {
return true
}
}
return len(links) > 0
}
// 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()
links, err := s.All()
if err != nil && err != errors.ErrNotExist {
logger.Error("failed to list shares", "error", err)
return 0, err
@@ -255,14 +352,7 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
return updated, err
}
s.mu.Lock()
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
existing.Source = l.Source
existing.Path = l.Path
} else {
s.shareCache[l.Hash] = l
}
s.mu.Unlock()
s.setCacheAfterMove(l, oldSource, oldPath)
updated++
}
return updated, nil
@@ -283,11 +373,7 @@ func (s *Storage) UpdateSharePath(hash, newPath string) error {
return err
}
s.mu.Lock()
if existing, ok := s.shareCache[hash]; ok && existing != nil {
existing.Path = newPath
}
s.mu.Unlock()
s.setCacheAfterMove(link, link.Source, oldPath)
logger.Debug("share path updated", "hash", hash, "fromPath", oldPath, "toPath", newPath)
return nil
@@ -298,9 +384,7 @@ func (s *Storage) Save(l *Link) error {
if err := s.back.Save(l); err != nil {
return err
}
s.mu.Lock()
s.shareCache[l.Hash] = l
s.mu.Unlock()
s.setCache(l)
return nil
}
@@ -309,47 +393,16 @@ func (s *Storage) Delete(hash string) error {
if err := s.back.Delete(hash); err != nil {
return err
}
s.mu.Lock()
delete(s.shareCache, hash)
s.mu.Unlock()
s.deleteFromCache(hash)
return nil
}
// Flush persists the current in-memory state of all shares to the backing store.
// Call during graceful shutdown to ensure DB matches memory.
// Flush is a no-op: every share write (Save, Delete, UpdateShares, UpdateSharePath) already
// updates the database, so the cache and DB stay in sync without flushing.
func (s *Storage) Flush() error {
s.mu.RLock()
links := make([]*Link, 0, len(s.shareCache))
for _, l := range s.shareCache {
if l != nil {
links = append(links, l)
}
}
s.mu.RUnlock()
for _, l := range links {
if err := s.back.Save(l); err != nil {
return err
}
}
return nil
}
// filterExpired removes expired links and deletes them from storage.
func (s *Storage) filterExpired(links []*Link) ([]*Link, error) {
var filtered []*Link
for _, link := range links {
if link.Expire != 0 && link.Expire <= time.Now().Unix() && !link.KeepAfterExpiration {
_ = s.back.Delete(link.Hash)
s.mu.Lock()
delete(s.shareCache, link.Hash)
s.mu.Unlock()
continue
}
filtered = append(filtered, link)
}
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)
+3 -10
View File
@@ -9,7 +9,6 @@ import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
@@ -158,12 +157,6 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
}
}
// last revoke api keys if needed.
if existingUser.Permissions.Api && !user.Permissions.Api && slices.Contains(fields, "Permissions") {
for _, key := range existingUser.ApiKeys {
auth.RevokeAPIKey(key.Key) // add to blacklist
}
}
return nil
}
@@ -180,7 +173,7 @@ func (st usersBackend) Save(user *users.User, changePass, disableScopeChange boo
if err != nil {
return err
}
pass, err := users.HashPwd(user.Password)
pass, err := utils.HashPwd(user.Password)
if err != nil {
return err
}
@@ -257,7 +250,7 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
field := t.Field(i)
// which=all can't update password
switch strings.ToLower(field.Name) {
case "id", "username", "password", "apikeys", "totpsecret", "totpnonce":
case "id", "username", "password", "tokens", "totpsecret", "totpnonce":
// Skip these fields
continue
}
@@ -299,7 +292,7 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
if err != nil {
return nil, fmt.Errorf("password does not meet complexity requirements")
}
value, err := users.HashPwd(user.Password)
value, err := utils.HashPwd(user.Password)
if err != nil {
logger.Error(err.Error())
}
+2 -1
View File
@@ -6,11 +6,12 @@ import (
"github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
func init() {
users.BcryptCost = 4 // bcrypt.MinCost for faster tests
utils.BcryptCost = 4 // bcrypt.MinCost for faster tests
// Initialize test source configuration
settings.Config.Server.SourceMap = map[string]*settings.Source{
+4 -1
View File
@@ -18,6 +18,7 @@ import (
var userStore *users.Storage
func InitializeDb(path string) (*bolt.BoltStore, bool, error) {
logger.Debugf("Initializing database: %v", path)
exists, err := dbExists(path)
if err != nil {
panic(err)
@@ -36,6 +37,8 @@ func InitializeDb(path string) (*bolt.BoltStore, bool, error) {
// Load access rules from DB on startup
// ignoring errors because
_ = store.Access.LoadFromDB()
// Load share cache from DB so shareByHash/shareByPath are populated at startup
store.Share.LoadShareCacheFromDB()
userStore = store.Users
err = bolt.Save(db, "version", 2)
if err != nil {
@@ -115,7 +118,7 @@ func CreateUser(userInfo users.User, permissions users.Permissions) error {
return fmt.Errorf("password is required to create a password login user")
}
} else {
hashpass, err := users.HashPwd(userInfo.Username)
hashpass, err := utils.HashPwd(userInfo.Username)
if err != nil {
return err
}
+2 -2
View File
@@ -99,7 +99,7 @@ func (u *User) GetBackendSidebarLinks() ([]SidebarLink, error) {
newLinks := []SidebarLink{}
for _, link := range u.SidebarLinks {
// For source links, validate that the source exists using SourceName
if link.Category == "source" {
if strings.HasPrefix(link.Category, "source") {
if link.SourceName == "" {
return nil, fmt.Errorf("source link missing sourceName (link name: %v)", link.Name)
}
@@ -126,7 +126,7 @@ func (u *User) GetFrontendSidebarLinks() []SidebarLink {
newLinks := []SidebarLink{}
for _, link := range u.SidebarLinks {
// For source links, validate that the source still exists
if link.Category == "source" {
if strings.HasPrefix(link.Category, "source") {
if link.SourceName == "" {
continue
}
-18
View File
@@ -1,18 +0,0 @@
package users
import (
"golang.org/x/crypto/bcrypt"
)
var BcryptCost = bcrypt.DefaultCost
// HashPwd hashes a password.
func HashPwd(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost)
return string(bytes), err
}
// CheckPwd checks if a password is correct.
func CheckPwd(password, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
+15 -14
View File
@@ -26,8 +26,8 @@ type Store interface {
Save(user *User, changePass bool, disableScopeChange bool) error
Delete(id interface{}) error
LastUpdate(id uint) int64
AddApiKey(username uint, name string, key AuthToken) error
DeleteApiKey(username uint, name string) error
AddApiToken(userID uint, name string, tokenString string, metadata AuthToken) error
DeleteApiToken(userID uint, name string) error
}
// crudBackend implements crud.CrudBackend[User] for users storage.
@@ -114,17 +114,18 @@ func (s *Storage) Update(user *User, adminIActor bool, fields ...string) error {
return nil
}
func (s *Storage) AddApiKey(userID uint, name string, key AuthToken) error {
func (s *Storage) AddApiToken(userID uint, name string, tokenString string, metadata AuthToken) error {
user, err := s.Get(userID)
if err != nil {
return err
}
// Initialize the ApiKeys map if it is nil
if user.ApiKeys == nil {
user.ApiKeys = make(map[string]AuthToken)
// Initialize the TokenHashes map if it is nil
if user.Tokens == nil {
user.Tokens = make(map[string]AuthToken)
}
user.ApiKeys[name] = key
err = s.Update(user, true, "ApiKeys")
metadata.Token = tokenString
user.Tokens[name] = metadata
err = s.Update(user, true, "Tokens")
if err != nil {
return err
}
@@ -132,17 +133,17 @@ func (s *Storage) AddApiKey(userID uint, name string, key AuthToken) error {
return nil
}
func (s *Storage) DeleteApiKey(userID uint, name string) error {
func (s *Storage) DeleteApiToken(userID uint, name string) error {
user, err := s.Get(userID)
if err != nil {
return err
}
// Initialize the ApiKeys map if it is nil
if user.ApiKeys == nil {
user.ApiKeys = make(map[string]AuthToken)
// Initialize the Tokens map if it is nil
if user.Tokens == nil {
user.Tokens = make(map[string]AuthToken)
}
delete(user.ApiKeys, name)
err = s.Update(user, true, "ApiKeys")
delete(user.Tokens, name)
err = s.Update(user, true, "Tokens")
if err != nil {
return err
}
+17 -7
View File
@@ -15,13 +15,18 @@ const (
LoginMethodPassword LoginMethod = "password"
LoginMethodProxy LoginMethod = "proxy"
LoginMethodOidc LoginMethod = "oidc"
LoginMethodLdap LoginMethod = "ldap"
LoginMethodJwt LoginMethod = "jwt"
)
type AuthToken struct {
MinimalAuthToken
Key string `json:"key,omitempty"`
Key string `json:"key,omitempty"` // for backward compatibility
Token string `json:"token,omitempty"`
Name string `json:"name,omitempty"`
BelongsTo uint `json:"belongsTo,omitempty"`
IssuedAt int64 `json:"issuedAt,omitempty"`
ExpiresAt int64 `json:"expiresAt,omitempty"`
Permissions Permissions `json:"Permissions,omitempty"`
}
@@ -49,15 +54,16 @@ type Sorting struct {
type Preview struct {
DisableHideSidebar bool `json:"disableHideSidebar"` // disable the hide sidebar preview for previews and editors
HighQuality bool `json:"highQuality"` // generate high quality thumbnail preview images
Image bool `json:"image"` // show thumbnail preview image for image files
Video bool `json:"video"` // show thumbnail preview image for video files
Audio bool `json:"audio"` // show thumbnail preview image for audio files
MotionVideoPreview bool `json:"motionVideoPreview"` // show multiple frames for videos in thumbnail preview when hovering
Office bool `json:"office"` // show thumbnail preview image for office files
PopUp bool `json:"popup"` // show larger popup preview when hovering over thumbnail
AutoplayMedia bool `json:"autoplayMedia"` // autoplay media files in preview
DefaultMediaPlayer bool `json:"defaultMediaPlayer"` // disable html5 media player and use the default media player
Folder bool `json:"folder"` // show thumbnail preview image for folder files
Models bool `json:"models"` // show live thumbnails for 3D models files
}
// User describes a user.
@@ -70,15 +76,16 @@ type User struct {
Scope string `json:"scope,omitempty"`
LockPassword bool `json:"lockPassword"`
Permissions Permissions `json:"permissions"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"`
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"` // deprecated: use Tokens instead
Tokens map[string]AuthToken `json:"tokens,omitempty"`
TOTPSecret string `json:"totpSecret,omitempty"`
TOTPNonce string `json:"totpNonce,omitempty"`
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"` // true if TOTP is enabled, false otherwise
Version int `json:"version"`
ShowFirstLogin bool `json:"showFirstLogin"`
// legacy for migration purposes... og filebrowser has perm attribute
Perm Permissions `json:"perm,omitzero"`
Version int `json:"version"`
ShowFirstLogin bool `json:"showFirstLogin"`
Perm Permissions `json:"perm,omitzero"` // deprecated: use Permissions instead
}
type SourceScope struct {
@@ -117,6 +124,9 @@ type NonAdminEditable struct {
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
}
type FileLoading struct {
@@ -129,7 +139,7 @@ type FileLoading struct {
// 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.
Category string `json:"category"` // Category type: "source", "source-link", "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
+6 -2
View File
@@ -11,12 +11,16 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
// GetImageOrientation extracts the EXIF orientation from an image file using exiftool
func (s *FFmpegService) GetImageOrientation(imagePath string) (string, error) {
// Use exiftool to get orientation information
cmd := exec.Command("exiftool", "-Orientation", "-s3", imagePath)
exiftoolPath := settings.Config.Integrations.Media.ExiftoolPath
if exiftoolPath == "" {
return "Horizontal (normal)", nil
}
cmd := exec.Command(exiftoolPath, "-Orientation", "-s3", imagePath)
var out bytes.Buffer
var errOut bytes.Buffer
cmd.Stdout = &out
+29 -26
View File
@@ -9,29 +9,30 @@ require (
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-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.30.1
github.com/goccy/go-yaml v1.19.2
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-cmp v0.7.0
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-logger v1.0.2
github.com/kovidgoyal/imaging v1.8.19
github.com/mattn/go-sqlite3 v1.14.34
github.com/kovidgoyal/imaging v1.8.20
github.com/mattn/go-sqlite3 v1.14.37
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.48.0
golang.org/x/image v0.36.0
golang.org/x/mod v0.33.0
golang.org/x/net v0.50.0
golang.org/x/oauth2 v0.35.0
golang.org/x/sys v0.41.0
golang.org/x/time v0.14.0
golang.org/x/tools v0.42.0
golang.org/x/crypto v0.49.0
golang.org/x/image v0.37.0
golang.org/x/mod v0.34.0
golang.org/x/net v0.52.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.42.0
golang.org/x/time v0.15.0
golang.org/x/tools v0.43.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.46.1
modernc.org/sqlite v1.47.0
)
require (
@@ -49,6 +50,7 @@ require (
github.com/Antonboom/errname v1.1.1 // indirect
github.com/Antonboom/nilnil v1.1.1 // indirect
github.com/Antonboom/testifylint v1.6.4 // indirect
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
@@ -99,7 +101,7 @@ require (
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/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.0-alpha.4 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
@@ -108,19 +110,20 @@ require (
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/ghostiam/protogetter v0.3.20 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-critic/go-critic v0.14.3 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.4 // indirect
github.com/go-openapi/spec v0.22.3 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect
github.com/go-openapi/swag/conv v0.25.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
github.com/go-openapi/swag/loading v0.25.5 // indirect
github.com/go-openapi/swag/stringutils v0.25.5 // indirect
github.com/go-openapi/swag/typeutils v0.25.5 // indirect
github.com/go-openapi/swag/yamlutils v0.25.5 // 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
@@ -136,7 +139,7 @@ require (
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gohugoio/hugo v0.149.1 // indirect
github.com/golang/geo v0.0.0-20260129164528-943061e2742c // indirect
github.com/golang/geo v0.0.0-20260302211937-87f5a40ea07a // 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
@@ -272,13 +275,13 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.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.7.0 // indirect
modernc.org/libc v1.68.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
+82 -62
View File
@@ -59,6 +59,8 @@ github.com/Antonboom/nilnil v1.1.1 h1:9Mdr6BYd8WHCDngQnNVV0b554xyisFioEKi30sksuf
github.com/Antonboom/nilnil v1.1.1/go.mod h1:yCyAmSw3doopbOWhJlVci+HuyNRuHJKIv6V2oYQa8II=
github.com/Antonboom/testifylint v1.6.4 h1:gs9fUEy+egzxkEbq9P4cpcMB6/G0DYdMeiFS87UiqmQ=
github.com/Antonboom/testifylint v1.6.4/go.mod h1:YO33FROXX2OoUfwjz8g+gUxQXio5i9qpVy7nXGbxDD4=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69 h1:+tu3HOoMXB7RXEINRVIpxJCT+KdYiI7LAEAUrOw3dIU=
github.com/BurntSushi/locker v0.0.0-20171006230638-a6e239ea1c69/go.mod h1:L1AbZdiDllfyYH5l5OkAaZtk7VkWe89bPJFmnDBNHxg=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@@ -94,6 +96,8 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQiFSsuzQ=
github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q=
github.com/alexkohler/prealloc v1.0.2 h1:MPo8cIkGkZytq7WNH9UHv3DIX1mPz1RatPXnZb0zHWQ=
@@ -244,8 +248,8 @@ github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje
github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.0-alpha.4 h1:JzPbdf+cqbyT9sZtP4xnqelwUXwf7LvD8xKS6+ofTds=
github.com/ebitengine/purego v0.10.0-alpha.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -277,6 +281,8 @@ github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0=
github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOESA0pog=
github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
@@ -293,38 +299,40 @@ github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9
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=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
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.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
github.com/go-openapi/spec v0.22.3 h1:qRSmj6Smz2rEBxMnLRBMeBWxbbOvuOoElvSvObIgwQc=
github.com/go-openapi/spec v0.22.3/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
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-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
github.com/go-openapi/testify/v2 v2.4.0/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=
@@ -393,8 +401,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-20260129164528-943061e2742c h1:ysO2h2Odnl1AJM1I2Lm/fa6JvO0pECMSt2CwBaa+ITo=
github.com/golang/geo v0.0.0-20260129164528-943061e2742c/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/geo v0.0.0-20260302211937-87f5a40ea07a h1:7f/cr+n7Jtd7mzCLrx47sdVIGabpSSxCHrf6fJczDp0=
github.com/golang/geo v0.0.0-20260302211937-87f5a40ea07a/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=
@@ -520,6 +528,18 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU=
github.com/jdkato/prose v1.2.1/go.mod h1:AiRHgVagnEx2JbQRQowVBKjG0bcs/vtkGCH1dYAL1rA=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
@@ -560,8 +580,8 @@ github.com/kovidgoyal/go-parallel v1.1.1 h1:1OzpNjtrUkBPq3UaqrnvOoB2F9RttSt811ui
github.com/kovidgoyal/go-parallel v1.1.1/go.mod h1:BJNIbe6+hxyFWv7n6oEDPj3PA5qSw5OCtf0hcVxWJiw=
github.com/kovidgoyal/go-shm v1.0.0 h1:HJEel9D1F9YhULvClEHJLawoRSj/1u/EDV7MJbBPgQo=
github.com/kovidgoyal/go-shm v1.0.0/go.mod h1:Yzb80Xf9L3kaoB2RGok9hHwMIt7Oif61kT6t3+VnZds=
github.com/kovidgoyal/imaging v1.8.19 h1:zWJdQqF2tfSKjvoB7XpLRhVGbYsze++M0iaqZ4ZkhNk=
github.com/kovidgoyal/imaging v1.8.19/go.mod h1:I0q8RdoEuyc4G8GFOF9CaluTUHQSf68d6TmsqpvfRI8=
github.com/kovidgoyal/imaging v1.8.20 h1:74GZ7C2rIm3rqmGEjK1GvvPOOnJ0SS5iDOa6Flfo0b0=
github.com/kovidgoyal/imaging v1.8.20/go.mod h1:d3phGYkTChGYkY4y++IjpHgUGhWGELDc2NEQAqxwZZg=
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=
@@ -624,8 +644,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
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.14.0 h1:CC2Ulb3kV7JFYt+izwORoS3VT/+Plb8BvslI/l1yZsc=
@@ -908,8 +928,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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
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=
@@ -928,8 +948,8 @@ golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjO
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/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.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
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=
@@ -955,8 +975,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.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
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=
@@ -999,16 +1019,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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
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.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
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=
@@ -1024,8 +1044,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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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=
@@ -1078,8 +1098,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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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=
@@ -1096,13 +1116,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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
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.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
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=
@@ -1153,8 +1173,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
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=
@@ -1273,18 +1293,18 @@ honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -1293,8 +1313,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+111 -161
View File
@@ -3,90 +3,52 @@ package http
import (
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
)
var (
ApiTokenUserCache = cache.NewCache[uint](24 * time.Hour) // API token to user ID mapping gets cached for 24 hours
)
// getUserFromApiToken finds the user ID associated with a given API token
func getUserFromApiToken(token string) (uint, error) {
// Check cache first
if cached, ok := ApiTokenUserCache.Get(token); ok {
return cached, nil
}
// Get all users
allUsers, err := store.Users.Gets()
if err != nil {
return 0, fmt.Errorf("failed to get all users: %w", err)
}
// Iterate through all users and their API keys
for _, user := range allUsers {
if user.ApiKeys == nil {
continue
}
for _, apiKey := range user.ApiKeys {
if apiKey.Key == token {
// Cache the result
ApiTokenUserCache.Set(token, user.ID)
return user.ID, nil
}
}
}
// Token not found
return 0, fmt.Errorf("token not found")
}
// createApiKeyHandler creates an API key for the user.
// @Summary Create API key
// @Description Create an API key with specified name, duration, and permissions.
// @Tags API Keys
// createApiTokenHandler creates an API token for the user.
// @Summary Create API Token
// @Description Create an API token with specified name, duration, and permissions.
// @Tags Auth
// @Accept json
// @Produce json
// @Param name query string true "Name of the API key"
// @Param days query string true "Duration of the API key in days"
// @Param permissions query string true "Permissions for the API key (comma-separated)"
// @Success 200 {object} HttpResponse "Token created successfully, response contains json object with token key"
// @Param name query string true "Name of the API token"
// @Param days query string true "Duration of the API token in days"
// @Param permissions query string true "Permissions for the API token (comma-separated)"
// @Success 200 {object} HttpResponse "Token created successfully, response contains json object with token"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 409 {object} map[string]string "Conflict"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/token [put]
func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// @Router /api/auth/token [post]
func createApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
name := r.URL.Query().Get("name")
durationStr := r.URL.Query().Get("days")
permissionsStr := r.URL.Query().Get("permissions")
minimal := r.URL.Query().Get("minimal") == "true"
minimal := permissionsStr == ""
if !d.user.Permissions.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to create api keys")
return http.StatusForbidden, fmt.Errorf("user does not have permission to create api tokens")
}
if name == "" {
return http.StatusBadRequest, fmt.Errorf("api name must be valid")
if name == "" || strings.HasPrefix(name, "WEB_TOKEN") {
return http.StatusBadRequest, fmt.Errorf("api token name must be valid")
}
if durationStr == "" {
return http.StatusBadRequest, fmt.Errorf("api duration must be valid")
return http.StatusBadRequest, fmt.Errorf("api token duration must be valid")
}
// For full tokens (minimal=false), permissions are required in the claim
// For minimal tokens (minimal=true), permissions are not in the token
var permissions users.Permissions
if !minimal {
if permissionsStr == "" {
return http.StatusBadRequest, fmt.Errorf("api permissions must be valid for full tokens")
}
// Parse permissions from the query parameter
permissions = users.Permissions{
Api: strings.Contains(permissionsStr, "api") && d.user.Permissions.Api,
@@ -108,156 +70,144 @@ func createApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// Here we assume the duration is in seconds; convert to time.Duration
duration := time.Duration(durationInt) * time.Hour * 24
// get request body like:
token, err := makeSignedTokenAPI(d.user, name, duration, permissions, minimal)
tokenString, authToken, err := auth.MakeSignedTokenAPI(d.user, name, duration, permissions, minimal)
if err != nil {
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, err
}
return http.StatusInternalServerError, err
}
// Store API token metadata in user's Tokens map
err = store.Users.AddApiToken(d.user.ID, name, tokenString, authToken)
if err != nil {
return http.StatusInternalServerError, err
}
// Store token hash → user ID mapping in access storage for fast lookups
err = store.Access.AddApiToken(tokenString, d.user.ID)
if err != nil {
return http.StatusInternalServerError, err
}
response := HttpResponse{
Message: "here is your token!",
Token: token.Key,
Token: tokenString,
}
return renderJSON(w, r, response)
}
// deleteApiKeyHandler deletes an API key for the user.
// @Summary Delete API key
// @Description Delete an API key with specified name.
// @Tags API Keys
// deleteApiTokenHandler deletes an API token for the user.
// @Summary Delete API token
// @Description Delete an API token with specified name.
// @Tags Auth
// @Accept json
// @Produce json
// @Param name query string true "Name of the API key to delete"
// @Success 200 {object} HttpResponse "API key deleted successfully"
// @Param name query string true "Name of the API token to delete"
// @Success 200 {object} HttpResponse "API token deleted successfully"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/token [delete]
func deleteApiKeyHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
func deleteApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
name := r.URL.Query().Get("name")
if !d.user.Permissions.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to delete api keys")
return http.StatusForbidden, fmt.Errorf("user does not have permission to delete api tokens")
}
keyInfo, ok := d.user.ApiKeys[name]
tokenInfo, ok := d.user.Tokens[name]
if !ok {
return http.StatusNotFound, fmt.Errorf("api key not found")
return http.StatusNotFound, fmt.Errorf("api token not found")
}
// Perform the user update
err := store.Users.DeleteApiKey(d.user.ID, name)
err := store.Users.DeleteApiToken(d.user.ID, name)
if err != nil {
return http.StatusNotFound, err
}
auth.RevokeAPIKey(keyInfo.Key) // add to blacklist
// Revoke the token (adds to RevokedTokens set)
if err := auth.RevokeApiToken(store.Access, tokenInfo.Token); err != nil {
logger.Errorf("Failed to revoke token: %v", err)
}
if err := store.Access.RemoveApiToken(tokenInfo.Token); err != nil {
logger.Errorf("Failed to remove api token: %v", err)
}
response := HttpResponse{
Message: "successfully deleted api key from user",
Message: "successfully deleted api token from user",
}
return renderJSON(w, r, response)
}
type AuthTokenMin struct {
Key string `json:"key"`
type AuthTokenFrontend struct {
Token string `json:"token"`
Name string `json:"name"`
Created int64 `json:"created"`
Expires int64 `json:"expires"`
IssuedAt int64 `json:"issuedAt"`
ExpiresAt int64 `json:"expiresAt"`
Permissions users.Permissions `json:"Permissions,omitempty"`
Minimal bool `json:"minimal"`
}
// listApiKeysHandler lists all API keys or retrieves details for a specific key.
// @Summary List API keys
// @Description List all API keys or retrieve details for a specific key.
// @Tags API Keys
// listApiTokensHandler lists all API tokens or retrieves details for a specific token.
// @Summary List API tokens
// @Description List all API tokens or retrieve details for a specific token.
// @Tags Auth
// @Accept json
// @Produce json
// @Param name query string false "Name of the API to retrieve details"
// @Success 200 {object} AuthTokenMin "List of API keys or specific key details"
// @Success 200 {array} AuthTokenFrontend "List of API tokens"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/tokens [get]
func listApiKeysHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
key := r.URL.Query().Get("name")
// @Router /api/auth/token/list [get]
func listApiTokensHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Permissions.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to list api keys")
return http.StatusForbidden, fmt.Errorf("user does not have permission to list api tokens")
}
if key != "" {
keyInfo, ok := d.user.ApiKeys[key]
if !ok {
return http.StatusNotFound, fmt.Errorf("api key not found")
}
// Determine if token is minimal by parsing the JWT claim
// For minimal tokens, the JWT claim doesn't have BelongsTo even though the database record does
minimal := false
if keyInfo.Key != "" {
keyFunc := func(token *jwt.Token) (interface{}, error) {
return []byte(config.Auth.Key), nil
}
var claim users.AuthToken
token, err := jwt.ParseWithClaims(keyInfo.Key, &claim, keyFunc)
if err == nil && token.Valid {
// If the parsed claim doesn't have BelongsTo, it's a minimal token
minimal = claim.BelongsTo == 0
}
}
modifiedKey := AuthTokenMin{
Key: keyInfo.Key,
Name: key,
Created: 0,
Expires: 0,
Minimal: minimal,
}
// Safely access IssuedAt and ExpiresAt (they're pointers)
if keyInfo.IssuedAt != nil {
modifiedKey.Created = keyInfo.IssuedAt.Unix()
}
if keyInfo.ExpiresAt != nil {
modifiedKey.Expires = keyInfo.ExpiresAt.Unix()
}
// Only include Permissions for full tokens (not minimal tokens)
if !minimal {
modifiedKey.Permissions = keyInfo.Permissions
}
return renderJSON(w, r, modifiedKey)
if len(d.user.Tokens) == 0 {
return http.StatusNotFound, fmt.Errorf("no api tokens found")
}
modifiedList := map[string]AuthTokenMin{}
for key, value := range d.user.ApiKeys {
// Determine if token is minimal by parsing the JWT claim
// For minimal tokens, the JWT claim doesn't have BelongsTo even though the database record does
minimal := false
if value.Key != "" {
keyFunc := func(token *jwt.Token) (interface{}, error) {
return []byte(config.Auth.Key), nil
}
var claim users.AuthToken
token, err := jwt.ParseWithClaims(value.Key, &claim, keyFunc)
if err == nil && token.Valid {
// If the parsed claim doesn't have BelongsTo, it's a minimal token
minimal = claim.BelongsTo == 0
}
}
modifiedKey := AuthTokenMin{
Key: value.Key,
Created: 0,
Expires: 0,
Minimal: minimal,
}
// Safely access IssuedAt and ExpiresAt (they're pointers)
if value.IssuedAt != nil {
modifiedKey.Created = value.IssuedAt.Unix()
}
if value.ExpiresAt != nil {
modifiedKey.Expires = value.ExpiresAt.Unix()
}
// Only include Permissions for full tokens (not minimal tokens)
if !minimal {
modifiedKey.Permissions = value.Permissions
}
modifiedList[key] = modifiedKey
AuthTokensFrontend := make([]AuthTokenFrontend, 0, len(d.user.Tokens))
for name, token := range d.user.Tokens {
AuthTokensFrontend = append(AuthTokensFrontend, AuthTokenFrontend{
Token: token.Token,
Name: name,
IssuedAt: token.RegisteredClaims.IssuedAt.Unix(),
ExpiresAt: token.RegisteredClaims.ExpiresAt.Unix(),
Permissions: token.Permissions,
})
}
sort.Slice(AuthTokensFrontend, func(i, j int) bool {
return AuthTokensFrontend[i].Name < AuthTokensFrontend[j].Name
})
return renderJSON(w, r, modifiedList)
return renderJSON(w, r, AuthTokensFrontend)
}
// getApiTokenHandler gets a specific API token.
// @Summary Get API token
// @Description Get a specific API token.
// @Tags Auth
// @Accept json
// @Produce json
// @Param name query string true "Name of the API token to retrieve"
// @Success 200 {object} AuthTokenFrontend "API token details"
// @Failure 404 {object} map[string]string "Not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/auth/token [get]
func getApiTokenHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
name := r.URL.Query().Get("name")
if !d.user.Permissions.Api {
return http.StatusForbidden, fmt.Errorf("user does not have permission to list api tokens")
}
tokenInfo, ok := d.user.Tokens[name]
if !ok {
return http.StatusNotFound, fmt.Errorf("api token not found")
}
AuthTokenFrontendResponse := AuthTokenFrontend{
Token: tokenInfo.Token,
Name: name,
IssuedAt: tokenInfo.IssuedAt,
ExpiresAt: tokenInfo.ExpiresAt,
Permissions: tokenInfo.Permissions,
}
return renderJSON(w, r, AuthTokenFrontendResponse)
}
+785
View File
@@ -0,0 +1,785 @@
package http
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/time/rate"
)
// archiveCreateHandler creates an archive on the server at the given destination.
// POST /resources/archive — server-side only; does not return archive data.
//
// @Summary Create an archive on the server
// @Description Creates a zip or tar.gz archive on the server from the given paths (files and/or directories). Server-side only; no archive bytes are returned. All items must be from the same source. Folders are walked recursively; access-denied paths are silently skipped. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.
// @Description
// @Description **Request body parameters:**
// @Description - **fromSource** (string, required): Source name where the paths to archive live. Example: `"default"`
// @Description - **toSource** (string, optional): Source name where the archive file will be written. Defaults to fromSource if omitted. Example: `"backups"`
// @Description - **paths** (array of strings, required): Paths of files or directories to add to the archive (relative to fromSource). Directories are walked; access-denied entries are skipped. Example: `["/docs/file.txt", "/photos"]`
// @Description - **destination** (string, required): Full path where the archive file will be created (on toSource). Must end with .zip or .tar.gz (or format is inferred). Example: `"/backups/my-archive.zip"`
// @Description - **format** (string, optional): Archive format. One of: `"zip"`, `"tar.gz"`. Default inferred from destination extension. Example: `"zip"`
// @Description - **compression** (integer, optional): Gzip compression level for tar.gz only (0–9). 0 = default. Ignored for zip. Example: `6`
// @Description - **deleteAfter** (boolean, optional): If true, delete source files/directories after successful creation. Requires delete permission. Example: `true`
// @Tags Resources
// @Accept json
// @Produce json
// @Param body body archiveCreateRequest true "Request body: fromSource, toSource (optional), paths, destination, format (optional), compression (optional)"
// @Success 200 {object} map[string]string "Created; returns {\"path\": \"<destination path>\"}"
// @Failure 400 {object} map[string]string "Invalid request (e.g. missing required field, invalid path)"
// @Failure 403 {object} map[string]string "Forbidden (create permission or access denied)"
// @Failure 404 {object} map[string]string "Source not found"
// @Failure 413 {object} map[string]string "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources/archive [post]
func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if d.share != nil {
return http.StatusForbidden, fmt.Errorf("archive create not allowed for shares")
}
if !d.user.Permissions.Create {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create resources")
}
var req archiveCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid JSON body: %v", err)
}
if req.FromSource == "" || len(req.Paths) == 0 || req.Destination == "" {
return http.StatusBadRequest, fmt.Errorf("fromSource, paths, and destination are required")
}
destClean, err := utils.SanitizeUserPath(req.Destination)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid destination path: %v", err)
}
req.Destination = destClean
pathsClean := make([]string, 0, len(req.Paths))
for _, p := range req.Paths {
var clean string
clean, err = utils.SanitizeUserPath(p)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path %q: %v", p, err)
}
pathsClean = append(pathsClean, clean)
}
req.Paths = pathsClean
destSource := req.ToSource
if destSource == "" {
destSource = req.FromSource
}
idx := indexing.GetIndex(req.FromSource)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", req.FromSource)
}
userScope, err := d.user.GetScopeForSourceName(req.FromSource)
if err != nil {
return http.StatusForbidden, err
}
// Resolve destination on ToSource (or Source if not set)
idxTo := indexing.GetIndex(destSource)
if idxTo == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", destSource)
}
userScopeTo, err := d.user.GetScopeForSourceName(destSource)
if err != nil {
return http.StatusForbidden, err
}
fullDest := utils.JoinPathAsUnix(userScopeTo, req.Destination)
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDest, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
}
// Destination is a file we are about to create; resolve parent dir only (parent must exist or be creatable).
destDir := filepath.Dir(req.Destination)
if destDir == "." {
destDir = "/"
}
fullDestDir := utils.JoinPathAsUnix(userScopeTo, destDir)
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestDir, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to destination directory %s", destDir)
}
destParentReal, _, err := idxTo.GetRealPath(fullDestDir)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("destination directory invalid: %v", err)
}
destRealPath := filepath.Join(destParentReal, filepath.Base(req.Destination))
if err = os.MkdirAll(destParentReal, fileutils.PermDir); err != nil {
return http.StatusInternalServerError, fmt.Errorf("cannot create destination directory: %v", err)
}
format := strings.ToLower(strings.TrimSpace(req.Format))
if format == "" {
ext := strings.ToLower(filepath.Ext(destRealPath))
if ext == ".gz" && len(destRealPath) > 3 && strings.HasSuffix(strings.ToLower(destRealPath), ".tar.gz") {
format = "tar.gz"
} else if ext == ".zip" {
format = "zip"
} else {
format = "zip"
}
}
if format != "zip" && format != "tar.gz" {
return http.StatusBadRequest, fmt.Errorf("format must be zip or tar.gz")
}
compression := req.Compression
if compression < 0 || compression > 9 {
compression = 0
}
// Build full paths for items (same source)
itemPaths := make([]string, 0, len(req.Paths))
for _, it := range req.Paths {
full := utils.JoinPathAsUnix(userScope, it)
if store.Access != nil && !store.Access.Permitted(idx.Path, full, d.user.Username) {
continue // silently skip
}
itemPaths = append(itemPaths, full)
}
if len(itemPaths) == 0 {
return http.StatusBadRequest, fmt.Errorf("no paths accessible; add at least one path you have access to")
}
// Check archive size limit if configured
if config.Server.MaxArchiveSizeGB > 0 {
var estimatedSize int64
estimatedSize, err = computeArchiveSize(req.FromSource, itemPaths, d)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to compute archive size: %v", err)
}
maxSizeBytes := config.Server.MaxArchiveSizeGB * 1024 * 1024 * 1024
if estimatedSize > maxSizeBytes {
return http.StatusRequestEntityTooLarge, fmt.Errorf("archive size would exceed the maximum allowed size (maxArchiveSize: %d GB)", config.Server.MaxArchiveSizeGB)
}
}
var createErr error
file, err := os.OpenFile(destRealPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return http.StatusInternalServerError, err
}
defer file.Close()
if format == "zip" {
createErr = createZip(d, req.FromSource, file, itemPaths...)
} else {
createErr = createTarGzWithLevel(d, req.FromSource, file, compression, itemPaths...)
}
if createErr != nil {
return http.StatusInternalServerError, createErr
}
if req.DeleteAfter && d.user.Permissions.Delete {
type itemToDelete struct {
realPath string
isDir bool
}
var toDelete []itemToDelete
for _, full := range itemPaths {
realPath, isDir, err := idx.GetRealPath(full)
if err != nil {
continue
}
toDelete = append(toDelete, itemToDelete{realPath: realPath, isDir: isDir})
}
for i := 0; i < len(toDelete); i++ {
for j := i + 1; j < len(toDelete); j++ {
if len(toDelete[j].realPath) > len(toDelete[i].realPath) {
toDelete[i], toDelete[j] = toDelete[j], toDelete[i]
}
}
}
for _, item := range toDelete {
if err := files.DeleteFiles(req.FromSource, item.realPath, item.isDir); err != nil {
logger.Errorf("Failed to delete source after archive: %v", err)
}
}
}
return renderJSON(w, r, map[string]string{"path": req.Destination}, http.StatusOK)
}
// unarchiveHandler extracts an archive on the server. POST /resources/unarchive — server-side only.
//
// @Summary Extract an archive on the server
// @Description Extracts a zip or tar.gz archive on the server into the given destination directory. Server-side only; no extracted bytes are returned. Supports extracting to a different source via toSource. Requires create permission. Archive size is checked against server.maxArchiveSizeGB limit if configured.
// @Description
// @Description **Request body parameters:**
// @Description - **fromSource** (string, required): Source name where the archive file lives. Example: `"default"`
// @Description - **toSource** (string, optional): Source name where contents will be extracted. Defaults to fromSource if omitted. Example: `"restored"`
// @Description - **path** (string, required): Path to the archive file (on fromSource). Must be .zip, .tar.gz, or .tgz. Example: `"/downloads/data.zip"`
// @Description - **destination** (string, required): Directory path (on toSource) to extract into. Example: `"/projects/imported"`
// @Description - **deleteAfter** (boolean, optional): If true, delete the archive file after successful extraction. Default: false. Example: `true`
// @Tags Resources
// @Accept json
// @Produce json
// @Param body body unarchiveRequest true "Request body: fromSource, toSource (optional), path, destination, deleteAfter (optional)"
// @Success 200 {object} map[string]string "Extracted; returns {\"path\": \"<destination path>\", \"source\": \"<toSource>\"}"
// @Failure 400 {object} map[string]string "Invalid request (e.g. missing required field, unsupported format)"
// @Failure 403 {object} map[string]string "Forbidden (create permission or access denied)"
// @Failure 404 {object} map[string]string "Source or archive file not found"
// @Failure 413 {object} map[string]string "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources/unarchive [post]
func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if d.share != nil {
return http.StatusForbidden, fmt.Errorf("unarchive not allowed for shares")
}
if !d.user.Permissions.Create {
return http.StatusForbidden, fmt.Errorf("user is not allowed to create resources")
}
var req unarchiveRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid JSON body: %v", err)
}
if req.FromSource == "" || req.Path == "" || req.Destination == "" {
return http.StatusBadRequest, fmt.Errorf("fromSource, path, and destination are required")
}
if req.ToSource == "" {
req.ToSource = req.FromSource
}
pathClean, err := utils.SanitizeUserPath(req.Path)
if err != nil {
return http.StatusBadRequest, err
}
destClean, err := utils.SanitizeUserPath(req.Destination)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid destination: %v", err)
}
req.Path = pathClean
req.Destination = destClean
idxFrom := indexing.GetIndex(req.FromSource)
if idxFrom == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", req.FromSource)
}
userScopeFrom, err := d.user.GetScopeForSourceName(req.FromSource)
if err != nil {
return http.StatusForbidden, err
}
fullArchivePath := utils.JoinPathAsUnix(userScopeFrom, req.Path)
if store.Access != nil && !store.Access.Permitted(idxFrom.Path, fullArchivePath, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to archive %s", req.Path)
}
archiveReal, _, err := idxFrom.GetRealPath(fullArchivePath)
if err != nil {
return http.StatusNotFound, fmt.Errorf("archive path not found: %v", err)
}
idxTo := indexing.GetIndex(req.ToSource)
if idxTo == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", req.ToSource)
}
userScopeTo, err := d.user.GetScopeForSourceName(req.ToSource)
if err != nil {
return http.StatusForbidden, err
}
fullDestPath := utils.JoinPathAsUnix(userScopeTo, req.Destination)
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestPath, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to destination %s", req.Destination)
}
// Destination may not exist yet; resolve parent dir then build destination path.
destDir := filepath.Dir(req.Destination)
if destDir == "." {
destDir = "/"
}
fullDestDir := utils.JoinPathAsUnix(userScopeTo, destDir)
if store.Access != nil && !store.Access.Permitted(idxTo.Path, fullDestDir, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to destination directory %s", destDir)
}
destParentReal, _, err := idxTo.GetRealPath(fullDestDir)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("destination directory invalid: %v", err)
}
destReal := filepath.Join(destParentReal, filepath.Base(req.Destination))
if err = os.MkdirAll(destReal, fileutils.PermDir); err != nil {
return http.StatusInternalServerError, fmt.Errorf("cannot create destination directory: %v", err)
}
info, err := os.Stat(archiveReal)
if err != nil {
return http.StatusNotFound, fmt.Errorf("archive not found: %v", err)
}
if info.IsDir() {
return http.StatusBadRequest, fmt.Errorf("path is not an archive file: %s", req.Path)
}
// Check archive size limit if configured
if config.Server.MaxArchiveSizeGB > 0 {
maxSizeBytes := config.Server.MaxArchiveSizeGB * 1024 * 1024 * 1024
if info.Size() > maxSizeBytes {
return http.StatusRequestEntityTooLarge, fmt.Errorf("archive size would exceed the maximum allowed size (maxArchiveSize: %d GB)", config.Server.MaxArchiveSizeGB)
}
}
lower := strings.ToLower(archiveReal)
var extractErr error
if strings.HasSuffix(lower, ".zip") {
extractErr = extractZip(archiveReal, destReal)
} else if strings.HasSuffix(lower, ".tar.gz") || (strings.HasSuffix(lower, ".tgz")) {
extractErr = extractTarGz(archiveReal, destReal)
} else {
return http.StatusBadRequest, fmt.Errorf("unsupported archive format (use .zip or .tar.gz)")
}
if extractErr != nil {
return http.StatusInternalServerError, extractErr
}
if req.DeleteAfter {
if err := os.Remove(archiveReal); err != nil {
logger.Errorf("Failed to delete archive after extract: %v", err)
}
}
return renderJSON(w, r, map[string]string{"path": req.Destination, "source": req.ToSource}, http.StatusOK)
}
// addFile adds a file or directory to a tar or zip archive, respecting access rules.
// For shares, path is already resolved; for users, access is checked via store.Access.
func addFile(source string, path string, d *requestContext, tarWriter *tar.Writer, zipWriter *zip.Writer, flatten bool) error {
idx := indexing.GetIndex(source)
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 !store.Access.Permitted(idx.Path, path, d.user.Username) {
return nil // Silently skip this file/folder
}
realPath, _, _ := idx.GetRealPath(path)
info, err := os.Stat(realPath)
if err != nil {
return err
}
// Get the base name of the top-level folder or file
baseName := filepath.Base(realPath)
if info.IsDir() {
// Walk through directory contents
return filepath.Walk(realPath, func(filePath string, fileInfo os.FileInfo, err error) error {
if err != nil {
return err
}
// Calculate the relative path
relPath, err := filepath.Rel(realPath, filePath)
if err != nil {
return err
}
// Normalize for tar: convert \ to /
relPath = filepath.ToSlash(relPath)
// Skip adding `.` (current directory)
if relPath == "." {
return nil
}
// Check access control for each file/folder during walk
if d.share == nil {
indexRelPath := utils.JoinPathAsUnix(path, relPath)
indexRelPath = filepath.ToSlash(indexRelPath)
if !store.Access.Permitted(idx.Path, indexRelPath, d.user.Username) {
if fileInfo.IsDir() {
return filepath.SkipDir
}
return nil
}
}
// Prepend base folder name unless flatten is true
if !flatten {
relPath = filepath.Join(baseName, relPath)
relPath = filepath.ToSlash(relPath)
}
if fileInfo.IsDir() {
if tarWriter != nil {
header := &tar.Header{
Name: relPath + "/",
Mode: int64(fileutils.PermDir),
Typeflag: tar.TypeDir,
ModTime: fileInfo.ModTime(),
}
return tarWriter.WriteHeader(header)
}
if zipWriter != nil {
_, err := zipWriter.Create(relPath + "/")
return err
}
return nil
}
return addSingleFile(filePath, relPath, zipWriter, tarWriter)
})
}
// For a single file, use the base name as the archive path
return addSingleFile(realPath, baseName, zipWriter, tarWriter)
}
// addSingleFile writes one file into the given zip or tar writer.
func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWriter *tar.Writer) error {
file, err := os.Open(realPath)
if err != nil {
if strings.Contains(err.Error(), "is a directory") {
return nil
}
return err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if tarWriter != nil {
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = filepath.ToSlash(archivePath)
if err = tarWriter.WriteHeader(header); err != nil {
return err
}
_, err = io.Copy(tarWriter, file)
return err
}
if zipWriter != nil {
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Name = archivePath
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return err
}
_, err = io.Copy(writer, file)
return err
}
return nil
}
// createZip writes a ZIP archive into w containing the given paths; access rules apply.
func createZip(d *requestContext, source string, w io.Writer, filenames ...string) error {
zipWriter := zip.NewWriter(w)
for _, filepath := range filenames {
err := addFile(source, filepath, d, nil, zipWriter, false)
if err != nil {
logger.Errorf("Failed to add %s to ZIP: %v", filepath, err)
return err
}
}
if err := zipWriter.Close(); err != nil {
return fmt.Errorf("failed to finalize ZIP archive: %w", err)
}
return nil
}
// createTarGz writes a tar.gz archive into w containing the given paths; access rules apply.
func createTarGz(d *requestContext, source string, w io.Writer, filenames ...string) error {
gzWriter := gzip.NewWriter(w)
tarWriter := tar.NewWriter(gzWriter)
for _, filepath := range filenames {
err := addFile(source, filepath, d, tarWriter, nil, false)
if err != nil {
logger.Errorf("Failed to add %s to TAR.GZ: %v", filepath, err)
return err
}
}
if err := tarWriter.Close(); err != nil {
return fmt.Errorf("failed to finalize TAR archive: %w", err)
}
if err := gzWriter.Close(); err != nil {
return fmt.Errorf("failed to finalize GZIP compression: %w", err)
}
return nil
}
// createTarGzWithLevel writes a tar.gz archive into w with the given gzip compression level (0=default, 1-9).
func createTarGzWithLevel(d *requestContext, source string, w io.Writer, level int, filenames ...string) error {
var gzWriter *gzip.Writer
if level >= 1 && level <= 9 {
var err error
gzWriter, err = gzip.NewWriterLevel(w, level)
if err != nil {
return err
}
} else {
gzWriter = gzip.NewWriter(w)
}
defer gzWriter.Close()
tarWriter := tar.NewWriter(gzWriter)
defer tarWriter.Close()
for _, filepath := range filenames {
err := addFile(source, filepath, d, tarWriter, nil, false)
if err != nil {
logger.Errorf("Failed to add %s to TAR.GZ: %v", filepath, err)
return err
}
}
if err := tarWriter.Close(); err != nil {
return fmt.Errorf("failed to finalize TAR archive: %w", err)
}
return nil
}
// BuildAndStreamArchive resolves paths, creates a zip or tar.gz archive, and streams it to w.
// It respects access rules. Used only by the raw handler for multi-file/directory download.
func BuildAndStreamArchive(w http.ResponseWriter, r *http.Request, d *requestContext, source string, fileList []string) (int, error) {
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
}
realPath, isDir, err := idx.GetRealPath(fileList[0])
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to get real path for %s: %v", fileList[0], err)
}
algo := r.URL.Query().Get("algo")
var extension string
switch algo {
case "zip", "true", "":
extension = ".zip"
case "tar.gz":
extension = ".tar.gz"
default:
return http.StatusInternalServerError, errors.New("format not implemented")
}
baseDirName := filepath.Base(filepath.Dir(realPath))
if baseDirName == "" || baseDirName == "/" {
baseDirName = "download"
}
if len(fileList) == 1 && isDir {
baseDirName = filepath.Base(realPath)
}
originalFileName := baseDirName + extension
setContentDisposition(w, r, originalFileName)
w.Header().Set("Content-Type", "application/octet-stream")
var writer io.Writer = w
if d.share != nil && d.share.MaxBandwidth > 0 {
limit := rate.Limit(d.share.MaxBandwidth * 1024)
burst := d.share.MaxBandwidth * 1024
writer = newThrottledWriter(w, limit, burst, r.Context())
}
if extension == ".zip" {
err = createZip(d, source, writer, fileList...)
} else {
err = createTarGz(d, source, writer, fileList...)
}
if err != nil {
return http.StatusInternalServerError, err
}
return 0, nil
}
// archiveCreateRequest is the body for POST /resources/archive (server-side create).
type archiveCreateRequest struct {
// Source name where the paths to archive live (required). Example: "default"
FromSource string `json:"fromSource"`
// Source name where the archive file will be written (optional; default: fromSource). Example: "backups"
ToSource string `json:"toSource"`
// Paths of files or directories to add; directories are walked; access-denied entries skipped (required). Example: ["/docs/file.txt", "/photos"]
Paths []string `json:"paths"`
// Full path where the archive will be created; use .zip or .tar.gz extension (required). Example: "/backups/my-archive.zip"
Destination string `json:"destination"`
// Archive format: "zip" or "tar.gz" (optional; inferred from destination if omitted). Example: "zip"
Format string `json:"format"`
// Gzip compression level for tar.gz only, 0-9; 0 = default; ignored for zip (optional). Example: 6
Compression int `json:"compression"`
// If true, delete the source files/directories after successful archive creation (optional; requires delete permission). Example: true
DeleteAfter bool `json:"deleteAfter"`
}
// unarchiveRequest is the body for POST /resources/unarchive (server-side extract).
type unarchiveRequest struct {
// Source name where the archive file lives (required). Example: "default"
FromSource string `json:"fromSource"`
// Source name where contents will be extracted (optional; default: fromSource). Example: "restored"
ToSource string `json:"toSource"`
// Path to the archive file on fromSource; .zip, .tar.gz, or .tgz (required). Example: "/downloads/data.zip"
Path string `json:"path"`
// Directory path on toSource to extract into (required). Example: "/projects/imported"
Destination string `json:"destination"`
// If true, delete the archive file after successful extraction (optional; default: false). Example: true
DeleteAfter bool `json:"deleteAfter"`
}
// safeExtractPath ensures name does not escape destDir (no ".." or absolute).
func safeExtractPath(destDir, name string) (string, error) {
clean := filepath.Clean(name)
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("invalid entry path: %s", name)
}
abs := filepath.Join(destDir, clean)
destAbs, err := filepath.Abs(destDir)
if err != nil {
return "", err
}
entryAbs, err := filepath.Abs(abs)
if err != nil {
return "", err
}
if !strings.HasPrefix(entryAbs, destAbs) {
return "", fmt.Errorf("invalid entry path: %s", name)
}
return abs, nil
}
func extractZip(archivePath, destDir string) error {
r, err := zip.OpenReader(archivePath)
if err != nil {
return err
}
defer r.Close()
for _, f := range r.File {
destPath, err := safeExtractPath(destDir, f.Name)
if err != nil {
return err
}
if f.FileInfo().IsDir() {
if err = os.MkdirAll(destPath, fileutils.PermDir); err != nil {
return err
}
continue
}
if err = os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
return err
}
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
out.Close()
return err
}
_, err = io.Copy(out, rc)
rc.Close()
out.Close()
if err != nil {
return err
}
}
return nil
}
func extractTarGz(archivePath, destDir string) error {
f, err := os.Open(archivePath)
if err != nil {
return err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
destPath, err := safeExtractPath(destDir, h.Name)
if err != nil {
return err
}
switch h.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(destPath, fileutils.PermDir); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
return err
}
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
}
if _, err := io.Copy(out, tr); err != nil {
out.Close()
return err
}
out.Close()
}
}
return nil
}
// computeArchiveSize returns the combined size of the given paths, respecting access rules.
// Paths denied by access are skipped (not counted).
func computeArchiveSize(source string, fileList []string, d *requestContext) (int64, error) {
var estimatedSize int64
idx := indexing.GetIndex(source)
if idx == nil {
return 0, fmt.Errorf("source %s is not available", source)
}
for _, path := range fileList {
if !store.Access.Permitted(idx.Path, path, d.user.Username) {
continue
}
realPath, isDir, err := idx.GetRealPath(path)
if err != nil {
return 0, err
}
indexPath := idx.MakeIndexPath(realPath, isDir)
info, ok := idx.GetReducedMetadata(indexPath, isDir)
if !ok {
info, err = idx.GetFsInfo(indexPath, false, true)
if err != nil {
return 0, fmt.Errorf("failed to get file info for %s : %v", path, err)
}
}
estimatedSize += info.Size
}
return estimatedSize, nil
}
+129 -107
View File
@@ -11,7 +11,6 @@ import (
"strings"
"time"
jwt "github.com/golang-jwt/jwt/v4"
"github.com/golang-jwt/jwt/v4/request"
"golang.org/x/crypto/bcrypt"
@@ -87,25 +86,22 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
if err.Error() != "the resource does not exist" {
return nil, err
}
if config.Auth.Methods.ProxyAuth.CreateUser {
user := users.User{
LoginMethod: users.LoginMethodProxy,
Username: proxyUser,
}
settings.ApplyUserDefaults(&user)
if user.Username == config.Auth.AdminUsername {
user.Permissions.Admin = true
}
err = storage.CreateUser(user, user.Permissions)
if err != nil {
return nil, err
}
data.user, err = store.Users.Get(proxyUser)
if err != nil {
return nil, err
}
} else {
return nil, fmt.Errorf("proxy authentication failed - no user found")
// Auto-create user on first proxy authentication
user := users.User{
LoginMethod: users.LoginMethodProxy,
Username: proxyUser,
}
settings.ApplyUserDefaults(&user)
if user.Username == config.Auth.AdminUsername {
user.Permissions.Admin = true
}
err = storage.CreateUser(user, user.Permissions)
if err != nil {
return nil, err
}
data.user, err = store.Users.Get(proxyUser)
if err != nil {
return nil, err
}
}
if data.user.LoginMethod != users.LoginMethodProxy {
@@ -121,6 +117,99 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
return data.user, nil
}
// setupJwtUser retrieves or creates a user based on external JWT token claims
func setupJwtUser(r *http.Request, data *requestContext, username string, claims map[string]interface{}) (*users.User, error) {
var err error
// Retrieve the user from the store
data.user, err = store.Users.Get(username)
if err != nil {
if err.Error() != "the resource does not exist" {
return nil, err
}
// Auto-create user on first JWT authentication
user := users.User{
LoginMethod: users.LoginMethodJwt,
Username: username,
}
settings.ApplyUserDefaults(&user)
// Check if user should be admin based on groups
if config.Auth.Methods.JwtAuth.AdminGroup != "" {
groups := auth.ExtractGroupsFromClaims(claims, config.Auth.Methods.JwtAuth.GroupsClaim)
for _, group := range groups {
if group == config.Auth.Methods.JwtAuth.AdminGroup {
user.Permissions.Admin = true
break
}
}
}
// Also check if username matches admin username
if user.Username == config.Auth.AdminUsername {
user.Permissions.Admin = true
}
err = storage.CreateUser(user, user.Permissions)
if err != nil {
return nil, err
}
data.user, err = store.Users.Get(username)
if err != nil {
return nil, err
}
}
// Verify login method matches
if data.user.LoginMethod != users.LoginMethodJwt {
return nil, errors.ErrWrongLoginMethod
}
// Check if user is in allowed groups (if UserGroups is configured)
if len(config.Auth.Methods.JwtAuth.UserGroups) > 0 {
groups := auth.ExtractGroupsFromClaims(claims, config.Auth.Methods.JwtAuth.GroupsClaim)
allowed := false
for _, userGroup := range groups {
for _, allowedGroup := range config.Auth.Methods.JwtAuth.UserGroups {
if userGroup == allowedGroup {
allowed = true
break
}
}
if allowed {
break
}
}
if !allowed {
return nil, fmt.Errorf("user is not in allowed groups")
}
}
// Update admin status if needed
shouldBeAdmin := false
if config.Auth.Methods.JwtAuth.AdminGroup != "" {
groups := auth.ExtractGroupsFromClaims(claims, config.Auth.Methods.JwtAuth.GroupsClaim)
for _, group := range groups {
if group == config.Auth.Methods.JwtAuth.AdminGroup {
shouldBeAdmin = true
break
}
}
}
if data.user.Username == config.Auth.AdminUsername {
shouldBeAdmin = true
}
if shouldBeAdmin != data.user.Permissions.Admin {
data.user.Permissions.Admin = shouldBeAdmin
err = store.Users.Update(data.user, true, "Permissions")
if err != nil {
return nil, err
}
}
return data.user, nil
}
// loginHandler handles user authentication via password.
// @Summary User login
// @Description Authenticate a user with a username and password. The password must be URL-encoded and sent in the X-Password header to support special characters (e.g., ^, %, £, €, etc.).
@@ -136,16 +225,13 @@ func setupProxyUser(r *http.Request, data *requestContext, proxyUser string) (*u
// @Failure 500 {object} map[string]string "Internal server error"
// @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 == ""
if passwordUser && enforcedOtp && missingOtp {
return http.StatusForbidden, errors.ErrNoTotpConfigured
}
return printToken(w, r, d.user) // Pass the data object
return printToken(w, r, d.user)
}
// logoutHandler handles user logout
@@ -157,7 +243,9 @@ func loginHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
// @Success 200 {object} map[string]string "{"logoutUrl": "http://..."}"
// @Router /api/auth/logout [post]
func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
defer auth.RevokeAPIKey(d.token)
if err := auth.RevokeApiToken(store.Access, d.token); err != nil {
logger.Errorf("Failed to revoke token on logout: %v", err)
}
// 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
@@ -170,7 +258,7 @@ func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
Value: "",
Domain: strings.Split(host, ":")[0],
Path: "/",
SameSite: http.SameSiteLaxMode,
SameSite: http.SameSiteStrictMode,
Expires: time.Unix(0, 0), // Expire immediately
MaxAge: -1, // Delete cookie
}
@@ -187,6 +275,16 @@ func logoutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
if oidcRedirectUrl != "" {
logoutUrl = oidcRedirectUrl
}
} else if d.user != nil && d.user.LoginMethod == users.LoginMethodLdap {
ldapRedirectUrl := config.Auth.Methods.LdapAuth.LogoutRedirectUrl
if ldapRedirectUrl != "" {
logoutUrl = ldapRedirectUrl
}
} else if d.user != nil && d.user.LoginMethod == users.LoginMethodJwt {
jwtRedirectUrl := config.Auth.Methods.JwtAuth.LogoutRedirectUrl
if jwtRedirectUrl != "" {
logoutUrl = jwtRedirectUrl
}
}
if logoutUrl == "" {
logger.Debug("no logout url found, using default")
@@ -257,7 +355,7 @@ func signupHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
// @Success 200 {string} string "New JWT token generated"
// @Failure 401 {object} map[string]string "Unauthorized - invalid token"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/renew [post]
// @Router /api/auth/renew [post]
func renewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// check if x-auth header is present and token is
return printToken(w, r, d.user)
@@ -265,7 +363,7 @@ func renewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
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, false)
tokenString, _, err := auth.MakeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
if err != nil {
if strings.Contains(err.Error(), "key already exists with same name") {
return http.StatusConflict, err
@@ -277,90 +375,16 @@ func printToken(w http.ResponseWriter, r *http.Request, user *users.User) (int,
// This allows backend to identify expired sessions and provide better user feedback
expiresTime := time.Now().Add(expires).Add(time.Minute * 30)
setSessionCookie(w, r, signed.Key, expiresTime)
setSessionCookie(w, r, tokenString, expiresTime)
// 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 {
if _, err := w.Write([]byte(tokenString)); err != nil {
return 401, errors.ErrUnauthorized
}
return 0, nil
}
func makeSignedTokenAPI(user *users.User, name string, duration time.Duration, perms users.Permissions, minimal bool) (users.AuthToken, error) {
_, ok := user.ApiKeys[name]
if ok {
return users.AuthToken{}, fmt.Errorf("key already exists with same name %v ", name)
}
now := time.Now()
expires := now.Add(duration)
var tokenString string
var err error
if minimal {
// Create minimal token with only JWT standard claims
minimalClaim := users.MinimalAuthToken{
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
Issuer: "FileBrowser Quantum",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, minimalClaim)
tokenString, err = token.SignedString([]byte(config.Auth.Key))
if err != nil {
return users.AuthToken{}, err
}
} else {
// Create full token with permissions and user ID
fullClaim := users.AuthToken{
MinimalAuthToken: users.MinimalAuthToken{
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
Issuer: "FileBrowser Quantum",
},
},
Name: name,
Permissions: perms,
BelongsTo: user.ID,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, fullClaim)
tokenString, err = token.SignedString([]byte(config.Auth.Key))
if err != nil {
return users.AuthToken{}, err
}
}
// Create the AuthToken to store in database (always includes permissions and user ID)
storedClaim := users.AuthToken{
MinimalAuthToken: users.MinimalAuthToken{
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
Issuer: "FileBrowser Quantum",
},
},
Key: tokenString,
Name: name,
Permissions: perms,
BelongsTo: user.ID,
}
if strings.HasPrefix(name, "WEB_TOKEN") {
// don't add to api tokens, its a short lived web token
return storedClaim, nil
}
// Perform the user update
err = store.Users.AddApiKey(user.ID, name, storedClaim)
if err != nil {
return storedClaim, err
}
return storedClaim, nil
}
func authenticateShareRequest(r *http.Request, l *share.Link) (int, error) {
if l.PasswordHash == "" {
return 200, nil
@@ -418,10 +442,8 @@ func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expi
Value: token,
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
SameSite: http.SameSiteStrictMode, // strict mode prevents cookie from being sent to other domains
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)
}
+304
View File
@@ -0,0 +1,304 @@
package http
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"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"
)
// throttledReadSeeker is a wrapper around an io.ReadSeeker that throttles the reading speed.
type throttledReadSeeker struct {
rs io.ReadSeeker
limiter *rate.Limiter
ctx context.Context
}
// throttledWriter is a wrapper around an io.Writer that throttles the writing speed.
type throttledWriter struct {
w io.Writer
limiter *rate.Limiter
ctx context.Context
}
// newThrottledReadSeeker creates a new throttledReadSeeker.
func newThrottledReadSeeker(rs io.ReadSeeker, limit rate.Limit, burst int, ctx context.Context) *throttledReadSeeker {
return &throttledReadSeeker{
rs: rs,
limiter: rate.NewLimiter(limit, burst),
ctx: ctx,
}
}
func (r *throttledReadSeeker) Read(p []byte) (n int, err error) {
n, err = r.rs.Read(p)
if n > 0 {
if waitErr := r.limiter.WaitN(r.ctx, n); waitErr != nil {
// The original error (like io.EOF) is potentially more important
// than the context error.
if err == nil {
err = waitErr
}
}
}
return
}
func (r *throttledReadSeeker) Seek(offset int64, whence int) (int64, error) {
return r.rs.Seek(offset, whence)
}
// newThrottledWriter creates a new throttledWriter.
func newThrottledWriter(w io.Writer, limit rate.Limit, burst int, ctx context.Context) *throttledWriter {
return &throttledWriter{
w: w,
limiter: rate.NewLimiter(limit, burst),
ctx: ctx,
}
}
func (w *throttledWriter) Write(p []byte) (n int, err error) {
n, err = w.w.Write(p)
if n > 0 {
if waitErr := w.limiter.WaitN(w.ctx, n); waitErr != nil {
if err != nil {
// The original error (like io.EOF) is potentially more important
// than the context error.
err = waitErr
}
}
}
return
}
// toASCIIFilename converts a filename to ASCII-safe format by replacing non-ASCII characters with underscores
func toASCIIFilename(fileName string) string {
var result strings.Builder
for _, r := range fileName {
if r > 127 {
// Replace non-ASCII characters with underscore
result.WriteRune('_')
} else {
result.WriteRune(r)
}
}
return result.String()
}
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
dispositionType := "attachment"
if r.URL.Query().Get("inline") == "true" {
dispositionType = "inline"
}
// standard: ASCII-only safe fallback
asciiFileName := toASCIIFilename(fileName)
// RFC 5987: UTF-8 encoded
encodedFileName := url.PathEscape(fileName)
// Always set both filename (ASCII) and filename* (UTF-8) for maximum compatibility (RFC 6266)
w.Header().Set("Content-Disposition", fmt.Sprintf("%s; filename=%q; filename*=utf-8''%s", dispositionType, asciiFileName, encodedFileName))
}
// downloadHandler serves the raw content of a file, multiple files, or directory in various formats.
// @Summary Download content of a file, multiple files, or directory
// @Description Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.
// @Description
// @Description **Filename Encoding:**
// @Description - The Content-Disposition header will always include both:
// @Description 1. `filename="..."`: An ASCII-safe version of the filename for compatibility.
// @Description 2. `filename*=utf-8"...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.
// @Description
// @Description **Multiple Files:**
// @Description - Use repeated query parameters: `?file=file1.txt&file=file2.txt&file=file3.txt`
// @Description - This supports filenames containing commas and special characters
// @Tags Resources
// @Accept json
// @Param source query string true "Source name for the files (required)"
// @Param file query []string true "File path (can be repeated for multiple files)"
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
// @Failure 202 {object} map[string]string "Modify permissions required"
// @Failure 400 {object} map[string]string "Invalid request path"
// @Failure 404 {object} map[string]string "File or directory not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources/download [get]
func downloadHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
source := r.URL.Query().Get("source")
fileList := r.URL.Query()["file"]
// Rule 1: Validate all user-provided file paths to prevent path traversal
for i, filePath := range fileList {
cleanPath, err := utils.SanitizeUserPath(filePath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid file path: %v", err)
}
fileList[i] = cleanPath
}
return rawFilesHandler(w, r, d, source, fileList)
}
func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext, source string, fileList []string) (int, error) {
if !d.user.Permissions.Download && d.share == nil {
return http.StatusForbidden, fmt.Errorf("user is not allowed to download")
}
if len(fileList) == 0 && d.share == nil {
return http.StatusBadRequest, fmt.Errorf("no files specified")
}
firstFilePath := fileList[0]
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
// modify all filepaths for user scope
if d.share == nil {
userscope, err = d.user.GetScopeForSourceName(source)
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(source)
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
}
for i, filePath := range fileList {
fileList[i] = utils.JoinPathAsUnix(userscope, filePath)
}
}
firstFilePath = fileList[0]
// For shares, the path is already correctly resolved by publicRawHandler
idx := indexing.GetIndex(source)
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", source))
}
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
}
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
}
// ** 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()
// 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")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
// serve content allows for range requests.
// video scrubbing, etc.
// Note: http.ServeContent will respect our already-set Content-Disposition header
var reader io.ReadSeeker = fd
if d.share != nil && d.share.MaxBandwidth > 0 {
// convert KB/s to B/s
limit := rate.Limit(d.share.MaxBandwidth * 1024)
// burst size can be the same as limit
burst := d.share.MaxBandwidth * 1024
reader = newThrottledReadSeeker(fd, limit, burst, r.Context())
}
http.ServeContent(w, r, fileName, fileInfo.ModTime(), reader)
return 200, nil
}
// ** Archive (ZIP/TAR.GZ) handling ** — delegate to archive package
return BuildAndStreamArchive(w, r, d, source, fileList)
}
// isOnlyOfficeCompatibleFile checks if a file extension is supported by OnlyOffice
func isOnlyOfficeCompatibleFile(fileName string) bool {
return iteminfo.IsOnlyOffice(fileName)
}
+2 -2
View File
@@ -133,7 +133,7 @@ func (s *duplicateProcessingStats) shouldStop() (bool, string) {
//
// @Summary Find Duplicate Files
// @Description Finds duplicate files using multi-stage filtering: size → type → fuzzy filename → progressive checksums. Files must match on size, MIME type, and have 50%+ filename similarity before checksum verification. Large fuzzy groups (>10 files) are skipped to avoid false positives. Checksums use 2-pass progressive verification (header → middle) for accuracy while minimizing disk I/O (~16KB read per file).
// @Tags Duplicates
// @Tags Tools
// @Accept json
// @Produce json
// @Param source query string true "Source name for the desired source"
@@ -142,7 +142,7 @@ func (s *duplicateProcessingStats) shouldStop() (bool, string) {
// @Success 200 {object} duplicateResponse "List of duplicate file groups with metadata. Response includes 'incomplete' flag if processing stopped early due to resource limits."
// @Failure 400 {object} map[string]string "Bad Request"
// @Failure 503 {object} map[string]string "Service Unavailable (indexing in progress or another search running)"
// @Router /api/duplicates [get]
// @Router /api/tools/duplicateFinder [get]
func duplicatesHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
opts, err := prepDuplicatesOptions(r, d)
if err != nil {
+70 -80
View File
@@ -26,10 +26,9 @@ type fileWatchResponse struct {
// fileWatchMetadata contains file information for non-text files
type fileWatchMetadata struct {
Name string `json:"name"` // File name
Size int64 `json:"size"` // File size in bytes
Size int64 `json:"size"` // File size in bytes (for directories, total size of all files)
Type string `json:"type"` // MIME type
Modified time.Time `json:"modified"` // Modification time
Path string `json:"path"` // File path
}
// readLastNLines reads the last N lines from a file efficiently
@@ -123,7 +122,7 @@ func readLastNLines(filePath string, n int) (string, error) {
// @Failure 403 {object} map[string]string "Permission denied"
// @Failure 404 {object} map[string]string "File not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/tools/watch [get]
// @Router /api/tools/fileWatcher [get]
func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// Check for latency check request - return immediately with minimal response
if r.URL.Query().Get("latencyCheck") != "" {
@@ -140,7 +139,7 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
var err error
path, err = utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
return http.StatusBadRequest, err
}
// Parse lines parameter
@@ -190,55 +189,42 @@ func fileWatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
}
// Verify it's a file, not a directory
// Get file/directory info
info, err := os.Stat(realPath)
if err != nil {
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
}
if info.IsDir() {
return http.StatusBadRequest, fmt.Errorf("path is a directory, not a file")
}
// Check if file is a text file
isText, err := utils.IsTextFile(realPath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error checking file type: %v", err)
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
}
// Get MIME type from the index if available
mimeType := "application/octet-stream"
reducedInfo, exists := idx.GetReducedMetadata(scopePath, false)
if exists && reducedInfo.Type != "" && reducedInfo.Type != "directory" {
if exists && reducedInfo.Type != "" {
mimeType = reducedInfo.Type
}
response := fileWatchResponse{
IsText: isText,
response := fileWatchResponse{}
// Handle directory - just return metadata, no content
response.IsText = false
response.Metadata = &fileWatchMetadata{
Name: info.Name(),
Size: 0, // Directories don't have a meaningful size
Type: mimeType,
Modified: info.ModTime(),
}
if isText {
// Read the last N lines for text files
content, err := readLastNLines(realPath, lines)
if !info.IsDir() {
// Handle regular file
// Check if file is a text file
isText, err := utils.IsTextFile(realPath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error reading file: %v", err)
return http.StatusInternalServerError, fmt.Errorf("error checking file type: %v", err)
}
response.Contents = content
// Include metadata for text files too (for file size display)
response.Metadata = &fileWatchMetadata{
Name: info.Name(),
Size: info.Size(),
Type: mimeType,
Modified: info.ModTime(),
Path: path,
}
} else {
// For non-text files, return metadata
response.Metadata = &fileWatchMetadata{
Name: info.Name(),
Size: info.Size(),
Type: mimeType,
Modified: info.ModTime(),
Path: path,
if isText {
// Read the last N lines for text files only
content, err := readLastNLines(realPath, lines)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error reading file: %v", err)
}
response.Contents = content
}
}
@@ -265,7 +251,7 @@ type fileWatchSSEEvent struct {
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 403 {object} map[string]string "Permission denied"
// @Failure 404 {object} map[string]string "File not found"
// @Router /api/tools/watch/sse [get]
// @Router /api/tools/fileWatcher/sse [get]
func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// Check realtime permissions
if !(d.user.Permissions.Realtime) {
@@ -289,7 +275,7 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
return http.StatusBadRequest, err
}
path = cleanPath
@@ -346,16 +332,13 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// Get real file path
realPath, _, err := idx.GetRealPath(scopePath)
if err != nil {
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
}
// Verify it's a file, not a directory
// Get file/directory info
info, err := os.Stat(realPath)
if err != nil {
return http.StatusNotFound, fmt.Errorf("file not found: %v", err)
}
if info.IsDir() {
return http.StatusBadRequest, fmt.Errorf("path is a directory, not a file")
return http.StatusNotFound, fmt.Errorf("path not found: %v", err)
}
// Set up SSE headers
@@ -386,10 +369,13 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// Get MIME type (we'll reuse this)
mimeType := "application/octet-stream"
reducedInfo, exists := idx.GetReducedMetadata(scopePath, false)
if exists && reducedInfo.Type != "" && reducedInfo.Type != "directory" {
if exists && reducedInfo.Type != "" {
mimeType = reducedInfo.Type
}
// Determine if this is a directory
isDir := info.IsDir()
// Start background goroutine to periodically send file updates via events system
stopTicker := make(chan struct{})
go func() {
@@ -397,14 +383,14 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
defer ticker.Stop()
// Send initial update
sendFileWatchUpdate(username, realPath, path, lines, mimeType)
sendFileWatchUpdate(username, realPath, path, source, lines, mimeType, isDir)
for {
select {
case <-stopTicker:
return
case <-ticker.C:
sendFileWatchUpdate(username, realPath, path, lines, mimeType)
sendFileWatchUpdate(username, realPath, path, source, lines, mimeType, isDir)
}
}
}()
@@ -443,54 +429,58 @@ func fileWatchSSEHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
}
// sendFileWatchUpdate reads the file and sends an update via the events system
func sendFileWatchUpdate(username, realPath, path string, lines int, mimeType string) {
// Re-check file (in case it was deleted or changed)
// sendFileWatchUpdate reads the file/directory and sends an update via the events system
func sendFileWatchUpdate(username, realPath, path, source string, lines int, mimeType string, isDir bool) {
// Re-check path (in case it was deleted or changed)
info, err := os.Stat(realPath)
if err != nil {
// File no longer exists
errorMsg, _ := json.Marshal(map[string]interface{}{"status": "error", "error": "file not found"})
// Path no longer exists
errorMsg, _ := json.Marshal(map[string]interface{}{"status": "error", "error": "path not found"})
events.SendToUsers("fileWatch", string(errorMsg), []string{username})
return
}
// Check if file is a text file
isText, err := utils.IsTextFile(realPath)
if err != nil {
// Error checking file, skip this update
return
}
// Build the SSE event
sseEvent := fileWatchSSEEvent{
IsText: isText,
}
sseEvent := fileWatchSSEEvent{}
if isText {
// Read the last N lines for text files
var content string
content, err = readLastNLines(realPath, lines)
if err != nil {
// Error reading, skip this update
return
}
sseEvent.Contents = content
if isDir {
// Handle directory - just return metadata, no content
sseEvent.IsText = false
sseEvent.Metadata = &fileWatchMetadata{
Name: info.Name(),
Size: info.Size(),
Type: mimeType,
Size: 0, // Directories don't have a meaningful size
Type: "directory",
Modified: info.ModTime(),
Path: path,
}
} else {
// For non-text files, send metadata
// Handle regular file
// Check if file is a text file
var isText bool
isText, err = utils.IsTextFile(realPath)
if err != nil {
// Error checking file, skip this update
return
}
sseEvent.IsText = isText
sseEvent.Metadata = &fileWatchMetadata{
Name: info.Name(),
Size: info.Size(),
Type: mimeType,
Modified: info.ModTime(),
Path: path,
}
if isText {
// Read the last N lines for text files only
var content string
content, err = readLastNLines(realPath, lines)
if err != nil {
// Error reading, skip this update
return
}
sseEvent.Contents = content
}
// For non-text files, Contents stays empty - frontend displays metadata
}
// Serialize and send via events system
+99 -74
View File
@@ -6,10 +6,10 @@ import (
"embed"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"net"
"net/http"
"text/template"
"time"
_ "net/http/pprof"
@@ -74,51 +74,67 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
devMode: settings.Env.IsDevMode,
}
// Initialize cached PWA manifest
InitializePWAManifest()
// Core routing
router := http.NewServeMux()
// API group routing
api := http.NewServeMux()
// Public group routing (new structure)
publicRoutes := http.NewServeMux()
publicApi := http.NewServeMux()
// User routes
// ========================================
// User Routes - /api/users/ (with public routes)
// ========================================
api.HandleFunc("GET /users", withUser(userGetHandler))
api.HandleFunc("POST /users", withSelfOrAdmin(usersPostHandler))
api.HandleFunc("PUT /users", withUser(userPutHandler))
api.HandleFunc("DELETE /users", withSelfOrAdmin(userDeleteHandler))
api.HandleFunc("GET /health", healthHandler)
publicApi.HandleFunc("GET /users", withUser(userGetHandler))
// Auth routes
// ========================================
// Auth Routes - /api/auth/
// ========================================
api.HandleFunc("POST /auth/login", userWithoutOTP(loginHandler))
api.HandleFunc("POST /auth/logout", withOrWithoutUser(logoutHandler))
api.HandleFunc("POST /auth/signup", withoutUser(signupHandler))
api.HandleFunc("POST /auth/otp/generate", userWithoutOTP(generateOTPHandler))
api.HandleFunc("POST /auth/otp/verify", userWithoutOTP(verifyOTPHandler))
api.HandleFunc("POST /auth/renew", withUser(renewHandler))
api.HandleFunc("PUT /auth/token", withUser(createApiKeyHandler))
api.HandleFunc("GET /auth/token", withUser(createApiKeyHandler))
api.HandleFunc("DELETE /auth/token", withUser(deleteApiKeyHandler))
api.HandleFunc("GET /auth/tokens", withUser(listApiKeysHandler))
api.HandleFunc("POST /auth/token", withUser(createApiTokenHandler))
api.HandleFunc("DELETE /auth/token", withUser(deleteApiTokenHandler))
api.HandleFunc("GET /auth/token/list", withUser(listApiTokensHandler))
api.HandleFunc("GET /auth/token", withUser(getApiTokenHandler))
api.HandleFunc("GET /auth/oidc/callback", wrapHandler(oidcCallbackHandler))
api.HandleFunc("GET /auth/oidc/login", wrapHandler(oidcLoginHandler))
// Resources routes
// ========================================
// Resources Routes - /api/resources/ (with public routes)
// ========================================
api.HandleFunc("GET /resources", withUser(resourceGetHandler))
api.HandleFunc("GET /resources/items", withUser(itemsGetHandler))
api.HandleFunc("DELETE /resources", withUser(resourceDeleteHandler))
api.HandleFunc("POST /resources", withUser(resourcePostHandler))
api.HandleFunc("PUT /resources", withUser(resourcePutHandler))
api.HandleFunc("PATCH /resources", withUser(resourcePatchHandler))
api.HandleFunc("POST /resources/bulk/delete", withUser(resourceBulkDeleteHandler))
api.HandleFunc("GET /raw", withUser(rawHandler))
api.HandleFunc("GET /preview", withTimeout(60*time.Second, withUserHelper(previewHandler)))
api.HandleFunc("GET /media/subtitles", withUser(subtitlesHandler))
if settings.Env.IsDevMode {
api.HandleFunc("GET /inspectIndex", inspectIndex)
api.HandleFunc("GET /mockData", mockData)
}
// Access routes
api.HandleFunc("DELETE /resources/bulk", withUser(resourceBulkDeleteHandler))
api.HandleFunc("POST /resources/archive", withUser(archiveCreateHandler))
api.HandleFunc("POST /resources/unarchive", withUser(unarchiveHandler))
api.HandleFunc("GET /resources/download", withUser(downloadHandler))
api.HandleFunc("GET /resources/preview", withTimeout(60*time.Second, withUserHelper(previewHandler)))
publicApi.HandleFunc("GET /resources", withHashFile(publicGetResourceHandler))
publicApi.HandleFunc("GET /resources/items", withHashFile(publicItemsGetHandler))
publicApi.HandleFunc("POST /resources", withHashFile(publicUploadHandler))
publicApi.HandleFunc("PUT /resources", withHashFile(publicPutHandler))
publicApi.HandleFunc("DELETE /resources", withHashFile(publicDeleteHandler))
publicApi.HandleFunc("DELETE /resources/bulk", withHashFile(publicBulkDeleteHandler))
publicApi.HandleFunc("PATCH /resources", withHashFile(publicPatchHandler))
publicApi.HandleFunc("GET /resources/download", withHashFile(publicDownloadHandler))
publicApi.HandleFunc("GET /resources/preview", withHashFile(publicPreviewHandler))
// Legacy routes (backwards compatibility)
api.HandleFunc("GET /raw", withUser(downloadHandler))
publicApi.HandleFunc("GET /raw", withHashFile(publicDownloadHandler))
// ========================================
// Access Routes - /api/access/
// ========================================
api.HandleFunc("GET /access", withAdmin(accessGetHandler))
api.HandleFunc("POST /access", withAdmin(accessPostHandler))
api.HandleFunc("PATCH /access", withAdmin(accessPatchHandler))
@@ -127,87 +143,96 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
api.HandleFunc("POST /access/group", withAdmin(groupPostHandler))
api.HandleFunc("DELETE /access/group", withAdmin(groupDeleteHandler))
// Share management routes (require permission)
api.HandleFunc("GET /shares", withPermShare(shareListHandler))
// ========================================
// Share Routes - /api/share/
// ========================================
api.HandleFunc("GET /share/list", withPermShare(shareListHandler))
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))
publicApi.HandleFunc("GET /share/info", withOrWithoutUser(shareInfoHandler))
publicApi.HandleFunc("GET /share/image", withHashFile(getShareImage))
// Create API sub-router for public API endpoints
publicAPI := http.NewServeMux()
// NEW PUBLIC ROUTES - All publicly accessible endpoints
// Public API routes (hash-based authentication)
publicAPI.HandleFunc("GET /raw", withHashFile(publicRawHandler))
publicAPI.HandleFunc("GET /preview", withTimeout(60*time.Second, withHashFileHelper(publicPreviewHandler)))
publicAPI.HandleFunc("GET /resources", withHashFile(publicGetResourceHandler))
publicAPI.HandleFunc("GET /users", withUser(userGetHandler))
publicAPI.HandleFunc("POST /onlyoffice/callback", withHashFile(onlyofficeCallbackHandler))
publicAPI.HandleFunc("GET /onlyoffice/callback", withHashFile(onlyofficeCallbackHandler))
publicAPI.HandleFunc("GET /onlyoffice/config", withHashFile(onlyofficeClientConfigGetHandler))
publicAPI.HandleFunc("POST /resources", withHashFile(publicUploadHandler))
publicAPI.HandleFunc("PUT /resources", withHashFile(publicPutHandler))
publicAPI.HandleFunc("DELETE /resources", withHashFile(publicDeleteHandler))
publicAPI.HandleFunc("POST /resources/bulk/delete", withHashFile(publicBulkDeleteHandler))
publicAPI.HandleFunc("PATCH /resources", withHashFile(publicPatchHandler))
publicAPI.HandleFunc("GET /shareinfo", withOrWithoutUser(shareInfoHandler))
publicAPI.HandleFunc("GET /share/image", withHashFile(getShareImage))
// Settings routes
// ========================================
// Settings Routes - /api/settings/
// ========================================
api.HandleFunc("GET /settings", withAdmin(settingsGetHandler))
api.HandleFunc("GET /settings/config", withAdmin(settingsConfigHandler))
api.HandleFunc("GET /settings/sources", withUser(getSourceInfoHandler))
// Events routes
api.HandleFunc("GET /events", withUser(func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
d.ctx = ctx // Pass the parent context to ensure proper shutdown
return sseHandler(w, r, d)
}))
// Job routes
api.HandleFunc("GET /jobs/{action}/{target}", withUser(getJobsHandler))
// ========================================
// Tools Routes - /api/tools/
// ========================================
api.HandleFunc("GET /tools/search", withUser(searchHandler))
api.HandleFunc("GET /tools/duplicateFinder", withUser(duplicatesHandler))
api.HandleFunc("GET /tools/fileWatcher", withUser(fileWatchHandler))
api.HandleFunc("GET /tools/fileWatcher/sse", withUser(fileWatchSSEHandler))
api.HandleFunc("GET /onlyoffice/config", withUser(onlyofficeClientConfigGetHandler))
api.HandleFunc("POST /onlyoffice/callback", withUser(onlyofficeCallbackHandler))
api.HandleFunc("GET /onlyoffice/callback", withUser(onlyofficeCallbackHandler))
// ========================================
// Media Routes - /api/media/
// ========================================
api.HandleFunc("GET /media/subtitles", withUser(subtitlesHandler))
api.HandleFunc("GET /search", withUser(searchHandler))
api.HandleFunc("GET /duplicates", withUser(duplicatesHandler))
api.HandleFunc("GET /tools/watch", withUser(fileWatchHandler))
api.HandleFunc("GET /tools/watch/sse", withUser(fileWatchSSEHandler))
// Mount the public API sub-router
publicRoutes.Handle("/api/", http.StripPrefix("/api", publicAPI))
// ========================================
// OnlyOffice Routes - /api/office/ (with public routes)
// ========================================
api.HandleFunc("GET /office/config", withUser(onlyofficeClientConfigGetHandler))
api.HandleFunc("POST /office/callback", withUser(onlyofficeCallbackHandler))
api.HandleFunc("GET /office/callback", withUser(onlyofficeCallbackHandler))
publicApi.HandleFunc("POST /office/callback", withHashFile(onlyofficeCallbackHandler))
publicApi.HandleFunc("GET /office/callback", withHashFile(onlyofficeCallbackHandler))
publicApi.HandleFunc("GET /office/config", withHashFile(onlyofficeClientConfigGetHandler))
// Mount the route groups
// ========================================
// Misc Routes
// ========================================
api.HandleFunc("GET /health", healthHandler)
api.HandleFunc("GET /events", withUser(sseHandler))
if settings.Env.IsDevMode {
api.HandleFunc("GET /inspectIndex", inspectIndex)
api.HandleFunc("GET /mockData", mockData)
}
// Mount public API
publicRoutes.Handle("/api/", http.StripPrefix("/api", publicApi))
// ========================================
// Configure Main Router
// ========================================
apiPath := config.Server.BaseURL + "api"
publicPath := config.Server.BaseURL + "public"
webDavPath := config.Server.BaseURL + "dav"
// Mount primary API and public routes
router.Handle(apiPath+"/", http.StripPrefix(apiPath, api))
router.Handle(publicPath+"/", http.StripPrefix(publicPath, publicRoutes))
// WebDav resources (catch-all)
// similar to NextCloud and others
// (reddec) do not trim /dav prefix here - webdav library for some reason does not like it.
router.Handle(webDavPath+"/{scope}/{path...}", wrapHandlerOpts(withUserHelper(createWebDAVHandler(webDavPath)), wrapperOpts{
requestBasicAuth: true,
}))
// WebDAV handler
if !config.Server.DisableWebDAV {
// Uses Basic Auth where password is JWT token
// Note: do not trim /dav prefix here - webdav library requires it
router.Handle(webDavPath+"/{source}/{path...}", withBasicAuth(webDAVHandler))
}
// Frontend share route redirect (DEPRECATED - maintain for backwards compatibility)
// Playwright tests need updating to remove this redirect.
// TODO: 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
// New frontend share route handler
publicRoutes.HandleFunc("GET /share/", withOrWithoutUser(indexHandler))
// Static and index file handlers
// Static assets
publicRoutes.Handle("GET /static/", http.HandlerFunc(staticAssetHandler))
// Standard browser favicon route
router.HandleFunc("GET /favicon.svg", http.HandlerFunc(staticAssetHandler))
// Index and utility routes
router.HandleFunc(config.Server.BaseURL, withOrWithoutUser(indexHandler))
router.HandleFunc(fmt.Sprintf("GET %vhealth", config.Server.BaseURL), healthHandler)
router.Handle(fmt.Sprintf("%vswagger/", config.Server.BaseURL), withUser(swaggerHandler))
// redirect to baseUrl if not root
// Base URL redirect (non-root deployments)
if config.Server.BaseURL != "/" {
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, config.Server.BaseURL, http.StatusMovedPermanently)
-33
View File
@@ -1,33 +0,0 @@
package http
import (
"net/http"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/go-logger/logger"
)
// getJobsHandler returns job info for the user.
// @Summary Get jobs info
// @Description Returns job info for the user.
// @Tags Jobs
// @Accept json
// @Produce json
// @Param action path string true "Job action"
// @Param target path string true "Job target"
// @Success 200 {object} map[string]indexing.ReducedIndex "Job info"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/jobs/{action}/{target} [get]
func getJobsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sources := d.user.GetSourceNames()
reducedIndexes := map[string]indexing.ReducedIndex{}
for _, source := range sources {
reducedIndex, err := indexing.GetIndexInfo(source, false)
if err != nil {
logger.Debugf("error getting index info: %v", err)
continue
}
reducedIndexes[source] = reducedIndex
}
return renderJSON(w, r, reducedIndexes)
}
+263
View File
@@ -0,0 +1,263 @@
package http
import (
"crypto/tls"
"fmt"
"strings"
ldap "github.com/go-ldap/ldap/v3"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// AuthenticateLDAPUser attempts LDAP authentication and returns the filebrowser user if successful.
func AuthenticateLDAPUser(username, password string) (*users.User, error) {
username = strings.TrimSpace(username)
if username == "" {
return nil, fmt.Errorf("username required")
}
groups, userAttributes, err := authenticateLDAP(username, password)
if err != nil {
logger.Debugf("ldap authentication failed: %v", err)
return nil, err
}
// Use the configured UserIdentifier if available, otherwise use the login username
ldapCfg := settings.Config.Auth.Methods.LdapAuth
mappedUsername := username
if ldapCfg.UserIdentifier != "" {
if val, ok := userAttributes[ldapCfg.UserIdentifier]; ok && val != "" {
mappedUsername = val
logger.Debugf("ldap: mapped username from %s: %s -> %s", ldapCfg.UserIdentifier, username, mappedUsername)
} else {
logger.Warningf("ldap: userIdentifier '%s' not found in LDAP entry for user %s, using login username", ldapCfg.UserIdentifier, username)
}
}
logger.Debugf("ldap authentication successful, getting or creating user %s", mappedUsername)
return getOrCreateLdapUser(mappedUsername, groups)
}
func authenticateLDAP(username, password string) ([]string, map[string]string, error) {
c := settings.Config.Auth.Methods.LdapAuth
logger.Debugf("ldap: connecting to %s", c.Server)
var opts []ldap.DialOpt
if c.DisableVerifyTLS && c.Scheme == "ldaps" {
opts = append(opts, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true}))
logger.Warning("LDAP TLS verification is disabled.")
}
conn, err := ldap.DialURL(c.Server, opts...)
if err != nil {
return nil, nil, fmt.Errorf("ldap connect: %w", err)
}
defer conn.Close()
// Bind with service account
if c.UserPassword != "" {
logger.Debugf("ldap: binding as service account %s", c.UserDN)
if err = conn.Bind(c.UserDN, c.UserPassword); err != nil {
return nil, nil, fmt.Errorf("ldap bind (service): %w", err)
}
} else {
logger.Debugf("ldap: no service account bind (userPassword empty)")
}
// Build list of attributes to fetch
groupAttr := c.GroupsClaim
if groupAttr == "" {
groupAttr = "memberOf"
}
attributes := []string{"dn", groupAttr}
if c.UserIdentifier != "" {
attributes = append(attributes, c.UserIdentifier)
}
filter := fmt.Sprintf(c.UserFilter, ldap.EscapeFilter(username))
searchRequest := ldap.NewSearchRequest(
c.BaseDN,
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases,
0,
0,
false,
filter,
attributes,
nil,
)
result, err := conn.Search(searchRequest)
if err != nil {
return nil, nil, fmt.Errorf("ldap search (%s): %w", c.Server, err)
}
if len(result.Entries) == 0 {
return nil, nil, fmt.Errorf("user not found: %s (LDAP search for %s returned no entries)", username, filter)
}
entry := result.Entries[0]
if len(result.Entries) > 1 {
// Prefer the user entry when multiple match (e.g. Authentik returns user + virtual group with same cn).
if u := pickUserEntry(result.Entries); u != nil {
entry = u
logger.Debugf("ldap: multiple entries, using user entry DN=%s", entry.DN)
} else {
return nil, nil, fmt.Errorf("multiple entries for user: %s (set userFilter to narrow, e.g. (&(cn=%%s)(objectClass=user)))", username)
}
}
userDN := entry.DN
// Verify password by binding as the user
if err := conn.Bind(userDN, password); err != nil {
logger.Debugf("ldap: bind as user failed: %v", err)
return nil, nil, fmt.Errorf("ldap bind (user): %w", err)
}
// Re-bind as service account for any follow-up (we're done; this is optional)
if c.UserPassword != "" {
_ = conn.Bind(c.UserDN, c.UserPassword)
}
groups := entry.GetAttributeValues(groupAttr)
// Extract user attributes for identifier mapping
userAttributes := make(map[string]string)
if c.UserIdentifier != "" {
values := entry.GetAttributeValues(c.UserIdentifier)
if len(values) > 0 {
userAttributes[c.UserIdentifier] = values[0]
}
}
return groups, userAttributes, nil
}
// pickUserEntry returns the entry that represents a user when multiple entries match (e.g. Authentik user + virtual group).
// Prefers entries with objectClass=user or DN under ou=users; returns nil if none or multiple user entries.
func pickUserEntry(entries []*ldap.Entry) *ldap.Entry {
var user *ldap.Entry
for _, e := range entries {
oc := e.GetAttributeValues("objectClass")
dnLower := strings.ToLower(e.DN)
isUser := false
for _, c := range oc {
if strings.EqualFold(c, "user") {
isUser = true
break
}
}
if !isUser && strings.Contains(dnLower, "ou=users") {
isUser = true
}
if isUser {
if user != nil {
return nil // multiple user entries
}
user = e
}
}
return user
}
// ldapGroupMatchesAdmin returns true if the LDAP group DN matches the configured admin group (full DN or CN value).
func ldapGroupMatchesAdmin(groupDN, adminGroup string) bool {
g := strings.TrimSpace(groupDN)
if g == adminGroup {
return true
}
dn, err := ldap.ParseDN(g)
if err != nil {
return false
}
for _, rdn := range dn.RDNs {
for _, attr := range rdn.Attributes {
if strings.EqualFold(attr.Type, "cn") && attr.Value == adminGroup {
return true
}
}
}
return false
}
// getOrCreateLdapUser returns the filebrowser user for an LDAP-authenticated username, creating one if configured.
func getOrCreateLdapUser(username string, groups []string) (*users.User, error) {
logger.Debugf("getting or creating ldap user %s", username)
ldapCfg := config.Auth.Methods.LdapAuth
// Check if user is in required groups (if userGroups is configured)
if len(ldapCfg.UserGroups) > 0 {
userInAllowedGroup := false
for _, allowedGroup := range ldapCfg.UserGroups {
for _, userGroup := range groups {
if ldapGroupMatchesAdmin(userGroup, allowedGroup) {
userInAllowedGroup = true
break
}
}
if userInAllowedGroup {
break
}
}
if !userInAllowedGroup {
logger.Warningf("User %s is not in any of the required groups %v. Access denied.", username, ldapCfg.UserGroups)
return nil, fmt.Errorf("user %s is not authorized to access this application (not in required groups)", username)
}
logger.Debugf("User %s is in required group, allowing access.", username)
}
isAdmin := false
if ldapCfg.AdminGroup != "" {
for _, g := range groups {
if ldapGroupMatchesAdmin(g, ldapCfg.AdminGroup) {
isAdmin = true
break
}
}
if isAdmin {
logger.Debugf("User %s is in admin group %s, granting admin privileges.", username, ldapCfg.AdminGroup)
}
}
user, err := store.Users.Get(username)
if err != nil {
if err.Error() != "the resource does not exist" {
return nil, err
}
// Auto-create user on first LDAP authentication
if ldapCfg.AdminGroup == "" {
isAdmin = config.UserDefaults.Permissions.Admin
}
user = &users.User{
Username: username,
LoginMethod: users.LoginMethodLdap,
}
settings.ApplyUserDefaults(user)
if isAdmin {
user.Permissions.Admin = true
}
if err = storage.CreateUser(*user, user.Permissions); err != nil {
return nil, err
}
user, err = store.Users.Get(username)
if err != nil {
return nil, err
}
} else {
if user.LoginMethod != users.LoginMethodLdap {
return nil, errors.ErrWrongLoginMethod
}
if ldapCfg.AdminGroup != "" && isAdmin != user.Permissions.Admin {
user.Permissions.Admin = isAdmin
_ = store.Users.Update(user, true, "Permissions")
}
}
if err := store.Access.SyncUserGroups(username, groups); err != nil {
logger.Warningf("failed to sync ldap user %s groups: %v", username, err)
}
return user, nil
}
+157
View File
@@ -0,0 +1,157 @@
package http
import (
"testing"
ldap "github.com/go-ldap/ldap/v3"
)
// newLDAPEntry creates a minimal *ldap.Entry for testing (DN and optional objectClass).
func newLDAPEntry(dn string, objectClasses []string) *ldap.Entry {
attrs := []*ldap.EntryAttribute{}
if len(objectClasses) > 0 {
attrs = append(attrs, &ldap.EntryAttribute{Name: "objectClass", Values: objectClasses})
}
return &ldap.Entry{DN: dn, Attributes: attrs}
}
func TestPickUserEntry(t *testing.T) {
tests := []struct {
name string
entries []*ldap.Entry
wantNil bool
wantDN string // if wantNil is false, expected entry DN
}{
{
name: "single user entry by objectClass",
entries: []*ldap.Entry{
newLDAPEntry("cn=alice,ou=users,dc=test", []string{"user", "organizationalPerson"}),
},
wantNil: false,
wantDN: "cn=alice,ou=users,dc=test",
},
{
name: "single user entry by ou=users in DN",
entries: []*ldap.Entry{
newLDAPEntry("cn=bob,ou=users,dc=example,dc=com", []string{"group"}),
},
wantNil: false,
wantDN: "cn=bob,ou=users,dc=example,dc=com",
},
{
name: "user and virtual group - picks user",
entries: []*ldap.Entry{
newLDAPEntry("cn=akadmin,ou=virtual-groups,dc=test", []string{"group"}),
newLDAPEntry("cn=akadmin,ou=users,dc=test", []string{"user"}),
},
wantNil: false,
wantDN: "cn=akadmin,ou=users,dc=test",
},
{
name: "two user entries - returns nil",
entries: []*ldap.Entry{
newLDAPEntry("cn=u1,ou=users,dc=test", []string{"user"}),
newLDAPEntry("cn=u2,ou=users,dc=test", []string{"user"}),
},
wantNil: true,
},
{
name: "only group entries - returns nil",
entries: []*ldap.Entry{
newLDAPEntry("cn=admins,ou=groups,dc=test", []string{"group"}),
newLDAPEntry("cn=akadmin,ou=virtual-groups,dc=test", []string{"group"}),
},
wantNil: true,
},
{
name: "empty entries",
entries: []*ldap.Entry{},
wantNil: true,
},
{
name: "objectClass case insensitive",
entries: []*ldap.Entry{
newLDAPEntry("cn=u,ou=users,dc=test", []string{"USER"}),
},
wantNil: false,
wantDN: "cn=u,ou=users,dc=test",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := pickUserEntry(tt.entries)
if tt.wantNil {
if got != nil {
t.Errorf("pickUserEntry() = %v, want nil", got)
}
return
}
if got == nil {
t.Fatal("pickUserEntry() = nil, want non-nil entry")
}
if got.DN != tt.wantDN {
t.Errorf("pickUserEntry() DN = %v, want %v", got.DN, tt.wantDN)
}
})
}
}
func TestLdapGroupMatchesAdmin(t *testing.T) {
tests := []struct {
name string
groupDN string
adminGroup string
want bool
}{
{
name: "exact DN match",
groupDN: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
adminGroup: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
want: true,
},
{
name: "CN match",
groupDN: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
adminGroup: "authentik Admins",
want: true,
},
{
name: "CN no match",
groupDN: "cn=Other Group,ou=groups,dc=test",
adminGroup: "authentik Admins",
want: false,
},
{
name: "exact no match",
groupDN: "cn=admins,ou=groups,dc=test",
adminGroup: "cn=authentik Admins,ou=groups,dc=ldap,dc=goauthentik,dc=io",
want: false,
},
{
name: "whitespace trimmed",
groupDN: " cn=admins,ou=groups,dc=test ",
adminGroup: "cn=admins,ou=groups,dc=test",
want: true,
},
{
name: "invalid DN returns false",
groupDN: "not-a-valid-dn",
adminGroup: "admins",
want: false,
},
{
name: "empty adminGroup no match",
groupDN: "cn=admins,ou=groups,dc=test",
adminGroup: "",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ldapGroupMatchesAdmin(tt.groupDN, tt.adminGroup)
if got != tt.want {
t.Errorf("ldapGroupMatchesAdmin(%q, %q) = %v, want %v", tt.groupDN, tt.adminGroup, got, tt.want)
}
})
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// subtitlesHandler handles subtitle requests for both external files and embedded streams
// @Summary Get subtitle content
// @Description Returns raw subtitle content from external files or embedded streams
// @Tags Subtitles
// @Tags Resources
// @Accept json
// @Produce text/plain
// @Param path query string true "Index path to the video file"
+195 -52
View File
@@ -5,7 +5,9 @@ import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"runtime"
"slices"
"strings"
@@ -113,8 +115,8 @@ func withHashFileHelper(fn handleFunc) handleFunc {
pathWithoutUserScope = pathWithoutUserScope + "/"
}
data.IndexPath = pathWithoutUserScope
if r.Method == "POST" && strings.Contains(r.URL.Path, "/resources") {
// no need to fetch file info for uploads
// skip file fetch for certain apis
if r.Method == "POST" && strings.Contains(r.URL.Path, "/resources") || r.Method == "GET" && strings.Contains(r.URL.Path, "/resources/items") {
return fn(w, r, data)
}
file, err := FileInfoFasterFunc(utils.FileOptions{
@@ -127,7 +129,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && link.ExtractEmbeddedSubtitles,
ShowHidden: link.ShowHidden,
FollowSymlinks: true,
}, store.Access, data.shareUser)
}, store.Access, data.shareUser, store.Share)
if err != nil {
logger.Errorf("error fetching file info for share. hash=%v path=%v error=%v", hash, path, err)
return errToStatus(err), fmt.Errorf("error fetching share from server")
@@ -312,16 +314,53 @@ func withoutUserHelper(fn handleFunc) handleFunc {
// allow user without OTP to pass
func userWithoutOTPhelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// This middleware is used when no user authentication is required
// Call the actual handler function with the updated context
username := r.URL.Query().Get("username")
password := r.Header.Get("X-Password")
if username == "" || password == "" {
return withUserHelper(fn)(w, r, d)
} else {
if !config.Auth.Methods.PasswordAuth.Enabled {
return 401, errors.ErrUnauthorized
// Check if request has a valid admin token first
if tokenStr, err := extractToken(r); err == nil && tokenStr != "" {
keyFunc := func(token *jwt.Token) (interface{}, error) {
return []byte(config.Auth.Key), nil
}
var tk users.AuthToken
if token, err := jwt.ParseWithClaims(tokenStr, &tk, keyFunc); err == nil && token.Valid {
if !auth.IsRevokedApiToken(store.Access, tokenStr) {
if tk.BelongsTo == 0 {
// Minimal token - look up user ID
userID, found := store.Access.GetUserIDFromToken(tokenStr)
if found {
tk.BelongsTo = userID
}
}
if tk.BelongsTo > 0 {
user, err := store.Users.Get(tk.BelongsTo)
if err == nil && user.Permissions.Admin {
// Valid admin token - allow operation
d.user = user
return fn(w, r, d)
}
}
}
}
}
// Try LDAP first if enabled; on success set d.user and continue to handler
if config.Auth.Methods.LdapAuth.Enabled {
// No valid admin token - proceed with username/password authentication
username := r.URL.Query().Get("username")
password := r.Header.Get("X-Password")
// URL-decode password to support special characters in headers
password, err := url.QueryUnescape(password)
if err != nil {
return 401, fmt.Errorf("invalid password encoding")
}
logger.Debug("ldap auth, calling AuthenticateLDAPUser")
ldapUser, err := AuthenticateLDAPUser(username, password)
if err == nil {
logger.Debugf("ldap auth successful, calling handler")
d.user = ldapUser
return fn(w, r, d)
}
logger.Debug("ldap auth failed, calling password auth", err)
}
if config.Auth.Methods.PasswordAuth.Enabled {
// Get the authentication method from the settings
auther, err := store.Auth.Get("password")
if err != nil {
@@ -330,14 +369,16 @@ func userWithoutOTPhelper(fn handleFunc) handleFunc {
// Authenticate the user based on the request
user, err := auther.Auth(r, store.Users)
if err != nil {
logger.Debug("password auth failed, calling handler", err)
if err == errors.ErrNoTotpProvided {
return 403, err
}
return 401, errors.ErrUnauthorized
}
d.user = user
return fn(w, r, d)
}
return fn(w, r, d)
return withUserHelper(fn)(w, r, d)
}
}
@@ -357,58 +398,75 @@ func withUserHelper(fn handleFunc) handleFunc {
}
return fn(w, r, data)
}
// Check for JWT external auth first (header or query param)
if config.Auth.Methods.JwtAuth.Enabled {
jwtToken := r.Header.Get(config.Auth.Methods.JwtAuth.Header)
if jwtToken == "" {
// Check query parameter (hardcoded to "jwt")
jwtToken = r.URL.Query().Get("jwt")
}
if jwtToken != "" {
return getJwtUser(w, r, data, fn, jwtToken)
}
}
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
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 && !isProxyUser {
return http.StatusUnauthorized, err
if data.token == "" {
var err error
data.token, err = extractToken(r)
if err != nil && !isProxyUser {
return http.StatusUnauthorized, err
}
}
data.token = tokenString
var tk users.AuthToken
token, err := jwt.ParseWithClaims(tokenString, &tk, keyFunc)
token, err := jwt.ParseWithClaims(data.token, &tk, keyFunc)
if err != nil {
if isProxyUser {
return getProxyUser(w, r, data, fn, proxyUser)
}
// JWT library automatically validates expiration - if expired, it returns an error
return http.StatusUnauthorized, fmt.Errorf("error processing token, %v", err)
return http.StatusUnauthorized, fmt.Errorf("invalid token: %v", err)
}
if !token.Valid {
return http.StatusUnauthorized, fmt.Errorf("invalid token")
}
if auth.IsRevokedApiKey(data.token) {
if isProxyUser {
return getProxyUser(w, r, data, fn, proxyUser)
}
return http.StatusUnauthorized, fmt.Errorf("token revoked")
if auth.IsRevokedApiToken(store.Access, data.token) {
return http.StatusUnauthorized, fmt.Errorf("token is expired or revoked")
}
// ExpiresAt should always be set in valid tokens created by our system
// If it's nil, the token is invalid
if tk.ExpiresAt == nil {
return http.StatusUnauthorized, fmt.Errorf("invalid token: missing expiration")
// JWT library populates RegisteredClaims.ExpiresAt
if tk.RegisteredClaims.ExpiresAt == nil {
return http.StatusUnauthorized, fmt.Errorf("token is invalid or revoked")
}
// Check if token is about to expire for renewal header
if tk.ExpiresAt.Unix() < time.Now().Add(time.Minute*30).Unix() {
if tk.RegisteredClaims.ExpiresAt.Unix() < time.Now().Add(time.Minute*30).Unix() {
w.Header().Add("X-Renew-Token", "true")
}
// Check if token is minimal/stateful (no BelongsTo in claim)
if tk.BelongsTo == 0 {
tk.BelongsTo, err = getUserFromApiToken(data.token)
if err != nil {
return http.StatusUnauthorized, err
// Hash the token and look up user ID in access storage
userID, found := store.Access.GetUserIDFromToken(data.token)
if !found {
return http.StatusUnauthorized, fmt.Errorf("token is invalid or revoked")
}
tk.BelongsTo = userID
}
data.user, err = store.Users.Get(tk.BelongsTo)
if err != nil {
logger.Errorf("Failed to get user with ID %v: %v", tk.BelongsTo, err)
return http.StatusInternalServerError, err
}
// Set cookie. Some clients like gvfs relies on it for concurrent uploads
if expire := tk.ExpiresAt; expire != nil {
setSessionCookie(w, r, tokenString, expire.Time)
if tk.RegisteredClaims.ExpiresAt != nil {
setSessionCookie(w, r, data.token, tk.RegisteredClaims.ExpiresAt.Time)
}
setUserInResponseWriter(w, data.user)
if data.user.Username == "" {
@@ -422,6 +480,48 @@ func withUserHelper(fn handleFunc) handleFunc {
}
}
func getJwtUser(w http.ResponseWriter, r *http.Request, data *requestContext, fn handleFunc, jwtToken string) (int, error) {
// Verify the external JWT token
username, claims, err := auth.VerifyExternalJWT(
jwtToken,
config.Auth.Methods.JwtAuth.Secret,
config.Auth.Methods.JwtAuth.Algorithm,
config.Auth.Methods.JwtAuth.UserIdentifier,
)
if err != nil {
logger.Debugf("JWT verification failed: %v", err)
return http.StatusForbidden, fmt.Errorf("JWT authentication failed: %w", err)
}
// Setup user based on JWT claims
user, err := setupJwtUser(r, data, username, claims)
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 FileBrowser session token for JWT users if they don't have one
if data.token == "" {
expires := time.Hour * time.Duration(config.Auth.TokenExpirationHours)
tokenString, _, err := auth.MakeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
if err != nil {
logger.Errorf("Failed to generate token for JWT user %s: %v", username, err)
return http.StatusInternalServerError, fmt.Errorf("failed to generate token")
}
data.token = tokenString
}
// 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)
}
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)
@@ -436,12 +536,12 @@ func getProxyUser(w http.ResponseWriter, r *http.Request, data *requestContext,
// 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, false)
tokenString, _, err := auth.MakeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
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
data.token = tokenString
}
// Call the handler function, passing in the context (or return OK if no handler)
if fn == nil {
@@ -463,16 +563,10 @@ func withSelfOrAdminHelper(fn handleFunc) handleFunc {
}
func wrapHandler(fn handleFunc) http.HandlerFunc {
return wrapHandlerOpts(fn, wrapperOpts{})
}
type wrapperOpts struct {
requestBasicAuth bool
}
func wrapHandlerOpts(fn handleFunc, opts wrapperOpts) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := &requestContext{}
data := &requestContext{
ctx: r.Context(),
}
// Call the actual handler function and get status code and error
status, err := fn(w, r, data)
@@ -484,10 +578,6 @@ func wrapHandlerOpts(fn handleFunc, opts wrapperOpts) http.HandlerFunc {
Message: err.Error(),
}
if status == http.StatusUnauthorized && opts.requestBasicAuth {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
}
// Set the content type to JSON and status code
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
@@ -514,6 +604,21 @@ func wrapHandlerOpts(fn handleFunc, opts wrapperOpts) http.HandlerFunc {
}
}
// wrapHandlerBasicAuth wraps a handler and automatically sets WWW-Authenticate header
// for 401 Unauthorized responses, triggering Basic Auth challenge
func wrapHandlerBasicAuth(fn handleFunc) http.HandlerFunc {
// Wrap the handler to set WWW-Authenticate header for 401 responses
wrappedFn := func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
status, err := fn(w, r, data)
// Set WWW-Authenticate header before returning 401
if status == http.StatusUnauthorized {
w.Header().Set("WWW-Authenticate", `Basic realm="WebDAV"`)
}
return status, err
}
return wrapHandler(wrappedFn)
}
func withPermShareHelper(fn handleFunc) handleFunc {
return withUserHelper(func(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Permissions.Share {
@@ -523,11 +628,30 @@ func withPermShareHelper(fn handleFunc) handleFunc {
})
}
// withBasicAuthHelper extracts Basic Auth credentials and uses the password as a JWT token
// to authenticate the user. The username is ignored, and the password should be a JWT token.
func withBasicAuthHelper(fn handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, data *requestContext) (int, error) {
_, password, ok := r.BasicAuth()
if !ok || password == "" {
// Return 401 - wrapHandlerBasicAuth will set WWW-Authenticate header
return http.StatusUnauthorized, fmt.Errorf("basic authentication required")
}
data.token = password
return withUserHelper(fn)(w, r, data)
}
}
// withBasicAuth returns an http.HandlerFunc for use with router.Handle.
// It extracts Basic Auth credentials and uses the password as a JWT token to authenticate the user.
func withBasicAuth(fn handleFunc) http.HandlerFunc {
return wrapHandlerBasicAuth(withBasicAuthHelper(fn))
}
func withPermShare(fn handleFunc) http.HandlerFunc {
return wrapHandler(withPermShareHelper(fn))
}
// Example of wrapping specific middleware functions for use with http.HandleFunc
func withHashFile(fn handleFunc) http.HandlerFunc {
return wrapHandler(withHashFileHelper(fn))
}
@@ -641,6 +765,26 @@ func setUserInResponseWriter(w http.ResponseWriter, user *users.User) {
}
}
func getRemoteIP(r *http.Request) string {
// 1. Check X-Forwarded-For
xff := r.Header.Get("X-Forwarded-For")
if xff != "" {
// The first IP is the original client
ips := strings.Split(xff, ",")
return strings.TrimSpace(ips[0])
}
// 2. Check X-Real-IP
xri := r.Header.Get("X-Real-IP")
if xri != "" {
return xri
}
// 3. Fallback to RemoteAddr (strip port if necessary)
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
return ip
}
// LoggingMiddleware logs each request and its status code.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -649,7 +793,6 @@ func LoggingMiddleware(next http.Handler) http.Handler {
if rcv := recover(); rcv != nil {
method := r.Method
url := r.URL.String()
remoteAddr := r.RemoteAddr
username := "unknown" // Default username
// Attempt to get username from ResponseWriterWrapper if it's set
@@ -662,7 +805,7 @@ func LoggingMiddleware(next http.Handler) http.Handler {
stackTrace := string(buf[:n])
logger.Errorf("PANIC RECOVERED: %v\nUser: %s\nMethod: %s\nURL: %s\nRemoteAddr: %s\nGo Stack Trace:\n%s",
rcv, username, method, url, remoteAddr, stackTrace)
rcv, username, method, url, getRemoteIP(r), stackTrace)
// Attempt to send a 500 error response to the client
// This is a best-effort; the connection might be broken or process too unstable.
@@ -705,7 +848,7 @@ func LoggingMiddleware(next http.Handler) http.Handler {
fmt.Sprintf("%-7s | %3d | %-15s | %-12s | %-12s | \"%s\"",
r.Method,
wrappedWriter.StatusCode,
r.RemoteAddr,
getRemoteIP(r),
truncUser,
fmt.Sprintf("%vms", duration.Milliseconds()),
fullURL))
+4 -3
View File
@@ -8,6 +8,7 @@ import (
"time"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
@@ -52,7 +53,7 @@ func mockFileInfoFaster(t *testing.T) {
t.Cleanup(func() { FileInfoFasterFunc = originalFileInfoFaster })
// Mock the function to skip execution
FileInfoFasterFunc = func(opts utils.FileOptions, access *access.Storage, user *users.User) (*iteminfo.ExtendedFileInfo, error) {
FileInfoFasterFunc = func(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
return &iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
Path: opts.Path,
@@ -109,7 +110,7 @@ func TestWithAdminHelper(t *testing.T) {
data := &requestContext{
user: tc.user,
}
token, err := makeSignedTokenAPI(tc.user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*2, tc.user.Perm, false)
tokenString, _, err := auth.MakeSignedTokenAPI(tc.user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), time.Hour*2, tc.user.Perm, false)
if err != nil {
t.Fatalf("Error making token for request: %v", err)
}
@@ -126,7 +127,7 @@ func TestWithAdminHelper(t *testing.T) {
}
req.AddCookie(&http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: token.Key,
Value: tokenString,
})
// Call the handler with the test request and mock context
+49 -30
View File
@@ -4,6 +4,7 @@ import (
"crypto/tls"
"encoding/json"
"fmt"
"maps"
"net/http"
"net/url"
"slices"
@@ -11,6 +12,7 @@ import (
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
@@ -44,6 +46,8 @@ func (u *userInfoUnmarshaller) UnmarshalJSON(data []byte) error {
if u.groupsClaim != "" {
if v, ok := raw[u.groupsClaim]; ok {
switch val := v.(type) {
case map[string]interface{}:
u.userInfo.Groups = slices.Collect(maps.Keys(val))
case []interface{}:
groups := make([]string, len(val))
for i, g := range val {
@@ -71,7 +75,7 @@ func (u *userInfoUnmarshaller) UnmarshalJSON(data []byte) error {
// oidcLoginHandler initiates OIDC login.
// @Summary OIDC login
// @Description Initiates OIDC login flow.
// @Tags OIDC
// @Tags Auth
// @Accept json
// @Produce json
// @Success 302 {string} string "Redirect to OIDC provider"
@@ -105,7 +109,7 @@ func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// oidcCallbackHandler handles OIDC callback.
// @Summary OIDC callback
// @Description Handles OIDC login callback.
// @Tags OIDC
// @Tags Auth
// @Accept json
// @Produce json
// @Param code query string false "OIDC code"
@@ -243,11 +247,29 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// based on the configured UserIdentifier and logs the user into the application.
// It creates a new user if one doesn't exist.
func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string, groups []string) (int, error) {
oidcCfg := config.Auth.Methods.OidcAuth
// Check if user is in required groups (if userGroups is configured)
if len(oidcCfg.UserGroups) > 0 {
userInAllowedGroup := false
for _, allowedGroup := range oidcCfg.UserGroups {
if slices.Contains(groups, allowedGroup) {
userInAllowedGroup = true
break
}
}
if !userInAllowedGroup {
logger.Warningf("User %s is not in any of the required groups %v. Access denied.", username, oidcCfg.UserGroups)
return http.StatusForbidden, fmt.Errorf("user %s is not authorized to access this application (not in required groups)", username)
}
logger.Debugf("User %s is in required group, allowing access.", username)
}
isAdmin := false // Default to non-admin user
if config.Auth.Methods.OidcAuth.AdminGroup != "" {
if slices.Contains(groups, config.Auth.Methods.OidcAuth.AdminGroup) {
if oidcCfg.AdminGroup != "" {
if slices.Contains(groups, oidcCfg.AdminGroup) {
isAdmin = true // User is in the admin group, grant admin privileges
logger.Debugf("User %s is in admin group %s, granting admin privileges.", username, config.Auth.Methods.OidcAuth.AdminGroup)
logger.Debugf("User %s is in admin group %s, granting admin privileges.", username, oidcCfg.AdminGroup)
}
}
logger.Debugf("Successfully authenticated OIDC username: %s isAdmin: %v", username, isAdmin)
@@ -257,32 +279,29 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
if err.Error() != "the resource does not exist" {
return http.StatusInternalServerError, err
}
if config.Auth.Methods.OidcAuth.CreateUser {
if config.Auth.Methods.OidcAuth.AdminGroup == "" {
isAdmin = config.UserDefaults.Permissions.Admin
}
user = &users.User{
Username: username,
LoginMethod: users.LoginMethodOidc,
}
settings.ApplyUserDefaults(user)
if isAdmin {
user.Permissions.Admin = true
}
err = storage.CreateUser(*user, user.Permissions)
if err != nil {
return http.StatusInternalServerError, err
}
user, err = store.Users.Get(username)
if err != nil {
return http.StatusInternalServerError, err
}
} else {
return http.StatusForbidden, fmt.Errorf("user %s does not exist and createUser is disabled. Your admin needs to create your user before you can access this application", username)
// Auto-create user on first OIDC authentication
if oidcCfg.AdminGroup == "" {
isAdmin = config.UserDefaults.Permissions.Admin
}
user = &users.User{
Username: username,
LoginMethod: users.LoginMethodOidc,
}
settings.ApplyUserDefaults(user)
if isAdmin {
user.Permissions.Admin = true
}
err = storage.CreateUser(*user, user.Permissions)
if err != nil {
return http.StatusInternalServerError, err
}
user, err = store.Users.Get(username)
if err != nil {
return http.StatusInternalServerError, err
}
} else {
// update user admin perms
if isAdmin != user.Permissions.Admin && config.Auth.Methods.OidcAuth.AdminGroup != "" {
if isAdmin != user.Permissions.Admin && oidcCfg.AdminGroup != "" {
user.Permissions.Admin = isAdmin
err = store.Users.Update(user, true, "Permissions")
if err != nil {
@@ -301,7 +320,7 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
expires := time.Hour * time.Duration(config.Auth.TokenExpirationHours)
// Generate a signed token for the user
signed, err2 := makeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
tokenString, _, err2 := auth.MakeSignedTokenAPI(user, "WEB_TOKEN_"+utils.InsecureRandomIdentifier(4), expires, user.Permissions, false)
if err2 != nil {
// Handle potential errors during token generation
if strings.Contains(err2.Error(), "key already exists with same name") {
@@ -322,7 +341,7 @@ func loginWithOidcUser(w http.ResponseWriter, r *http.Request, username string,
}
cookie := &http.Cookie{
Name: "filebrowser_quantum_jwt",
Value: signed.Key,
Value: tokenString,
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
+46
View File
@@ -77,6 +77,43 @@ func TestUserInfoUnmarshaller(t *testing.T) {
Groups: []string{},
},
},
{
name: "preferred_username and sub",
jsonData: `{"sub":"auth0|123","preferred_username":"jdoe","email":"jdoe@example.com"}`,
groupsClaim: "groups",
expected: userInfo{
Claims: map[string]interface{}{
"sub": "auth0|123",
"preferred_username": "jdoe",
"email": "jdoe@example.com",
},
Groups: nil,
},
},
{
name: "empty groups string",
jsonData: `{"name":"Dana","groups":""}`,
groupsClaim: "groups",
expected: userInfo{
Claims: map[string]interface{}{
"name": "Dana",
"groups": "",
},
Groups: nil,
},
},
{
name: "single group in array",
jsonData: `{"preferred_username":"single","groups":["admins"]}`,
groupsClaim: "groups",
expected: userInfo{
Claims: map[string]interface{}{
"preferred_username": "single",
"groups": []interface{}{"admins"},
},
Groups: []string{"admins"},
},
},
}
for _, tt := range tests {
@@ -98,3 +135,12 @@ func TestUserInfoUnmarshaller(t *testing.T) {
})
}
}
func TestUserInfoUnmarshaller_InvalidJSON(t *testing.T) {
var userdata userInfo
u := &userInfoUnmarshaller{userInfo: &userdata, groupsClaim: "groups"}
err := json.Unmarshal([]byte(`{invalid json}`), u)
if err == nil {
t.Fatal("Unmarshal expected to fail on invalid JSON")
}
}
+51 -45
View File
@@ -65,6 +65,21 @@ type OnlyOfficeJWTPayload struct {
} `json:"actions"`
}
// onlyofficeClientConfigGetHandler retrieves OnlyOffice client configuration
//
// @Summary Get OnlyOffice client configuration
// @Description Returns the configuration needed for OnlyOffice document editor client
// @Tags Office
// @Accept json
// @Produce json
// @Param source query string false "Source name"
// @Param path query string false "File path"
// @Param hash query string false "Share hash (for public shares)"
// @Success 200 {object} map[string]interface{} "OnlyOffice configuration"
// @Failure 400 {object} map[string]string "Missing or invalid parameters"
// @Failure 500 {object} map[string]string "Server error"
// @Router /api/office/config [get]
// @Security ApiKeyAuth
func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if settings.Config.Integrations.OnlyOffice.Url == "" {
return http.StatusInternalServerError, errors.New("only-office integration must be configured in settings")
@@ -84,7 +99,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(providedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
return http.StatusBadRequest, err
}
providedPath = cleanPath
@@ -116,7 +131,7 @@ func onlyofficeClientConfigGetHandler(w http.ResponseWriter, r *http.Request, d
Source: source,
Expand: false,
FollowSymlinks: true,
}, store.Access, d.user)
}, store.Access, d.user, store.Share)
if err != nil {
logger.Errorf("OnlyOffice: failed to get file info for source=%s, path=%s: %v", source, path, err)
return errToStatus(err), err
@@ -265,14 +280,12 @@ func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token strin
}
escapedPath := url.QueryEscape(path)
downloadURL := fmt.Sprintf("%s/api/raw?file=%s&auth=%s",
strings.TrimSuffix(baseURL, "/"), escapedPath, token)
downloadURL := fmt.Sprintf("%s/api/resources/download?file=%s&auth=%s&source=%s",
strings.TrimSuffix(baseURL, "/"), escapedPath, token, url.QueryEscape(source))
if hash != "" {
downloadURL = downloadURL + "&hash=" + hash
} else {
downloadURL = downloadURL + "&source=" + url.QueryEscape(source)
downloadURL = fmt.Sprintf("%s/public/api/resources/download?file=%s&auth=%s&hash=%s",
strings.TrimSuffix(baseURL, "/"), escapedPath, token, hash)
}
return downloadURL
}
@@ -318,7 +331,7 @@ func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token strin
params.Set("path", path) // This should be the path relative to the share, not the full filesystem path
params.Set("auth", token)
callbackURL = fmt.Sprintf("%s/public/api/onlyoffice/callback?%s",
callbackURL = fmt.Sprintf("%s/public/api/office/callback?%s",
strings.TrimSuffix(baseURL, "/"), params.Encode())
} else {
// Regular callback URL - include source for non-share requests
@@ -327,7 +340,7 @@ func buildOnlyOfficeCallbackURL(r *http.Request, source, path, hash, token strin
params.Set("path", path)
params.Set("auth", token)
callbackURL = fmt.Sprintf("%s/api/onlyoffice/callback?%s",
callbackURL = fmt.Sprintf("%s/api/office/callback?%s",
strings.TrimSuffix(baseURL, "/"), params.Encode())
}
@@ -339,6 +352,13 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
// Extract clean parameters from query string
source := r.URL.Query().Get("source")
path := r.URL.Query().Get("path")
user := d.user
if d.share != nil {
source = d.share.GetSourceName()
path = d.IndexPath
user = d.shareUser
}
// Validate required parameters
if (path == "" || source == "") && d.fileInfo.Hash == "" {
@@ -349,40 +369,10 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
logger.Errorf("OnlyOffice callback: invalid path: %v", err)
return returnOnlyOfficeError(w, r, 400, "invalid path")
return returnOnlyOfficeError(w, r, 400, err.Error())
}
path = cleanPath
var sourceInfo *settings.Source
var ok bool
if d.fileInfo.Hash != "" {
sourceInfo, ok = settings.Config.Server.SourceMap[d.share.Source]
if !ok {
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 returnOnlyOfficeError(w, r, 404, "source not found")
}
}
if d.fileInfo.Hash == "" {
// Regular user request - need to resolve scope
userScope, scopeErr := d.user.GetScopeForSourceName(source)
if scopeErr != nil {
logger.Errorf("OnlyOffice callback: source %s not available for user %s: %v", source, d.user.Username, scopeErr)
return returnOnlyOfficeError(w, r, 403, "source not available")
}
path = utils.JoinPathAsUnix(userScope, path)
} else {
source = sourceInfo.Name
// path is index path, so we build from share path
path = utils.JoinPathAsUnix(d.share.Path, path)
}
// Handle document closure - clean up document key cache
if data.Status == onlyOfficeStatusDocumentClosedWithChanges ||
data.Status == onlyOfficeStatusDocumentClosedWithNoChanges {
@@ -487,14 +477,14 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
}
// Check share permissions first if this is a share request
if d.fileInfo.Hash != "" {
if d.share != nil {
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 {
if !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")
@@ -528,7 +518,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
_, err = files.FileInfoFaster(utils.FileOptions{
Source: source,
Path: path,
}, store.Access, d.user)
}, store.Access, user, store.Share)
if err != nil {
logger.Errorf("OnlyOffice callback: original file no longer exists at path=%s: %v",
path, err)
@@ -543,7 +533,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
}
// Get user scope to resolve full index path for write operation
userScope, err := d.user.GetScopeForSourceName(source)
userScope, err := user.GetScopeForSourceName(source)
if err != nil {
return returnOnlyOfficeError(w, r, 403, "user scope not found")
}
@@ -575,6 +565,22 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
return returnOnlyOfficeSuccess(w, r)
}
// onlyofficeCallbackHandler handles OnlyOffice document server callbacks
//
// @Summary Handle OnlyOffice document server callback
// @Description Receives callbacks from OnlyOffice document server for document status changes and saves
// @Tags Office
// @Accept json
// @Produce json
// @Param source query string false "Source name"
// @Param path query string false "File path"
// @Param hash query string false "Share hash (for public shares)"
// @Success 200 {object} map[string]interface{} "Callback processed successfully"
// @Failure 400 {object} map[string]string "Invalid callback data"
// @Failure 500 {object} map[string]string "Server error"
// @Router /api/office/callback [post]
// @Router /api/office/callback [get]
// @Security ApiKeyAuth
func onlyofficeCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// Parse callback data based on request method
var callbackData *OnlyOfficeCallback
+80 -69
View File
@@ -53,7 +53,7 @@ func isClientCancellation(ctx context.Context, err error) bool {
// @Failure 415 {object} map[string]string "Unsupported file type for preview"
// @Failure 500 {object} map[string]string "Internal server error"
// @Failure 501 {object} map[string]string "Preview generation not implemented"
// @Router /api/preview [get]
// @Router /api/resources/preview [get]
func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if config.Server.DisablePreviews {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
@@ -71,7 +71,7 @@ func previewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
Source: source,
AlbumArt: true, // Extract album art for audio previews
Expand: true,
}, store.Access, d.user)
}, store.Access, d.user, store.Share)
if err != nil {
logger.Errorf("error getting file info: %v", err)
return errToStatus(err), err
@@ -99,60 +99,60 @@ func rawFileHandler(w http.ResponseWriter, r *http.Request, file iteminfo.Extend
return 0, nil
}
// getDirectoryPreview finds a valid preview file within a directory.
func getDirectoryPreview(r *http.Request, d *requestContext) (*iteminfo.ExtendedFileInfo, error) {
var lastErr error
// getDirectoryPreview returns the previewable file at the given frame index (0–3) for motion preview.
// atPercentage maps to frame: 0→0, 1–25→1, 26–50→2, 51–100→3. The returned file is frameIndex % n where n is the number of previewable items.
func getDirectoryPreview(r *http.Request, d *requestContext, frameIndex int) (*iteminfo.ExtendedFileInfo, error) {
// Build list of previewable item names in stable order (same as d.fileInfo.Files)
var previewableNames []string
for _, item := range d.fileInfo.Files {
if !item.HasPreview || !iteminfo.ShouldBubbleUpToFolderPreview(item.ItemInfo) {
continue
}
source := d.fileInfo.Source
path := utils.JoinPathAsUnix(d.fileInfo.Path, item.Name)
if d.share != nil {
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
if !ok {
return nil, fmt.Errorf("source not found for share")
}
source = sourceInfo.Name
path = d.IndexPath + item.Name
}
fileInfo, err := files.FileInfoFaster(
utils.FileOptions{
Path: path,
Source: source,
AlbumArt: true, // Extract album art for audio previews
Metadata: true,
}, store.Access, d.user)
if err != nil {
lastErr = err
continue // Try next file if this one fails
}
// Try to generate preview to verify the file is not corrupted
tempCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
_, previewErr := preview.GetPreviewForFile(tempCtx, *fileInfo, "small", "", 0)
cancel()
if previewErr != nil {
// 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
}
return fileInfo, nil
previewableNames = append(previewableNames, item.Name)
}
// If we exhausted all files and none worked, return the last error
if lastErr != nil {
return nil, fmt.Errorf("no valid preview files found in directory: %w", lastErr)
if len(previewableNames) == 0 {
return nil, fmt.Errorf("no previewable files found in directory")
}
// Cycle: 1 image → always 0; 2 images → 0,1,0,1; 3 → 0,1,2,0; 4 → 0,1,2,3
index := frameIndex % len(previewableNames)
name := previewableNames[index]
return nil, fmt.Errorf("no previewable files found in directory")
source := d.fileInfo.Source
path := utils.JoinPathAsUnix(d.fileInfo.Path, name)
user := d.user
if d.share != nil {
sourceInfo, ok := settings.Config.Server.SourceMap[d.share.Source]
if !ok {
return nil, fmt.Errorf("source not found for share")
}
source = sourceInfo.Name
path = d.IndexPath + name
user = d.shareUser
}
fileInfo, err := files.FileInfoFaster(
utils.FileOptions{
Path: path,
Source: source,
AlbumArt: true,
Metadata: true,
}, store.Access, user, store.Share)
if err != nil {
return nil, err
}
tempCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
_, previewErr := preview.GetPreviewForFile(tempCtx, *fileInfo, "small", "", 0)
cancel()
if previewErr != nil {
if !errors.Is(previewErr, context.Canceled) && !errors.Is(previewErr, context.DeadlineExceeded) {
logger.Debugf("Skipping preview file in directory '%s': %s (error: %v)", d.fileInfo.Name, name, previewErr)
// Fallback: try first item (frame 0) once so atPercentage=0 still works when another frame fails
if index != 0 {
return getDirectoryPreview(r, d, 0)
}
}
return nil, previewErr
}
return fileInfo, nil
}
func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
@@ -164,14 +164,39 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
return http.StatusBadRequest, fmt.Errorf("this item does not have a preview")
}
seekPercentage := 0
percentage := r.URL.Query().Get("atPercentage")
if percentage != "" {
var err error
seekPercentage, err = strconv.Atoi(percentage)
if err != nil {
seekPercentage = 0
}
if seekPercentage < 0 || seekPercentage > 100 {
seekPercentage = 0
}
}
// For directories: map atPercentage to frame index 0–3 for motion preview (cycle over previewable items)
var dirFrameIndex int
if d.fileInfo.Type == "directory" {
// Get extended file info of first previewable item in directory
fileInfo, err := getDirectoryPreview(r, d)
switch {
case seekPercentage <= 0:
dirFrameIndex = 0
case seekPercentage <= 25:
dirFrameIndex = 1
case seekPercentage <= 50:
dirFrameIndex = 2
default:
dirFrameIndex = 3
}
fileInfo, err := getDirectoryPreview(r, d, dirFrameIndex)
if err != nil {
logger.Errorf("error getting directory preview: %v", err)
return http.StatusInternalServerError, err
}
d.fileInfo = *fileInfo
seekPercentage = 0
}
setContentDisposition(w, r, d.fileInfo.Name)
@@ -179,29 +204,15 @@ func previewHelperFunc(w http.ResponseWriter, r *http.Request, d *requestContext
ext := strings.ToLower(filepath.Ext(d.fileInfo.Name))
resizable := iteminfo.ResizableImageTypes[ext]
// If image is under 500KB, serve the original instead of generating a preview
const maxSizeForOriginal = 500 * 1024 // 500KB
if (!resizable || config.Server.DisableResize || d.fileInfo.Size < maxSizeForOriginal) && isImage {
// For small, displayable images (jpg, png, etc.) serve the original to avoid processing.
const maxSizeForOriginal = 128 * 1024 // 128KB
if resizable && (config.Server.DisableResize || d.fileInfo.Size < maxSizeForOriginal) && isImage {
return rawFileHandler(w, r, d.fileInfo)
}
seekPercentage := 0
percentage := r.URL.Query().Get("atPercentage")
if percentage != "" {
var err error
// convert string to int
seekPercentage, err = strconv.Atoi(percentage)
if err != nil {
seekPercentage = 10
}
if seekPercentage < 0 || seekPercentage > 100 {
seekPercentage = 10
}
}
officeUrl := ""
if d.fileInfo.OnlyOfficeId != "" {
pathUrl := fmt.Sprintf("/api/raw?file=%s&source=%s", url.QueryEscape(d.fileInfo.Path), url.QueryEscape(d.fileInfo.Source))
pathUrl := fmt.Sprintf("/api/resources/download?file=%s&source=%s", url.QueryEscape(d.fileInfo.Path), url.QueryEscape(d.fileInfo.Source))
pathUrl = pathUrl + "&auth=" + d.token
if settings.Config.Server.InternalUrl != "" {
officeUrl = config.Server.InternalUrl + pathUrl
+55 -49
View File
@@ -4,28 +4,26 @@ import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"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/database/share"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/go-logger/logger"
_ "github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
)
// publicRawHandler serves the raw content of a file, multiple files, or directory via a public share.
// publicDownloadHandler serves the raw content of a file, multiple files, or directory via a public share.
// @Summary Download files from a public share
// @Description Downloads raw content from a public share. Supports single files, multiple files, or directories as archives. Enforces download limits (global or per-user) and blocks anonymous users when per-user limits are enabled.
// @Description
// @Description **Multiple Files:**
// @Description - Use repeated query parameters: `?file=file1.txt&file=file2.txt&file=file3.txt`
// @Description - This supports filenames containing commas and special characters
// @Tags Public Shares
// @Tags Shares
// @Accept json
// @Produce octet-stream
// @Param hash query string true "Share hash for authentication"
@@ -38,8 +36,8 @@ import (
// @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 "Downloads disabled for upload shares"
// @Router /public/api/raw [get]
func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
// @Router /public/api/resources/download [get]
func publicDownloadHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if d.share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("downloads are disabled for upload shares")
}
@@ -78,7 +76,7 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// Get all "file" parameter values (supports repeated params)
files := r.URL.Query()["file"]
if len(files) == 0 {
return http.StatusBadRequest, fmt.Errorf("no files specified")
files = []string{"/"}
}
// Get the actual source name from the share's source mapping
@@ -116,7 +114,7 @@ func publicRawHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// publicShareHandler returns file or directory information from a public share.
// @Summary Get file/directory information from a public share
// @Description Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.
// @Tags Public Shares
// @Tags Shares
// @Accept json
// @Produce json
// @Param hash query string true "Share hash for authentication"
@@ -139,7 +137,7 @@ func publicGetResourceHandler(w http.ResponseWriter, r *http.Request, d *request
// publicUploadHandler processes file uploads to a public upload share.
// @Summary Upload files to a public upload share
// @Description Handles file and directory uploads to an upload-only public share. Supports chunked uploads, conflict resolution (override), and directory creation.
// @Tags Public Shares
// @Tags Shares
// @Accept multipart/form-data
// @Produce json
// @Param hash query string true "Share hash for authentication"
@@ -181,7 +179,6 @@ func publicUploadHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// @Summary Health Check
// @Schemes
// @Description Returns the health status of the API.
// @Tags Health
// @Accept json
// @Produce json
// @Success 200 {object} HttpResponse "successful health check response"
@@ -198,7 +195,7 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
// publicPreviewHandler handles the preview request for images from public shares.
// @Summary Get image/video preview from a public share
// @Description Returns a preview (thumbnail) for images or videos accessible via a public share. Preview generation can be disabled globally or per-share. Not available for upload-only shares.
// @Tags Public Shares
// @Tags Shares
// @Accept json
// @Produce image/jpeg
// @Param hash query string true "Share hash for authentication"
@@ -209,7 +206,7 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
// @Failure 404 {object} map[string]string "File not found or preview not available"
// @Failure 500 {object} map[string]string "Internal server error"
// @Failure 501 {object} map[string]string "Previews disabled globally, for this share, or for upload shares"
// @Router /public/api/preview [get]
// @Router /public/api/resources/preview [get]
func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if config.Server.DisablePreviews || d.share.DisableThumbnails {
return http.StatusNotImplemented, fmt.Errorf("preview is disabled")
@@ -229,7 +226,7 @@ func publicPreviewHandler(w http.ResponseWriter, r *http.Request, d *requestCont
// 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
// @Tags Shares
// @Accept json
// @Produce json
// @Param hash query string true "Share hash for authentication"
@@ -246,8 +243,8 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if !d.share.AllowModify {
return http.StatusForbidden, fmt.Errorf("create is not allowed for this share")
}
source, err := d.share.GetSourceName()
if err != nil {
sourceName := d.share.GetSourceName()
if sourceName == "" {
return http.StatusNotFound, fmt.Errorf("source not available")
}
@@ -260,11 +257,11 @@ func publicPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
return http.StatusBadRequest, err
}
resolvedPath := utils.JoinPathAsUnix(d.share.Path, cleanPath)
err = files.WriteFile(source, resolvedPath, r.Body)
err = files.WriteFile(sourceName, resolvedPath, r.Body)
// hide the error
if err != nil {
logger.Errorf("public put handler: error updating resource with error %v", err)
@@ -282,7 +279,7 @@ func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestConte
FollowSymlinks: true,
Path: d.IndexPath,
Source: d.share.Source,
}, store.Access, d.shareUser)
}, store.Access, d.shareUser, store.Share)
if err != nil {
return http.StatusNotFound, fmt.Errorf("resource not available")
}
@@ -299,7 +296,7 @@ func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// publicBulkDeleteHandler deletes multiple resources from a public share in a single request.
// @Summary Bulk delete resources from public share
// @Description Deletes multiple resources specified in the request body. Returns a list of succeeded and failed deletions.
// @Tags Public Shares
// @Tags Shares
// @Accept json
// @Produce json
// @Param hash query string true "Share hash for authentication"
@@ -309,7 +306,7 @@ func publicDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// @Failure 400 {object} map[string]string "Bad request - invalid JSON or empty items array"
// @Failure 403 {object} map[string]string "Forbidden - delete not allowed for this share"
// @Failure 500 {object} map[string]string "Internal server error - all deletions failed"
// @Router /public/api/resources/bulk/delete [post]
// @Router /public/api/resources/bulk [delete]
func publicBulkDeleteHandler(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")
@@ -326,7 +323,7 @@ func publicBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestC
// publicPatchHandler performs a patch operation (e.g., move, copy, rename) on resources in a public share.
// @Summary Move, copy, or rename resources in a public share
// @Description Performs move, copy, or rename operations on multiple resources within a public share. All operations are performed atomically.
// @Tags Public Shares
// @Tags Shares
// @Accept json
// @Produce json
// @Param hash query string true "Share hash for authentication"
@@ -344,8 +341,8 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
}
// Get the source from the share
source, err := d.share.GetSourceName()
if err != nil {
sourceName := d.share.GetSourceName()
if sourceName == "" {
return http.StatusNotFound, fmt.Errorf("source not available")
}
@@ -371,9 +368,9 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// Note: Share paths are absolute, so we don't strip user scope here
// resourcePatchHandler will skip adding scope for shares
for i := range req.Items {
req.Items[i].FromSource = source
req.Items[i].FromSource = sourceName
req.Items[i].FromPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].FromPath)
req.Items[i].ToSource = source
req.Items[i].ToSource = sourceName
req.Items[i].ToPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].ToPath)
}
d.Data = req
@@ -395,7 +392,7 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// getShareImage serves banner or favicon files for shares as resizable previews
// @Summary Get share image (banner or favicon) as preview
// @Description Returns a resizable preview (large size) for the banner or favicon file of a share
// @Tags Public Shares
// @Tags Shares
// @Produce image/jpeg
// @Param hash query string true "Share hash"
// @Param banner query bool false "Request banner file"
@@ -419,7 +416,7 @@ func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (i
return http.StatusNotFound, fmt.Errorf("user for share no longer exists")
}
sourceName, assetPath, err := getShareImagePartsHelper(d.share, isBanner)
sourceName, assetPath, err := d.share.GetShareImagePartsHelper(isBanner)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid asset configuration: %v", err)
}
@@ -433,7 +430,7 @@ func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (i
Metadata: false,
ShowHidden: false,
FollowSymlinks: true,
}, store.Access, shareCreatedByUser)
}, store.Access, shareCreatedByUser, store.Share)
if err != nil {
logger.Errorf("error accessing share asset: source=%v path=%v error=%v", sourceName, assetPath, err)
@@ -465,28 +462,37 @@ func getShareImage(w http.ResponseWriter, r *http.Request, d *requestContext) (i
return status, err
}
func getShareImagePartsHelper(share *share.Link, isBanner bool) (string, string, error) {
// Get the asset query string from share
var assetQueryString string
if isBanner {
assetQueryString = share.Banner
} else {
assetQueryString = share.Favicon
// publicItemsGetHandler efficiently returns a basic list of items for a directory in a public share.
// @Summary Get directory items (public share)
// @Description Efficiently returns a basic list of items for the specified path in a public share. Use hash for authentication instead of source. Use 'only' parameter to filter by only files or folders.
// @Tags Shares
// @Accept json
// @Produce json
// @Param hash query string true "Share hash for authentication"
// @Param path query string false "Path within the share to list child items. Defaults to share root."
// @Param only query string false "Filter: 'files', 'folders', or omit for both"
// @Success 200 {object} files.Items "lists files and folders"
// @Failure 403 {object} map[string]string "Forbidden (access denied)"
// @Failure 404 {object} map[string]string "Share not found or source not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /public/api/resources/items [get]
func publicItemsGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sourceInfo, ok := config.Server.SourceMap[d.share.Source]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
}
if assetQueryString == "" {
return "", "", fmt.Errorf("asset not configured for this share")
}
// Parse the query string to extract source and path
assetParams, err := url.ParseQuery(assetQueryString)
items, err := files.GetDirItems(utils.FileOptions{
FollowSymlinks: true,
Path: d.IndexPath,
Source: sourceInfo.Name,
ShowHidden: d.shareUser.ShowHidden,
Only: r.URL.Query().Get("only"),
}, store.Access, d.shareUser)
if err != nil {
return "", "", fmt.Errorf("invalid asset configuration: %v", err)
if err == errors.ErrAccessDenied {
return http.StatusForbidden, err
}
return http.StatusInternalServerError, err
}
sourceName := assetParams.Get("source")
assetPath := assetParams.Get("path")
return sourceName, assetPath, nil
return renderJSON(w, r, items)
}
-620
View File
@@ -1,620 +0,0 @@
package http
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"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"
)
// throttledReadSeeker is a wrapper around an io.ReadSeeker that throttles the reading speed.
type throttledReadSeeker struct {
rs io.ReadSeeker
limiter *rate.Limiter
ctx context.Context
}
// newThrottledReadSeeker creates a new throttledReadSeeker.
func newThrottledReadSeeker(rs io.ReadSeeker, limit rate.Limit, burst int, ctx context.Context) *throttledReadSeeker {
return &throttledReadSeeker{
rs: rs,
limiter: rate.NewLimiter(limit, burst),
ctx: ctx,
}
}
func (r *throttledReadSeeker) Read(p []byte) (n int, err error) {
n, err = r.rs.Read(p)
if n > 0 {
if waitErr := r.limiter.WaitN(r.ctx, n); waitErr != nil {
// The original error (like io.EOF) is potentially more important
// than the context error.
if err == nil {
err = waitErr
}
}
}
return
}
func (r *throttledReadSeeker) Seek(offset int64, whence int) (int64, error) {
return r.rs.Seek(offset, whence)
}
// toASCIIFilename converts a filename to ASCII-safe format by replacing non-ASCII characters with underscores
func toASCIIFilename(fileName string) string {
var result strings.Builder
for _, r := range fileName {
if r > 127 {
// Replace non-ASCII characters with underscore
result.WriteRune('_')
} else {
result.WriteRune(r)
}
}
return result.String()
}
func setContentDisposition(w http.ResponseWriter, r *http.Request, fileName string) {
dispositionType := "attachment"
if r.URL.Query().Get("inline") == "true" {
dispositionType = "inline"
}
// standard: ASCII-only safe fallback
asciiFileName := toASCIIFilename(fileName)
// RFC 5987: UTF-8 encoded
encodedFileName := url.PathEscape(fileName)
// Always set both filename (ASCII) and filename* (UTF-8) for maximum compatibility (RFC 6266)
w.Header().Set("Content-Disposition", fmt.Sprintf("%s; filename=%q; filename*=utf-8''%s", dispositionType, asciiFileName, encodedFileName))
}
// rawHandler serves the raw content of a file, multiple files, or directory in various formats.
// @Summary Get raw content of a file, multiple files, or directory
// @Description Returns the raw content of a file, multiple files, or a directory. Supports downloading files as archives in various formats.
// @Description
// @Description **Filename Encoding:**
// @Description - The Content-Disposition header will always include both:
// @Description 1. `filename="..."`: An ASCII-safe version of the filename for compatibility.
// @Description 2. `filename*=utf-8"...`: The full UTF-8 encoded filename (RFC 6266/5987) for modern clients.
// @Description
// @Description **Multiple Files:**
// @Description - Use repeated query parameters: `?file=file1.txt&file=file2.txt&file=file3.txt`
// @Description - This supports filenames containing commas and special characters
// @Tags Resources
// @Accept json
// @Param source query string true "Source name for the files (required)"
// @Param file query []string true "File path (can be repeated for multiple files)"
// @Param inline query bool false "If true, sets 'Content-Disposition' to 'inline'. Otherwise, defaults to 'attachment'."
// @Param algo query string false "Compression algorithm for archiving multiple files or directories. Options: 'zip' and 'tar.gz'. Default is 'zip'."
// @Success 200 {file} file "Raw file or directory content, or archive for multiple files"
// @Failure 202 {object} map[string]string "Modify permissions required"
// @Failure 400 {object} map[string]string "Invalid request path"
// @Failure 404 {object} map[string]string "File or directory not found"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/raw [get]
func rawHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
source := r.URL.Query().Get("source")
fileList := r.URL.Query()["file"]
// Rule 1: Validate all user-provided file paths to prevent path traversal
for i, filePath := range fileList {
cleanPath, err := utils.SanitizeUserPath(filePath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid file path: %v", err)
}
fileList[i] = cleanPath
}
return rawFilesHandler(w, r, d, source, fileList)
}
func addFile(source string, path string, d *requestContext, tarWriter *tar.Writer, zipWriter *zip.Writer, flatten bool) error {
// Path handling is now done inside FileInfoFaster
// For shares, path comes from share context
// For regular users, FileInfoFaster will apply user scope
idx := indexing.GetIndex(source)
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{
Path: path,
Source: source,
Expand: false,
FollowSymlinks: true,
}, store.Access, d.user)
if err != nil {
return err
}
realPath, _, _ := idx.GetRealPath(path)
info, err := os.Stat(realPath)
if err != nil {
return err
}
// Get the base name of the top-level folder or file
baseName := filepath.Base(realPath)
if info.IsDir() {
// Walk through directory contents
return filepath.Walk(realPath, func(filePath string, fileInfo os.FileInfo, err error) error {
if err != nil {
return err
}
// Calculate the relative path
relPath, err := filepath.Rel(realPath, filePath) // Use realPath directly
if err != nil {
return err
}
// Normalize for tar: convert \ to /
relPath = filepath.ToSlash(relPath)
// Skip adding `.` (current directory)
if relPath == "." {
return nil
}
// Check access control for each file/folder during walk
if d.share == nil {
indexRelPath := utils.JoinPathAsUnix(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)
relPath = filepath.ToSlash(relPath) // Ensure normalized separators
}
if fileInfo.IsDir() {
if tarWriter != nil {
header := &tar.Header{
Name: relPath + "/",
Mode: int64(fileutils.PermDir),
Typeflag: tar.TypeDir,
ModTime: fileInfo.ModTime(),
}
return tarWriter.WriteHeader(header)
}
if zipWriter != nil {
_, err := zipWriter.Create(relPath + "/")
return err
}
return nil
}
return addSingleFile(filePath, relPath, zipWriter, tarWriter)
})
} else {
// For a single file, use the base name as the archive path
return addSingleFile(realPath, baseName, zipWriter, tarWriter)
}
}
func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWriter *tar.Writer) error {
file, err := os.Open(realPath)
if err != nil {
// If we get "is a directory" error, this is likely a symlink to a directory
// that wasn't properly detected. Skip it gracefully.
if strings.Contains(err.Error(), "is a directory") {
return nil
}
return err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return err
}
// Double-check if this is actually a directory (in case of symlinks)
if info.IsDir() {
return nil
}
if tarWriter != nil {
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = filepath.ToSlash(archivePath)
if err = tarWriter.WriteHeader(header); err != nil {
return err
}
_, err = io.Copy(tarWriter, file)
return err
}
if zipWriter != nil {
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Name = archivePath
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return err
}
_, err = io.Copy(writer, file)
return err
}
return nil
}
func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext, source string, fileList []string) (int, error) {
if !d.user.Permissions.Download && d.share == nil {
return http.StatusForbidden, fmt.Errorf("user is not allowed to download")
}
if len(fileList) == 0 {
return http.StatusBadRequest, fmt.Errorf("no files specified")
}
firstFilePath := fileList[0]
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 = d.user.GetScopeForSourceName(source)
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(source)
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)
}
// For shares, the path is already correctly resolved by publicRawHandler
idx := indexing.GetIndex(source)
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", source))
}
return http.StatusInternalServerError, fmt.Errorf("source %s is not available", source)
}
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
estimatedSize, err := computeArchiveSize(source, fileList, d)
if err != nil {
return http.StatusInternalServerError, err
}
// ** 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()
// 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")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
sizeInMB := estimatedSize / 1024 / 1024
// if larger than 500 MB, log it
if sizeInMB > 500 {
logger.Debugf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, fileName)
}
// serve content allows for range requests.
// video scrubbing, etc.
// Note: http.ServeContent will respect our already-set Content-Disposition header
var reader io.ReadSeeker = fd
if d.share != nil && d.share.MaxBandwidth > 0 {
// convert KB/s to B/s
limit := rate.Limit(d.share.MaxBandwidth * 1024)
// burst size can be the same as limit
burst := d.share.MaxBandwidth * 1024
reader = newThrottledReadSeeker(fd, limit, burst, r.Context())
}
http.ServeContent(w, r, fileName, fileInfo.ModTime(), reader)
return 200, nil
}
if config.Server.MaxArchiveSizeGB > 0 {
maxSize := config.Server.MaxArchiveSizeGB * 1024 * 1024 * 1024
if estimatedSize > maxSize {
return http.StatusRequestEntityTooLarge, fmt.Errorf("pre-archive combined size of files exceeds maximum limit of %d GB", config.Server.MaxArchiveSizeGB)
}
}
// ** Archive (ZIP/TAR.GZ) handling **
algo := r.URL.Query().Get("algo")
var extension string
switch algo {
case "zip", "true", "":
extension = ".zip"
case "tar.gz":
extension = ".tar.gz"
default:
return http.StatusInternalServerError, errors.New("format not implemented")
}
baseDirName := filepath.Base(filepath.Dir(firstFilePath))
if baseDirName == "" || baseDirName == "/" {
baseDirName = "download"
}
if len(fileList) == 1 && isDir {
baseDirName = filepath.Base(realPath)
}
// Store original filename before any encoding
originalFileName := baseDirName + extension
archiveData := filepath.Join(config.Server.CacheDir, utils.InsecureRandomIdentifier(10))
if extension == ".zip" {
archiveData = archiveData + ".zip"
err = createZip(d, source, archiveData, fileList...)
} else {
archiveData = archiveData + ".tar.gz"
err = createTarGz(d, source, archiveData, fileList...)
}
if err != nil {
return http.StatusInternalServerError, err
}
// stream archive to response
fd, err := os.Open(archiveData)
if err != nil {
return http.StatusInternalServerError, err
}
defer fd.Close()
// Get file size
fileInfo, err := fd.Stat()
if err != nil {
os.Remove(archiveData) // Remove the file if stat fails
return http.StatusInternalServerError, err
}
sizeInMB := fileInfo.Size() / 1024 / 1024
if sizeInMB > 500 {
logger.Debugf("User %v is downloading large (%d MB) file: %v", d.user.Username, sizeInMB, originalFileName)
}
// Set headers AFTER computing actual archive size
// Use the same setContentDisposition logic for archives
setContentDisposition(w, r, originalFileName)
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
w.Header().Set("Content-Type", "application/octet-stream")
// Stream the file
var reader io.Reader = fd
if d.share != nil && d.share.MaxBandwidth > 0 {
// convert KB/s to B/s
limit := rate.Limit(d.share.MaxBandwidth * 1024)
// burst size can be the same as limit
burst := d.share.MaxBandwidth * 1024
reader = newThrottledReadSeeker(fd, limit, burst, r.Context())
}
_, err = io.Copy(w, reader)
os.Remove(archiveData) // Remove the file after streaming
if err != nil {
logger.Errorf("Failed to copy archive data to response: %v", err)
return http.StatusInternalServerError, err
}
return 0, nil
}
func computeArchiveSize(source string, fileList []string, d *requestContext) (int64, error) {
var estimatedSize int64
idx := indexing.GetIndex(source)
if idx == nil {
return 0, fmt.Errorf("source %s is not available", source)
}
var userScope string
var err error
if d.share == nil {
userScope, err = d.user.GetScopeForSourceName(source)
if err != nil {
return 0, fmt.Errorf("source %s is not available for user %s", source, d.user.Username)
}
}
for _, path := range fileList {
var fullPath string
if d.share == nil {
fullPath = 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, fullPath, d.user.Username) {
continue // Skip this file and continue with the next one
}
} else {
fullPath = path
}
// For shares, the path is already correctly resolved by publicRawHandler
realPath, isDir, err := idx.GetRealPath(fullPath)
if err != nil {
return http.StatusInternalServerError, err
}
indexPath := idx.MakeIndexPath(realPath, isDir)
info, ok := idx.GetReducedMetadata(indexPath, isDir)
if !ok {
info, err = idx.GetFsInfo(indexPath, false, true)
if err != nil {
return 0, fmt.Errorf("failed to get file info for %s : %v", path, err)
}
}
estimatedSize += info.Size
}
return estimatedSize, nil
}
func createZip(d *requestContext, source string, tmpDirPath string, filenames ...string) error {
file, err := os.Create(tmpDirPath)
if err != nil {
return err
}
defer file.Close()
zipWriter := zip.NewWriter(file)
for _, fname := range filenames {
err := addFile(source, 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
}
}
// Close the ZIP writer and check for errors
if err := zipWriter.Close(); err != nil {
return fmt.Errorf("failed to finalize ZIP archive: %w", err)
}
return nil
}
func createTarGz(d *requestContext, source string, tmpDirPath string, filenames ...string) error {
file, err := os.Create(tmpDirPath)
if err != nil {
return err
}
defer file.Close()
gzWriter := gzip.NewWriter(file)
tarWriter := tar.NewWriter(gzWriter)
for _, fname := range filenames {
err := addFile(source, fname, d, tarWriter, nil, false)
if err != nil {
logger.Errorf("Failed to add %s to TAR.GZ: %v", fname, err)
return err
}
}
// Close writers in reverse order and check for errors
if err := tarWriter.Close(); err != nil {
return fmt.Errorf("failed to finalize TAR archive: %w", err)
}
if err := gzWriter.Close(); err != nil {
return fmt.Errorf("failed to finalize GZIP compression: %w", err)
}
return nil
}
// isOnlyOfficeCompatibleFile checks if a file extension is supported by OnlyOffice
func isOnlyOfficeCompatibleFile(fileName string) bool {
return iteminfo.IsOnlyOffice(fileName)
}
+58 -23
View File
@@ -61,6 +61,7 @@ func validateMoveOperation(src, dst string, isSrcDir bool) error {
// @Accept json
// @Produce json
// @Param path query string true "Path to the resource"
// @Param skipExtendedAttrs query string false "Skip extended attributes for faster retrieval, no hasPreview"
// @Param source query string true "Source name for the desired source, default is used if not provided"
// @Param content query string false "Include file content if true"
// @Param metadata query string false "Extract audio/video metadata if true"
@@ -74,6 +75,7 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
source := r.URL.Query().Get("source")
getContent := r.URL.Query().Get("content") == "true"
getMetadata := r.URL.Query().Get("metadata") == "true"
skipExtendedAttrs := r.URL.Query().Get("skipExtendedAttrs") == "true"
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: path,
@@ -83,7 +85,9 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
Metadata: getMetadata,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
ShowHidden: d.user.ShowHidden,
}, store.Access, d.user)
SkipExtendedAttrs: skipExtendedAttrs,
ShowSharedAttr: true,
}, store.Access, d.user, store.Share)
if err != nil {
return errToStatus(err), err
}
@@ -137,7 +141,7 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
Source: source,
Expand: false,
ShowHidden: d.user.ShowHidden,
}, store.Access, d.user)
}, store.Access, d.user, store.Share)
if err != nil {
return errToStatus(err), err
}
@@ -201,7 +205,7 @@ type MoveCopyResponse struct {
// @Failure 400 {object} map[string]string "Bad request - invalid JSON or empty items array"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 500 {object} map[string]string "Internal server error - all deletions failed"
// @Router /api/resources/bulk/delete [post]
// @Router /api/resources/bulk [delete]
func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
filePermUser := d.user
if d.share != nil {
@@ -252,12 +256,12 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
}
if d.share != nil {
source, err := d.share.GetSourceName()
if err != nil {
sourceName := d.share.GetSourceName()
if sourceName == "" {
return http.StatusNotFound, fmt.Errorf("source not available")
}
// get user scope path from share
userScope, err := d.shareUser.GetScopeForSourceName(source)
userScope, err := d.shareUser.GetScopeForSourceName(sourceName)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
@@ -272,15 +276,15 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: indexPath,
Source: source,
Source: sourceName,
ShowHidden: true,
}, store.Access, filePermUser)
}, store.Access, filePermUser, store.Share)
if err != nil {
return http.StatusNotFound, fmt.Errorf("resource not available")
}
// Delete the file/directory
err = files.DeleteFiles(source, fileInfo.RealPath, fileInfo.Type == "directory")
err = files.DeleteFiles(sourceName, fileInfo.RealPath, fileInfo.Type == "directory")
if err != nil {
logger.Errorf("resource bulk delete handler: error deleting file/directory: %v", err)
response.Failed = append(response.Failed, BulkDeleteItem{
@@ -327,10 +331,10 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
// Get file info
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: idx.MakeIndexPath(item.Path, false),
Path: item.Path,
Source: item.Source,
ShowHidden: true,
}, store.Access, filePermUser)
}, store.Access, filePermUser, store.Share)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
@@ -386,7 +390,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
return http.StatusBadRequest, err
}
path = cleanPath
@@ -487,7 +491,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
}
var fileInfo *iteminfo.ExtendedFileInfo
fileInfo, err = files.FileInfoFaster(fileOpts, store.Access, filePermUser)
fileInfo, err = files.FileInfoFaster(fileOpts, store.Access, filePermUser, store.Share)
if err == nil { // File exists
if r.URL.Query().Get("override") != "true" {
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
@@ -546,7 +550,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return http.StatusOK, nil
}
fileInfo, err := files.FileInfoFaster(fileOpts, store.Access, filePermUser)
fileInfo, err := files.FileInfoFaster(fileOpts, store.Access, filePermUser, store.Share)
if err == nil { // File exists
if r.URL.Query().Get("override") != "true" {
logger.Debugf("resource already exists: %v", fileInfo.RealPath)
@@ -585,21 +589,15 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
return http.StatusBadRequest, err
}
path = cleanPath
// Only allow PUT for files.
if strings.HasSuffix(path, "/") {
return http.StatusMethodNotAllowed, nil
}
// Get user scope to resolve full index path for write operation
userScope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
fullIndexPath := utils.JoinPathAsUnix(userScope, path)
// Check access control for the target path
idx := indexing.GetIndex(source)
if idx == nil {
@@ -610,7 +608,14 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
}
err = files.WriteFile(source, utils.JoinPathAsUnix(userScope, path), r.Body)
// check if destination is a directory
stat, err := os.Stat(filepath.Join(idx.Path + fullIndexPath))
if err == nil && stat.IsDir() {
// if directory return StatusMethodNotAllowed
return http.StatusMethodNotAllowed, fmt.Errorf("path is a directory")
}
err = files.WriteFile(source, fullIndexPath, r.Body)
return errToStatus(err), err
}
@@ -927,7 +932,7 @@ func patchAction(ctx context.Context, params patchActionParams) error {
Source: params.srcIndex,
IsDir: params.isSrcDir,
ShowHidden: params.d.user.ShowHidden,
}, store.Access, params.d.user)
}, store.Access, params.d.user, store.Share)
if err != nil {
return err
@@ -965,3 +970,33 @@ func mockData(w http.ResponseWriter, r *http.Request) {
mockDir := indexing.CreateMockData(NumDirs, numFiles)
renderJSON(w, r, mockDir) // nolint:errcheck
}
// itemsGetHandler efficiently returns a basic list of items for a directory.
// @Summary Get directory items
// @Description Efficiently returns a basic list of items for the specified path and source. Use 'only' parameter to filter by only files or folders
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "A directory path to list child items"
// @Param source query string true "The source name which contains the path"
// @Param only query string false "Filter: 'files', 'folders', or omit for both"
// @Success 200 {object} files.Items "lists files and folders"
// @Failure 403 {object} map[string]string "Forbidden (access denied)"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/resources/items [get]
func itemsGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
items, err := files.GetDirItems(utils.FileOptions{
FollowSymlinks: true,
Path: r.URL.Query().Get("path"),
Source: r.URL.Query().Get("source"),
ShowHidden: d.user.ShowHidden,
Only: r.URL.Query().Get("only"),
}, store.Access, d.user)
if err != nil {
if err == errors.ErrAccessDenied {
return http.StatusForbidden, err
}
return http.StatusInternalServerError, err
}
return renderJSON(w, r, items)
}
+51 -13
View File
@@ -2,8 +2,10 @@ package http
import (
"fmt"
"math"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
@@ -17,7 +19,9 @@ type searchOptions struct {
searchScope string
combinedPath map[string]string // source -> combinedPath
sessionId string
largest bool
largest bool
olderThanUnix int64 // optional; 0 = unset. Modified time must be strictly before this Unix second.
newerThanUnix int64 // optional; 0 = unset. Modified time must be >= this Unix second.
}
// searchHandler handles search requests for files based on the provided query.
@@ -39,14 +43,16 @@ type searchOptions struct {
// - source: Source name (deprecated, use 'sources' instead)
// - sources: Comma-separated list of source names (e.g., "source1,source2")
// - scope: Optional path within user scope to search
// - olderThan: Optional Unix time in seconds; only items modified strictly before this instant
// - newerThan: Optional Unix time in seconds; only items modified on or after this instant
//
// Example request (single source):
//
// GET api/search?query=myfile&source=mysource
// GET api/tools/search?query=myfile&source=mysource
//
// Example request (multiple sources):
//
// GET api/search?query=myfile&sources=source1,source2
// GET api/tools/search?query=myfile&sources=source1,source2
//
// Example response:
// [
@@ -66,23 +72,30 @@ type searchOptions struct {
//
// @Summary Search Files
// @Description Searches for files matching the provided query. Returns file paths and metadata based on the user's session and scope. Supports searching across multiple sources when using the 'sources' parameter.
// @Tags Search
// @Tags Tools
// @Accept json
// @Produce json
// @Param query query string true "Search query"
// @Param source query string false "Source name for the desired source (deprecated, use 'sources' instead)"
// @Param sources query string false "Comma-separated list of source names to search across multiple sources. When multiple sources are specified, scope is always the user's scope for each source."
// @Param scope query string false "path within user scope to search, for example '/first/second' to search within the second directory only. Ignored when multiple sources are specified."
// @Param olderThan query int false "Unix seconds; only results modified strictly before this time"
// @Param newerThan query int false "Unix seconds; only results modified on or after this time"
// @Param SessionId header string false "User session ID, add unique value to prevent collisions"
// @Success 200 {array} indexing.SearchResult "List of search results with source field populated"
// @Failure 400 {object} map[string]string "Bad Request"
// @Router /api/search [get]
// @Router /api/tools/search [get]
func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
searchOptions, err := prepSearchOptions(r, d)
if err != nil {
return http.StatusBadRequest, err
}
searchSize := indexing.DefaultSearchResults
if searchOptions.largest {
searchSize = 200
}
var response []*indexing.SearchResult
if len(searchOptions.sources) == 1 {
// Single source - use the existing Search method for backward compatibility
@@ -91,10 +104,10 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
return http.StatusBadRequest, fmt.Errorf("index not found for source %s", searchOptions.sources[0])
}
combinedPath := searchOptions.combinedPath[searchOptions.sources[0]]
response = index.Search(searchOptions.query, combinedPath, searchOptions.sessionId, searchOptions.largest, indexing.DefaultSearchResults)
response = index.Search(searchOptions.query, combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix)
} else {
// Multiple sources - use the new SearchMultiSources function
response = indexing.SearchMultiSources(searchOptions.query, searchOptions.sources, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, indexing.DefaultSearchResults)
response = indexing.SearchMultiSources(searchOptions.query, searchOptions.sources, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix)
}
// Filter out items that are not permitted according to access rules and trim user scope from paths
@@ -122,6 +135,14 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
sourceParam := r.URL.Query().Get("source") // deprecated, but still supported
scope := r.URL.Query().Get("scope") // Go automatically decodes query params
largest := r.URL.Query().Get("largest") == "true"
olderThanUnix, err := parseOptionalUnixQueryParam("olderThan", r.URL.Query().Get("olderThan"))
if err != nil {
return nil, err
}
newerThanUnix, err := parseOptionalUnixQueryParam("newerThan", r.URL.Query().Get("newerThan"))
if err != nil {
return nil, err
}
cleanScope, err := utils.SanitizeUserPath(scope)
if err != nil {
return nil, fmt.Errorf("invalid scope: %v", err)
@@ -171,11 +192,28 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
}
return &searchOptions{
query: query,
sources: sources,
searchScope: searchScope,
combinedPath: combinedPathMap,
sessionId: sessionId,
largest: largest,
query: query,
sources: sources,
searchScope: searchScope,
combinedPath: combinedPathMap,
sessionId: sessionId,
largest: largest,
olderThanUnix: olderThanUnix,
newerThanUnix: newerThanUnix,
}, nil
}
func parseOptionalUnixQueryParam(name, raw string) (int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, nil
}
v, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid %s: must be a non-negative Unix timestamp in seconds", name)
}
if v > math.MaxInt64 {
return 0, fmt.Errorf("invalid %s: value too large", name)
}
return int64(v), nil
}
+20
View File
@@ -6,6 +6,8 @@ import (
"os"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/go-logger/logger"
)
// settingsGetHandler retrieves the current system settings.
@@ -95,3 +97,21 @@ func settingsConfigHandler(w http.ResponseWriter, r *http.Request, d *requestCon
return http.StatusOK, nil
}
func getSourceInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
sources := d.user.GetSourceNames()
reducedIndexes := map[string]indexing.ReducedIndex{}
for _, source := range sources {
reducedIndex, err := indexing.GetIndexInfo(source, false)
if err != nil {
logger.Debugf("error getting index info: %v", err)
continue
}
showScannerInfo := r.URL.Query().Get("scanners") == "true"
if !showScannerInfo {
reducedIndex.Scanners = nil
}
reducedIndexes[source] = reducedIndex
}
return renderJSON(w, r, reducedIndexes)
}
+50 -26
View File
@@ -33,13 +33,14 @@ type ShareResponse struct {
}
// convertToFrontendShareResponse converts shares to response format with usernames
func convertToFrontendShareResponse(r *http.Request, shares []*share.Link) ([]*ShareResponse, error) {
func convertToFrontendShareResponse(r *http.Request, shares []*share.Link, user *users.User) ([]*ShareResponse, error) {
responses := make([]*ShareResponse, 0, len(shares))
for _, s := range shares {
user, err := store.Users.Get(s.UserID)
// Look for the username of the user who created the share
creator, err := store.Users.Get(s.UserID)
username := ""
if err == nil {
username = user.Username
username = creator.Username
}
// Get source info to convert path to name for frontend
@@ -48,9 +49,6 @@ func convertToFrontendShareResponse(r *http.Request, shares []*share.Link) ([]*S
// Source not found - likely corrupted data. Try to find by name as fallback
sourceInfo, ok = config.Server.NameToSource[s.Source]
if !ok {
// Still not found - this share is invalid, skip it and delete it
logger.Error("Invalid share - deleting", "hash", s.Hash, "source", s.Source)
_ = store.Share.Delete(s.Hash) // Best effort delete
continue
}
// Found by name - this is corrupted data, fix it
@@ -65,6 +63,9 @@ func convertToFrontendShareResponse(r *http.Request, shares []*share.Link) ([]*S
s.CommonShare.HasPassword = s.HasPassword()
s.DownloadURL = getShareURL(r, s.Hash, true, s.Token)
s.ShareURL = getShareURL(r, s.Hash, false, s.Token)
if s.UserCanEdit(user) {
s.CommonShare.SourceURL = s.SourceURL(user)
}
// Create response with source name (overrides the embedded Link's source field)
responses = append(responses, &ShareResponse{
Link: s,
@@ -84,7 +85,7 @@ func convertToFrontendShareResponse(r *http.Request, shares []*share.Link) ([]*S
// @Produce json
// @Success 200 {array} share.Link "List of share links"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/shares [get]
// @Router /api/share/list [get]
func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
var err error
var shares []*share.Link
@@ -97,7 +98,7 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusInternalServerError, err
}
shares = utils.NonNilSlice(shares)
sharesWithUsernames, err := convertToFrontendShareResponse(r, shares)
sharesWithUsernames, err := convertToFrontendShareResponse(r, shares, d.user)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -137,7 +138,7 @@ func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("error getting share info from server")
}
sharesWithUsernames, err := convertToFrontendShareResponse(r, s)
sharesWithUsernames, err := convertToFrontendShareResponse(r, s, d.user)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -154,7 +155,7 @@ func shareGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// @Success 200 "Share link deleted successfully"
// @Failure 400 {object} map[string]string "Bad request - missing or invalid hash"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/shares [delete]
// @Router /api/share [delete]
func shareDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
hash := r.URL.Query().Get("hash")
@@ -162,7 +163,16 @@ func shareDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return http.StatusBadRequest, nil
}
err := store.Share.Delete(hash)
// only allow users to delete their own shares
thisShare, err := store.Share.GetByHash(hash)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("share not found")
}
if thisShare.UserID != d.user.ID && !d.user.Permissions.Admin {
return http.StatusForbidden, fmt.Errorf("you are not allowed to delete this share")
}
err = store.Share.Delete(hash)
if err != nil {
return errToStatus(err), err
}
@@ -196,8 +206,16 @@ func sharePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext
return http.StatusBadRequest, fmt.Errorf("hash and path are required")
}
// only allow users to update their own shares
thisShare, err := store.Share.GetByHash(body.Hash)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("share not found")
}
if thisShare.UserID != d.user.ID && !d.user.Permissions.Admin {
return http.StatusForbidden, fmt.Errorf("you are not allowed to update this share")
}
// Update the share path
err := store.Share.UpdateSharePath(body.Hash, body.Path)
err = store.Share.UpdateSharePath(body.Hash, body.Path)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -209,7 +227,7 @@ func sharePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext
}
// Convert to response format
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{updatedShare})
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{updatedShare}, d.user)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -362,7 +380,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
// Rule 1: Validate user-provided path to prevent path traversal
cleanPath, err := utils.SanitizeUserPath(providedPath)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
return http.StatusBadRequest, err
}
body.Path = utils.JoinPathAsUnix(userscope, cleanPath)
@@ -389,7 +407,7 @@ func sharePostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
if err = store.Share.Save(s); err != nil {
return http.StatusInternalServerError, err
}
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{s})
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{s}, d.user)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -561,7 +579,7 @@ func getShareURL(r *http.Request, hash string, isDirectDownload bool, token stri
if config.Server.ExternalUrl != "" {
if isDirectDownload {
shareURL = fmt.Sprintf("%s%spublic/api/raw?hash=%s%s", config.Server.ExternalUrl, config.Server.BaseURL, hash, tokenParam)
shareURL = fmt.Sprintf("%s%spublic/api/resources/download?hash=%s%s", config.Server.ExternalUrl, config.Server.BaseURL, hash, tokenParam)
} else {
shareURL = fmt.Sprintf("%s%spublic/share/%s", config.Server.ExternalUrl, config.Server.BaseURL, hash)
}
@@ -584,7 +602,7 @@ func getShareURL(r *http.Request, hash string, isDirectDownload bool, token stri
scheme = getScheme(r)
}
if isDirectDownload {
shareURL = fmt.Sprintf("%s://%s%spublic/api/raw?hash=%s%s", scheme, host, config.Server.BaseURL, hash, tokenParam)
shareURL = fmt.Sprintf("%s://%s%spublic/api/resources/download?hash=%s%s", scheme, host, config.Server.BaseURL, hash, tokenParam)
} else {
shareURL = fmt.Sprintf("%s://%s%spublic/share/%s", scheme, host, config.Server.BaseURL, hash)
}
@@ -601,7 +619,7 @@ func getShareURL(r *http.Request, hash string, isDirectDownload bool, token stri
// @Param hash query string true "Hash of the share link"
// @Success 200 {object} share.CommonShare "Share information"
// @Failure 404 {object} map[string]string "Share hash not found"
// @Router /public/api/shareinfo [get]
// @Router /public/api/share/info [get]
func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
hash := r.URL.Query().Get("hash")
// Get the file link by hash (need full Link to get Token)
@@ -611,16 +629,11 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
commonShare := shareLink.CommonShare
commonShare.ShareURL = getShareURL(r, hash, false, "")
_, _, err = getShareImagePartsHelper(shareLink, true)
if err == nil {
commonShare.BannerUrl = fmt.Sprintf("%spublic/api/share/image?banner=true&hash=%s", config.Server.BaseURL, hash)
}
_, _, err = getShareImagePartsHelper(shareLink, false)
if err == nil {
commonShare.FaviconUrl = fmt.Sprintf("%spublic/api/share/image?favicon=true&hash=%s", config.Server.BaseURL, hash)
}
commonShare.BannerUrl = shareLink.BannerURL()
commonShare.FaviconUrl = shareLink.FaviconURL()
commonShare.Source = ""
commonShare.Path = ""
commonShare.DownloadURL = ""
commonShare.SidebarLinks = []users.SidebarLink{}
for _, link := range shareLink.SidebarLinks {
if link.Category == "download" && shareLink.ShareType == "upload" {
@@ -629,6 +642,17 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
commonShare.SidebarLinks = append(commonShare.SidebarLinks, link)
}
}
commonShare.SourceURL = shareLink.SourceURL(d.user)
commonShare.CanEditShare = shareLink.UserCanEdit(d.user)
if commonShare.SourceURL != "" {
link := users.SidebarLink{
Name: "sourceLocation",
Category: "custom",
Target: commonShare.SourceURL,
}
commonShare.SidebarLinks = append(commonShare.SidebarLinks, link)
}
return renderJSON(w, r, commonShare)
}
+32 -119
View File
@@ -3,20 +3,20 @@ package http
import (
"encoding/json"
"fmt"
"html/template"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"text/template"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/filebrowser/backend/icons"
)
var templateRenderer *TemplateRenderer
var cachedManifest PWAManifest // generated at startup
type TemplateRenderer struct {
templates *template.Template
@@ -25,10 +25,18 @@ type TemplateRenderer struct {
// Render renders a template document with headers and data
func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data interface{}) error {
// If in dev mode, reload templates on every render.
templates := t.templates
// If in dev mode, reload templates on every render by creating a fresh instance
if t.devMode {
var err error
t.templates, err = t.templates.ParseFS(assetFs, "public/index.html")
templates = template.New("").Funcs(template.FuncMap{
"marshal": func(v interface{}) (string, error) {
var a []byte
a, err = json.Marshal(v)
return string(a), err
},
})
templates, err = templates.ParseFS(assetFs, "public/index.html")
if err != nil {
return fmt.Errorf("error reloading template: %w", err)
}
@@ -39,7 +47,7 @@ func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data inter
w.Header().Set("X-Accel-Expires", "0")
w.Header().Set("Transfer-Encoding", "identity")
// Execute the template with the provided data
return t.templates.ExecuteTemplate(w, name, data)
return templates.ExecuteTemplate(w, name, data)
}
func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestContext, file, contentType string) (int, error) {
@@ -111,21 +119,10 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
if d.share.DisableSidebar {
disableSidebar = true
}
if strings.HasPrefix(strings.ToLower(d.share.Banner), "http") {
banner = d.share.Banner
} else {
_, _, err := getShareImagePartsHelper(d.share, true)
if err == nil {
banner = fmt.Sprintf("%s%spublic/api/share/image?banner=true&hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, d.share.Hash)
}
}
if strings.HasPrefix(strings.ToLower(d.share.Favicon), "http") {
favicon = d.share.Favicon
} else {
_, _, err := getShareImagePartsHelper(d.share, false)
if err == nil {
favicon = fmt.Sprintf("%s%spublic/api/share/image?favicon=true&hash=%s", config.Server.ExternalUrl, config.Server.BaseURL, d.share.Hash)
}
banner = d.share.BannerURL()
shareFavicon := d.share.FaviconURL()
if shareFavicon != "" {
favicon = shareFavicon
}
shareHash = d.share.Hash
}
@@ -180,8 +177,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
data["htmlVars"] = map[string]interface{}{
"title": title,
"customCSS": config.Frontend.Styling.CustomCSSRaw,
"userSelectedTheme": userSelectedTheme,
"customCSS": template.CSS(config.Frontend.Styling.CustomCSSRaw),
"userSelectedTheme": template.CSS(userSelectedTheme),
"lightBackground": config.Frontend.Styling.LightBackground,
"darkBackground": config.Frontend.Styling.DarkBackground,
"staticURL": staticURL,
@@ -192,7 +189,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"winIcon": staticURL + "/mstile-256x256.png",
"appIcon": staticURL + "/apple-touch-icon.png",
"description": description,
"loadingSpinnersCSS": loadingSpinnersCSS,
"loadingSpinnersCSS": template.CSS(loadingSpinnersCSS),
"banner": banner,
"image": ogImage,
"url": fullURL,
@@ -219,7 +216,9 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"oidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
"proxyAvailable": config.Auth.Methods.ProxyAuth.Enabled,
"passwordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
"ldapAvailable": config.Auth.Methods.LdapAuth.Enabled,
"mediaAvailable": settings.MediaEnabled(),
"exiftoolAvailable": settings.Config.Integrations.Media.ExiftoolPath != "",
"muPdfAvailable": settings.Env.MuPdfAvailable,
"updateAvailable": utils.GetUpdateAvailableUrl(),
"disableNavButtons": disableNavButtons,
@@ -230,6 +229,7 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"disableSidebar": disableSidebar,
"shareHash": shareHash,
"oidcLoginButtonText": config.Frontend.OIDCLoginButtonText,
"loginButtonText": config.Frontend.LoginButtonText,
}
// Marshal each variable to JSON strings for direct template usage
@@ -238,8 +238,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
return http.StatusInternalServerError, fmt.Errorf("error marshaling globalVars: %w", err)
}
// Replace with JSON strings for direct template usage
data["globalVars"] = string(globalVarsJSON)
// Mark as JS for html/template to avoid escaping
data["globalVars"] = template.JS(globalVarsJSON)
// Render the template with global variables
if err := templateRenderer.Render(w, file, data); err != nil {
@@ -282,7 +282,7 @@ func manifestHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/manifest+json")
w.Header().Set("Cache-Control", "public, max-age=3600") // Cache for 1 hour
if err := json.NewEncoder(w).Encode(cachedManifest); err != nil {
if err := json.NewEncoder(w).Encode(icons.CachedManifest); err != nil {
http.Error(w, "Failed to serve manifest", http.StatusInternalServerError)
}
}
@@ -294,11 +294,8 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", `default-src 'self'; style-src 'unsafe-inline';`)
// Strip baseURL and /static/ prefix to get clean asset path
path := r.URL.Path
path = strings.TrimPrefix(path, config.Server.BaseURL) // Remove /testing/ or other baseURL
path = strings.TrimPrefix(path, "/") // Remove leading slash FIRST
path = strings.TrimPrefix(path, "static/") // Then remove static/ prefix
path := strings.TrimPrefix(r.URL.Path, "/public")
path = strings.TrimPrefix(path, "/static/")
// Handle special routes that need path mapping
var assetPath string
switch path {
@@ -323,9 +320,9 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
}
// Fall back to embedded default favicon.png
assetPath = "img/icons/favicon.png"
case "favicon-32x32.png",
"pwa-icon-192.png", "pwa-icon-256.png", "pwa-icon-512.png",
"apple-touch-icon.png":
case "icons/favicon-32x32.png",
"icons/pwa-icon-192.png", "icons/pwa-icon-256.png", "icons/pwa-icon-512.png",
"icons/apple-touch-icon.png":
// Serve generated icons from cache directory
iconPath := filepath.Join(settings.Env.PWAIconsDir, path)
if _, err := os.Stat(iconPath); err == nil {
@@ -334,7 +331,7 @@ func staticAssetHandler(w http.ResponseWriter, r *http.Request) {
}
// Fall back to embedded favicon.png if generation failed
assetPath = "img/icons/favicon.png"
case "mstile-256x256.png":
case "icons/mstile-256x256.png":
// Windows tile - redirect to pwa-icon-256.png (they're identical)
iconPath := filepath.Join(settings.Env.PWAIconsDir, "pwa-icon-256.png")
if _, err := os.Stat(iconPath); err == nil {
@@ -395,87 +392,3 @@ func indexHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (in
}
return handleWithStaticData(w, r, d, "index.html", "text/html")
}
// PWAManifest represents the web app manifest structure
type PWAManifest struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
Icons []PWAIcon `json:"icons"`
StartURL string `json:"start_url"`
Display string `json:"display"`
BackgroundColor string `json:"background_color"`
ThemeColor string `json:"theme_color"`
Description string `json:"description"`
}
// PWAIcon represents an icon in the web app manifest
type PWAIcon struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
Purpose string `json:"purpose"`
}
// generatePWAManifest creates the PWA manifest structure
func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwaIcon256, pwaIcon512 string) PWAManifest {
shortName := name
if len(name) > 12 {
shortName = name[:12]
}
return PWAManifest{
Name: name,
ShortName: shortName,
StartURL: baseURL,
Display: "standalone",
BackgroundColor: "#ffffff",
ThemeColor: themeColor,
Description: description,
Icons: []PWAIcon{
{
Src: pwaIcon192,
Sizes: "192x192",
Type: "image/png",
Purpose: "any",
},
{
Src: pwaIcon256,
Sizes: "256x256",
Type: "image/png",
Purpose: "any maskable",
},
{
Src: pwaIcon512,
Sizes: "512x512",
Type: "image/png",
Purpose: "any",
},
},
}
}
// InitializePWAManifest generates and caches the PWA manifest at startup
func InitializePWAManifest() {
staticURL := config.Server.BaseURL + "public/static"
title := config.Frontend.Name
description := config.Frontend.Description
defaultThemeColor := "#455a64"
// Determine PWA icon URLs based on custom favicon settings
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
favicon := staticURL + "/favicon"
pwaIcon192 = favicon
pwaIcon256 = favicon
pwaIcon512 = favicon
} else if settings.Env.FaviconIsCustom {
pwaIcon192 = staticURL + "/pwa-icon-192.png"
pwaIcon256 = staticURL + "/pwa-icon-256.png"
pwaIcon512 = staticURL + "/pwa-icon-512.png"
}
cachedManifest = generatePWAManifest(title, description, config.Server.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
}
+2 -2
View File
@@ -11,7 +11,7 @@ import (
// generateOTPHandler handles the generation of a new TOTP secret and QR code.
// @Summary Generate OTP
// @Description Generates a new TOTP secret and QR code for the authenticated user. The password must be URL-encoded and sent in the X-Password header to support special characters.
// @Tags OTP
// @Tags Auth
// @Accept json
// @Produce json
// @Security ApiKeyAuth
@@ -46,7 +46,7 @@ func generateOTPHandler(w http.ResponseWriter, r *http.Request, d *requestContex
// verifyOTPHandler handles the verification of a TOTP code.
// @Summary Verify OTP
// @Description Verifies the provided TOTP code for the authenticated user. The password must be URL-encoded and sent in the X-Password header to support special characters.
// @Tags OTP
// @Tags Auth
// @Accept json
// @Produce json
// @Security ApiKeyAuth
+23
View File
@@ -5,13 +5,16 @@ import (
"fmt"
"io"
"net/http"
"slices"
"sort"
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
type UserRequest struct {
@@ -87,6 +90,7 @@ func userGetHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
func prepForFrontend(u *users.User) {
u.Password = ""
u.ApiKeys = nil
u.Tokens = nil
u.OtpEnabled = u.TOTPSecret != ""
u.TOTPSecret = ""
u.TOTPNonce = ""
@@ -306,10 +310,29 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
req.User.TOTPNonce = ""
}
// Get the old user to check if permissions changed
oldUser, err := store.Users.Get(req.User.ID)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("failed to get user: %w", err)
}
err = store.Users.Update(&req.User, d.user.Permissions.Admin, req.Which...)
if err != nil {
return http.StatusBadRequest, err
}
// Revoke all API keys if API permission was removed
if slices.Contains(req.Which, "Permissions") && oldUser.Permissions.Api && !req.User.Permissions.Api {
for _, tokenInfo := range oldUser.Tokens {
if err := auth.RevokeApiToken(store.Access, tokenInfo.Token); err != nil {
logger.Errorf("Failed to revoke API key: %v", err)
}
// Also remove from HashedTokens
if err := store.Access.RemoveApiToken(tokenInfo.Token); err != nil {
logger.Errorf("Failed to remove api token: %v", err)
}
}
}
return http.StatusNoContent, nil
}
+419 -62
View File
@@ -1,81 +1,438 @@
package http
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/net/webdav"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
commonerrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func createWebDAVHandler(prefix string) handleFunc {
var locks = webdav.NewMemLS()
// webdavHandler serves the webdav requests.
return func(writer http.ResponseWriter, request *http.Request, d *requestContext) (int, error) {
fullPath := request.PathValue("path")
source := request.PathValue("scope")
if !userHasReadWriteAccess(d) {
// (reddec): we're currently allowing WebDAV access only for users with R/W access for simplicity.
// in the follow-ups, we need to add mapping of webdav operations and permissions (which is trickier than it looks like).
return http.StatusForbidden, fmt.Errorf("user is not allowed to create or modify")
}
userscope, err := d.user.GetScopeForSourceName(source)
if err != nil {
logger.Debugf("error getting scope from source name: %v", err)
return http.StatusForbidden, err
}
// fileInfoWrapper wraps iteminfo.ItemInfo to implement os.FileInfo
type fileInfoWrapper struct {
iteminfo.ItemInfo
}
virtualPath := utils.JoinPathAsUnix(userscope, fullPath)
func (f *fileInfoWrapper) Name() string { return f.ItemInfo.Name }
func (f *fileInfoWrapper) Size() int64 { return f.ItemInfo.Size }
func (f *fileInfoWrapper) Mode() os.FileMode { return f.mode() }
func (f *fileInfoWrapper) ModTime() time.Time { return f.ItemInfo.ModTime }
func (f *fileInfoWrapper) IsDir() bool { return f.Type == "directory" }
func (f *fileInfoWrapper) Sys() interface{} { return nil }
idx := indexing.GetIndex(source)
if idx == nil {
logger.Debugf("source %s not found", source)
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
realPath, _, _ := idx.GetRealPath(virtualPath)
scopePath, _, _ := idx.GetRealPath(userscope)
// checking only base (scope) path, as per-item permission is currently impossible to implement.
// however, when we are granting an access to the user, we are granting it per-folder, so everything should be fine.
// TODO: properly handle symlink and hidden files in requests
info, err := idx.GetFsInfo(userscope, false, true)
if err != nil {
logger.Debugf("error getting dir info: %v", err)
return http.StatusNotFound, err
}
err = store.Access.CheckChildItemAccess(&iteminfo.ExtendedFileInfo{
FileInfo: *info,
Source: source,
RealPath: scopePath,
}, idx, d.user.Username)
if err != nil {
logger.Debugf("error checking child item access: %v", err)
return http.StatusForbidden, err
}
logger.Debugf("webdav: method=%s, request=%s, source=%s, path=%s, virtual_path=%s, real_path=%s, scope_path=%s", request.Method, request.URL.Path, source, fullPath, virtualPath, realPath, scopePath)
wd := &webdav.Handler{
Prefix: prefix + "/" + source,
FileSystem: webdav.Dir(scopePath),
LockSystem: locks,
Logger: func(request *http.Request, err error) {
if err != nil {
logger.Errorf("webdav handler failed on path %s: %s", request.URL.Path, err)
}
},
}
wd.ServeHTTP(writer, request)
return 0, nil // errors and responses (XML-formatted) are handled by webdav handler
func (f *fileInfoWrapper) mode() os.FileMode {
if f.IsDir() {
return os.ModeDir | fileutils.PermDir
}
return fileutils.PermFile
}
func userHasReadWriteAccess(d *requestContext) bool {
return d.user.Permissions.Create && d.user.Permissions.Delete && d.user.Permissions.Modify && d.user.Permissions.Download
// filteredFileSystem wraps a webdav.FileSystem and filters directory listings using FileInfoFaster
type filteredFileSystem struct {
fs webdav.FileSystem
source string
user *users.User
userScope string
// Cache FileInfoFaster results per path to avoid redundant calls within the same request
fileInfoCache map[string]*iteminfo.ExtendedFileInfo
}
// getFileInfo retrieves file information with caching and access control
// requestPath should NOT include user scope - FileInfoFaster applies it internally
// This function prefers cache, then tries FileInfoFaster which applies access control
func (ffs *filteredFileSystem) getFileInfo(requestPath string, expand bool) (*iteminfo.ExtendedFileInfo, error) {
// Initialize cache if needed
if ffs.fileInfoCache == nil {
ffs.fileInfoCache = make(map[string]*iteminfo.ExtendedFileInfo)
}
// Create cache key (don't modify requestPath itself!)
cacheKey := requestPath
if expand {
cacheKey += ":expand"
}
if cached, found := ffs.fileInfoCache[cacheKey]; found {
return cached, nil
}
// If we're looking for non-expanded but have expanded cached, we can use it
// (expanded contains all the same info plus more)
if !expand {
expandedKey := requestPath + ":expand"
if cached, found := ffs.fileInfoCache[expandedKey]; found {
return cached, nil
}
}
// Call FileInfoFaster with clean requestPath (without scope or cache suffix)
// FileInfoFaster applies user scope internally AND enforces access control
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Path: requestPath,
Source: ffs.source,
Expand: expand,
ShowHidden: ffs.user.ShowHidden,
SkipExtendedAttrs: true,
FollowSymlinks: true,
}, store.Access, ffs.user, store.Share)
if err != nil {
return nil, err
}
// Cache result using the cache key
ffs.fileInfoCache[cacheKey] = fileInfo
// If we got an expanded directory listing, also cache individual file entries
// so Stat calls on those files can use the cache
if expand && fileInfo.Type == "directory" {
dirPath := requestPath
// Cache each file and folder from the directory listing
for _, file := range fileInfo.Files {
filePath := utils.JoinPathAsUnix(dirPath, file.Name)
// Create a minimal ExtendedFileInfo for the file (without expanding its contents)
fileEntry := &iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
ItemInfo: file.ItemInfo,
},
}
ffs.fileInfoCache[filePath] = fileEntry
}
for _, folder := range fileInfo.Folders {
folderPath := utils.JoinPathAsUnix(dirPath, folder.Name)
// Create a minimal ExtendedFileInfo for the folder (without expanding its contents)
folderEntry := &iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
ItemInfo: folder,
},
}
ffs.fileInfoCache[folderPath] = folderEntry
}
}
return fileInfo, nil
}
// checkAccess validates if the user can access a given path
// This is used by all write operations (mkdir, delete, rename, etc.)
// Returns nil if access is allowed, error otherwise
func (ffs *filteredFileSystem) checkAccess(requestPath string) error {
// First, validate permissions using CheckPermissions
indexPath, _, err := files.CheckPermissions(utils.FileOptions{
FollowSymlinks: false,
Path: requestPath,
Source: ffs.source,
ShowHidden: ffs.user.ShowHidden,
}, store.Access, ffs.user)
if err != nil {
logger.Debugf("checkAccess: CheckPermissions denied for %s: %v", requestPath, err)
return err
}
// Try to get file info to verify it's accessible
// Use non-expanded to be faster (we just need to know if it exists and is accessible)
_, err = ffs.getFileInfo(requestPath, false)
if err == nil {
// Successfully got info - access allowed
return nil
}
// Handle specific error cases
if errors.Is(err, commonerrors.ErrAccessDenied) {
logger.Debugf("checkAccess: access explicitly denied for %s", requestPath)
return os.ErrPermission
}
// CRITICAL: If item is not indexed AND not viewable, deny access
// This prevents WebDAV from creating/modifying files in non-indexed areas
if errors.Is(err, commonerrors.ErrNotViewable) {
logger.Debugf("checkAccess: path not viewable for %s", requestPath)
return os.ErrPermission
}
// If not indexed but potentially viewable, we need to check more carefully
if errors.Is(err, commonerrors.ErrNotIndexed) {
// Get the index to check viewability
idx := indexing.GetIndex(ffs.source)
if idx == nil {
return fmt.Errorf("source not found")
}
// Check if the item is viewable using GetFileInfo (without expand)
info, getErr := idx.GetFileInfo(indexing.FileInfoRequest{
IndexPath: indexPath,
FollowSymlinks: false,
ShowHidden: ffs.user.ShowHidden,
Expand: false,
SkipExtendedAttrs: true,
})
// If GetFileInfo succeeds, the item is viewable despite not being indexed
if getErr == nil && info != nil {
logger.Debugf("checkAccess: path not indexed but viewable for %s", requestPath)
return nil // Allow access to viewable items
}
// Not viewable - deny access
logger.Debugf("checkAccess: path not indexed and not viewable for %s", requestPath)
return os.ErrPermission
}
// For other errors (like file not found), that's okay for new file/directory creation
// The caller will handle whether the operation is appropriate
logger.Debugf("checkAccess: path check returned: %v (may be acceptable for new items)", err)
return nil
}
// filteredFile wraps a webdav.File and filters Readdir results based on FileInfoFaster
type filteredFile struct {
webdav.File
fs *filteredFileSystem
requestPath string // The request path (without user scope)
isDir bool // Whether this is a directory
}
func (ff *filteredFile) Readdir(count int) ([]os.FileInfo, error) {
// If not a directory, use the underlying file's Readdir
if !ff.isDir {
return ff.File.Readdir(count)
}
// Pass the requestPath (without scope) to getCachedFileInfo
// FileInfoFaster will apply the user's scope internally
fileInfo, err := ff.fs.getFileInfo(ff.requestPath, true)
if err != nil {
logger.Debugf("readdir: getFileInfo failed for requestPath=%s: %v", ff.requestPath, err)
// Handle errors gracefully - return empty directory for access/indexing issues
// This is especially important when user's scope points to a non-viewable directory
if errors.Is(err, commonerrors.ErrAccessDenied) ||
errors.Is(err, commonerrors.ErrNotIndexed) ||
errors.Is(err, commonerrors.ErrNotViewable) {
logger.Debugf("readdir: access issue for %s: %v - returning empty", ff.requestPath, err)
return []os.FileInfo{}, nil
}
// Other errors - propagate them
return nil, err
}
// Build os.FileInfo list directly from FileInfoFaster's filtered results
// No need to read from underlying filesystem - FileInfoFaster already filtered by permissions
entries := make([]os.FileInfo, 0, len(fileInfo.Files)+len(fileInfo.Folders))
// Add folders first (common convention)
for _, folder := range fileInfo.Folders {
entries = append(entries, &fileInfoWrapper{ItemInfo: folder})
}
// Add files
for _, file := range fileInfo.Files {
entries = append(entries, &fileInfoWrapper{ItemInfo: file.ItemInfo})
}
// Handle count parameter
if count > 0 && len(entries) > count {
return entries[:count], nil
}
return entries, nil
}
func (ffs *filteredFileSystem) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
if !ffs.user.Permissions.Create {
return fmt.Errorf("create permission required")
}
// Check access before creating directory
if err := ffs.checkAccess(name); err != nil {
logger.Debugf("Mkdir: access denied for %s: %v", name, err)
return err
}
return ffs.fs.Mkdir(ctx, name, perm)
}
func (ffs *filteredFileSystem) OpenFile(ctx context.Context, requestPath string, flag int, perm os.FileMode) (webdav.File, error) {
// Check if this is a write operation
isWrite := (flag&os.O_WRONLY) != 0 || (flag&os.O_RDWR) != 0 || (flag&os.O_CREATE) != 0
if isWrite {
// Check user permissions first
if !ffs.user.Permissions.Create && !ffs.user.Permissions.Modify {
return nil, fmt.Errorf("write permission required")
}
// For write operations, check access
if err := ffs.checkAccess(requestPath); err != nil {
logger.Debugf("OpenFile: write access denied for %s: %v", requestPath, err)
return nil, err
}
}
file, err := ffs.fs.OpenFile(ctx, requestPath, flag, perm)
if err != nil {
return nil, err
}
stat, err := file.Stat()
if err != nil {
file.Close()
return nil, err
}
// Wrap the file to filter directory listings
// name is the request path (without user scope)
return &filteredFile{
File: file,
fs: ffs,
requestPath: requestPath,
isDir: stat.IsDir(),
}, nil
}
func (ffs *filteredFileSystem) RemoveAll(ctx context.Context, requestPath string) error {
if !ffs.user.Permissions.Delete {
return fmt.Errorf("delete permission required")
}
// Check access before deleting
if err := ffs.checkAccess(requestPath); err != nil {
logger.Debugf("RemoveAll: access denied for %s: %v", requestPath, err)
return err
}
return ffs.fs.RemoveAll(ctx, requestPath)
}
func (ffs *filteredFileSystem) Rename(ctx context.Context, oldPath, newPath string) error {
if !ffs.user.Permissions.Modify {
return fmt.Errorf("modify permission required")
}
// Check access for both old and new paths
if err := ffs.checkAccess(oldPath); err != nil {
logger.Debugf("Rename: access denied for source %s: %v", oldPath, err)
return err
}
if err := ffs.checkAccess(newPath); err != nil {
logger.Debugf("Rename: access denied for destination %s: %v", newPath, err)
return err
}
return ffs.fs.Rename(ctx, oldPath, newPath)
}
func (ffs *filteredFileSystem) Stat(ctx context.Context, requestPath string) (os.FileInfo, error) {
// Try to get file info (uses cache if available, FileInfoFaster otherwise)
_, err := ffs.getFileInfo(requestPath, false)
if err == nil {
// Successfully got info - use underlying filesystem
return ffs.fs.Stat(ctx, requestPath)
}
// Handle specific error cases
if errors.Is(err, commonerrors.ErrAccessDenied) || errors.Is(err, commonerrors.ErrNotViewable) {
return nil, os.ErrPermission
}
// For not indexed, check if it's viewable
if errors.Is(err, commonerrors.ErrNotIndexed) {
// Use checkAccess to determine if viewable
if accessErr := ffs.checkAccess(requestPath); accessErr != nil {
return nil, os.ErrPermission
}
// Viewable - allow underlying filesystem
return ffs.fs.Stat(ctx, requestPath)
}
// For other errors (like file not found), let underlying filesystem handle it
return ffs.fs.Stat(ctx, requestPath)
}
// webDAVHandler serves WebDAV requests.
func webDAVHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if !d.user.Permissions.Download {
return http.StatusForbidden, fmt.Errorf("download permission required")
}
if r.Method == "DELETE" && !d.user.Permissions.Delete {
return http.StatusForbidden, fmt.Errorf("delete permission required")
}
isWrite := r.Method == http.MethodPut || r.Method == "MKCOL"
if isWrite && !userCanWrite(d.user.Permissions) {
return http.StatusForbidden, fmt.Errorf("user has no permission to modify")
}
requestPath := utils.AddTrailingSlashIfNotExists(r.PathValue("path"))
source := r.PathValue("source")
if !strings.HasPrefix(requestPath, "/") {
requestPath = "/" + requestPath
}
logger.Debugf("webdav: method=%s, request=%s, source=%s, requestPath=%s", r.Method, r.URL.Path, source, requestPath)
_, userScope, err := files.CheckPermissions(utils.FileOptions{
FollowSymlinks: false,
Path: requestPath,
Source: source,
ShowHidden: d.user.ShowHidden,
}, store.Access, d.user)
if err != nil {
return http.StatusForbidden, err
}
idx := indexing.GetIndex(source)
if idx == nil {
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
// Get the user's scope to determine the WebDAV root directory
// Resolve the scope path to get the real filesystem root for WebDAV
// This is the root directory that WebDAV will use to resolve relative paths
scopePath, _, err := idx.GetRealPath(userScope)
if err != nil {
return http.StatusNotFound, err
}
// Construct the WebDAV prefix from BaseURL
webDavPrefix := config.Server.BaseURL + "dav"
prefix := webDavPrefix + "/" + source
// Wrap the filesystem to filter directory listings using FileInfoFaster
// We pass requestPath (without scope) to FileInfoFaster, which applies scope internally
filteredFS := &filteredFileSystem{
fs: webdav.Dir(scopePath),
source: source,
user: d.user,
userScope: userScope,
}
wd := &webdav.Handler{
Prefix: prefix,
FileSystem: filteredFS,
LockSystem: idx.WebdavLock,
Logger: func(req *http.Request, err error) {
if err != nil {
errStr := err.Error()
// Filter out expected/benign errors that don't indicate actual failures
if strings.Contains(errStr, "no such file or directory") ||
strings.Contains(errStr, "skip this directory") {
return
}
logger.Errorf("webdav handler failed on path %s: %s", req.URL.Path, err)
}
},
}
wd.ServeHTTP(w, r)
return 200, nil // errors and responses (XML-formatted) are handled by webdav handler
}
func userCanWrite(permissions users.Permissions) bool {
return permissions.Create && permissions.Modify && permissions.Delete
}
+803
View File
@@ -0,0 +1,803 @@
package http
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
commonerrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
// setupWebDAVTestEnv sets up the test environment with multiple sources and users
func setupWebDAVTestEnv(t *testing.T) (string, string) {
// Create temp directories for test sources
tempDir := t.TempDir()
source1Path := filepath.Join(tempDir, "source1")
source2Path := filepath.Join(tempDir, "source2")
dockerPath := filepath.Join(source1Path, "_docker")
// Create directory structure
dirs := []string{
filepath.Join(source1Path, "public"),
filepath.Join(source1Path, "private"),
filepath.Join(source1Path, "viewable-only"),
filepath.Join(source1Path, "not-viewable"),
dockerPath,
filepath.Join(dockerPath, "data"),
filepath.Join(source2Path, "shared"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatal(err)
}
}
// Create test files
testFiles := map[string]string{
filepath.Join(source1Path, "public", "readme.txt"): "public content",
filepath.Join(source1Path, "private", "secret.txt"): "private content",
filepath.Join(source1Path, "viewable-only", "data.txt"): "viewable content",
filepath.Join(source1Path, "not-viewable", "hidden.txt"): "hidden content",
filepath.Join(dockerPath, "data", "config.yml"): "docker config",
filepath.Join(source2Path, "shared", "document.txt"): "shared content",
}
for path, content := range testFiles {
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
// Setup database
dbPath := filepath.Join(tempDir, "db")
db, err := storm.Open(dbPath)
if err != nil {
t.Fatal(err)
}
store, err = bolt.NewStorage(db)
if err != nil {
t.Fatal(err)
}
// Clear any previous access rules
store.Access.AllRules = make(access.SourceRuleMap)
store.Access.Groups = make(access.GroupMap)
// Set cache directory for index database
settings.Config.Server.CacheDir = tempDir
// Setup config with sources - update both the local config var AND the global settings.Config
config = &settings.Settings{
Server: settings.Server{
BaseURL: "/", // Use / for tests to avoid prefix mismatch
CacheDir: tempDir,
SourceMap: map[string]*settings.Source{
source1Path: {
Path: source1Path,
Name: "source1",
},
source2Path: {
Path: source2Path,
Name: "source2",
},
},
NameToSource: map[string]*settings.Source{
"source1": {
Path: source1Path,
Name: "source1",
},
"source2": {
Path: source2Path,
Name: "source2",
},
},
},
}
// CRITICAL: Also update the global settings.Config so access.Permitted can find sources
settings.Config.Server.SourceMap = config.Server.SourceMap
settings.Config.Server.NameToSource = config.Server.NameToSource
// Initialize user resolvers
settings.InitializeUserResolvers()
// Create minimal mock indices for webdav handler
indexing.SetTestIndex("source1", source1Path)
indexing.SetTestIndex("source2", source2Path)
t.Cleanup(func() {
indexing.ClearTestIndices()
})
// Mock CheckPermissions to bypass index requirement
mockCheckPermissions(t, source1Path, source2Path)
// Initialize mock indexing - this mocks FileInfoFaster
mockWebDAVIndexing(t, source1Path, source2Path)
return source1Path, source2Path
}
// mockCheckPermissions mocks the CheckPermissions function to bypass index lookups
func mockCheckPermissions(t *testing.T, source1Path, source2Path string) {
t.Helper()
// Store original function
originalCheckPermissions := files.CheckPermissionsFunc
t.Cleanup(func() { files.CheckPermissionsFunc = originalCheckPermissions })
// Mock the function
files.CheckPermissionsFunc = func(opts utils.FileOptions, access *access.Storage, user *users.User) (string, string, error) {
// Get the source path
var sourcePath string
if opts.Source == "source1" {
sourcePath = source1Path
} else if opts.Source == "source2" {
sourcePath = source2Path
} else {
return "", "", fmt.Errorf("unknown source: %s", opts.Source)
}
// Get user scope for this source - match by source PATH, not name
userScope := "/"
hasScope := false
for _, scope := range user.Scopes {
if scope.Name == sourcePath {
userScope = scope.Scope
hasScope = true
break
}
}
// If user has no scope for this source, deny access
if !hasScope && len(user.Scopes) > 0 {
return "", "", fmt.Errorf("user has no access to source: %s", opts.Source)
}
// Sanitize path
safePath, err := utils.SanitizeUserPath(opts.Path)
if err != nil {
return "", "", commonerrors.ErrAccessDenied
}
// Combine scope + sanitized path
indexPath := utils.JoinPathAsUnix(userScope, safePath)
// Check access control
if access != nil && !access.Permitted(sourcePath, indexPath, user.Username) {
return "", "", commonerrors.ErrAccessDenied
}
return indexPath, userScope, nil
}
}
// mockWebDAVIndexing mocks the indexing system for WebDAV tests
func mockWebDAVIndexing(t *testing.T, source1Path, source2Path string) {
// Mock FileInfoFaster to simulate indexing behavior
originalFileInfoFaster := files.FileInfoFasterFunc
t.Cleanup(func() { files.FileInfoFasterFunc = originalFileInfoFaster })
files.FileInfoFasterFunc = func(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
// Resolve source name to source path first
sourcePath := ""
if opts.Source == "source1" {
sourcePath = source1Path
} else if opts.Source == "source2" {
sourcePath = source2Path
} else {
return nil, fmt.Errorf("unknown source: %s", opts.Source)
}
// Simulate access control
if access != nil && user != nil {
// Simple scope check based on user's configured scopes (which use source PATH)
hasAccess := false
for _, scope := range user.Scopes {
if scope.Name == sourcePath {
hasAccess = true
// Check if path is within user's scope
userScope := scope.Scope
fullPath := utils.JoinPathAsUnix(userScope, opts.Path)
// Check if user has permission using the access storage
if !access.Permitted(sourcePath, fullPath, user.Username) {
return nil, commonerrors.ErrAccessDenied
}
break
}
}
if !hasAccess && len(user.Scopes) > 0 {
return nil, commonerrors.ErrAccessDenied
}
}
// Simulate indexing rules
path := opts.Path
// Paths containing "not-viewable" are not indexed and not viewable
if strings.Contains(path, "not-viewable") {
return nil, commonerrors.ErrNotViewable
}
// Paths containing "viewable-only" are not indexed but viewable
if strings.Contains(path, "viewable-only") {
return nil, commonerrors.ErrNotIndexed
}
// Simulate directory expansion
var files []iteminfo.ExtendedItemInfo
var folders []iteminfo.ItemInfo
if opts.Expand && (path == "/" || strings.HasSuffix(path, "/")) {
// Return mock directory contents based on path
if path == "/" || path == "" {
folders = []iteminfo.ItemInfo{
{Name: "public", Type: "directory"},
{Name: "private", Type: "directory"},
{Name: "viewable-only", Type: "directory"},
}
} else if strings.Contains(path, "public") {
files = []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "readme.txt", Size: 14, Type: "text/plain"}},
}
} else if strings.Contains(path, "private") {
files = []iteminfo.ExtendedItemInfo{
{ItemInfo: iteminfo.ItemInfo{Name: "secret.txt", Size: 15, Type: "text/plain"}},
}
}
}
return &iteminfo.ExtendedFileInfo{
FileInfo: iteminfo.FileInfo{
Path: path,
Files: files,
Folders: folders,
ItemInfo: iteminfo.ItemInfo{
Name: filepath.Base(path),
Type: "directory",
},
},
}, nil
}
}
// Test PROPFIND with different user scopes
func TestWebDAV_PROPFIND_UserScopes(t *testing.T) {
source1Path, source2Path := setupWebDAVTestEnv(t)
// Create users with different scopes
// IMPORTANT: SourceScope.Name should be the source PATH, not the source name
adminUser := &users.User{
ID: 1,
Username: "admin",
Permissions: users.Permissions{
Admin: true,
Download: true,
Create: true,
Delete: true,
Modify: true,
},
Scopes: []users.SourceScope{
{Name: source1Path, Scope: "/"},
{Name: source2Path, Scope: "/"},
},
}
scopedUser := &users.User{
ID: 2,
Username: "scoped",
Permissions: users.Permissions{
Download: true,
},
Scopes: []users.SourceScope{
{Name: source1Path, Scope: "/_docker"},
},
}
restrictedUser := &users.User{
ID: 3,
Username: "restricted",
Permissions: users.Permissions{
Download: true,
},
Scopes: []users.SourceScope{
{Name: source1Path, Scope: "/public"},
},
}
// Save users
if err := store.Users.Save(adminUser, true, true); err != nil {
t.Fatal(err)
}
if err := store.Users.Save(scopedUser, true, true); err != nil {
t.Fatal(err)
}
if err := store.Users.Save(restrictedUser, true, true); err != nil {
t.Fatal(err)
}
testCases := []struct {
name string
user *users.User
source string
path string
expectedStatus int
shouldHaveContent bool
}{
{
name: "Admin can access root of source1",
user: adminUser,
source: "source1",
path: "/",
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
shouldHaveContent: true,
},
{
name: "Scoped user can access their scope root",
user: scopedUser,
source: "source1",
path: "/",
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
shouldHaveContent: false, // Empty because _docker is not viewable
},
{
name: "Restricted user can only see public folder",
user: restrictedUser,
source: "source1",
path: "/",
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
shouldHaveContent: true,
},
{
name: "Admin can access source2",
user: adminUser,
source: "source2",
path: "/",
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
shouldHaveContent: true,
},
{
name: "Scoped user cannot access source2",
user: scopedUser,
source: "source2",
path: "/",
expectedStatus: http.StatusForbidden,
},
}
// Initialize indexing for sources
initTestIndex(t, "source1", source1Path)
initTestIndex(t, "source2", source2Path)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("PROPFIND", "/dav/"+tc.source+tc.path, nil)
req.SetPathValue("source", tc.source)
req.SetPathValue("path", tc.path)
w := httptest.NewRecorder()
ctx := &requestContext{user: tc.user}
status, err := webDAVHandler(w, req, ctx)
// If handler returned an error status, use that instead of response code
if status != 0 && status != http.StatusOK {
if w.Code == 0 || w.Code == http.StatusOK {
w.Code = status
}
}
// Check the actual HTTP response status code from ResponseWriter
if w.Code != tc.expectedStatus {
t.Errorf("expected status %d, got %d (err: %v)", tc.expectedStatus, w.Code, err)
}
if tc.shouldHaveContent && w.Code == 207 {
body := w.Body.String()
if body == "" {
t.Error("expected response body to have content")
}
}
})
}
}
// Test WebDAV write operations with access control
func TestWebDAV_WriteOperations(t *testing.T) {
source1Path, _ := setupWebDAVTestEnv(t)
// Create users with different permissions
// IMPORTANT: SourceScope.Name should be the source PATH, not the source name
fullAccessUser := &users.User{
ID: 1,
Username: "fullaccess",
Permissions: users.Permissions{
Download: true,
Create: true,
Delete: true,
Modify: true,
},
Scopes: []users.SourceScope{
{Name: source1Path, Scope: "/"},
},
}
readOnlyUser := &users.User{
ID: 2,
Username: "readonly",
Permissions: users.Permissions{
Download: true,
},
Scopes: []users.SourceScope{
{Name: source1Path, Scope: "/"},
},
}
scopedUser := &users.User{
ID: 3,
Username: "scoped",
Permissions: users.Permissions{
Download: true,
Create: true,
Modify: true,
Delete: true,
},
Scopes: []users.SourceScope{
{Name: source1Path, Scope: "/public"},
},
}
// Save users
if err := store.Users.Save(fullAccessUser, true, true); err != nil {
t.Fatal(err)
}
if err := store.Users.Save(readOnlyUser, true, true); err != nil {
t.Fatal(err)
}
if err := store.Users.Save(scopedUser, true, true); err != nil {
t.Fatal(err)
}
initTestIndex(t, "source1", source1Path)
testCases := []struct {
name string
user *users.User
method string
path string
expectedStatus int
}{
{
name: "Full access user can PUT file",
user: fullAccessUser,
method: http.MethodPut,
path: "/public/newfile.txt",
expectedStatus: http.StatusCreated, // 201 for new file creation
},
{
name: "Read-only user cannot PUT file",
user: readOnlyUser,
method: http.MethodPut,
path: "/public/newfile.txt",
expectedStatus: http.StatusForbidden,
},
{
name: "Full access user can MKCOL",
user: fullAccessUser,
method: "MKCOL",
path: "/newdir",
expectedStatus: http.StatusCreated, // 201 for new directory
},
{
name: "Read-only user cannot MKCOL",
user: readOnlyUser,
method: "MKCOL",
path: "/newdir",
expectedStatus: http.StatusForbidden,
},
{
name: "Full access user can DELETE",
user: fullAccessUser,
method: http.MethodDelete,
path: "/public/readme.txt",
expectedStatus: http.StatusNoContent, // 204 for successful deletion
},
{
name: "Read-only user cannot DELETE",
user: readOnlyUser,
method: http.MethodDelete,
path: "/public/readme.txt",
expectedStatus: http.StatusForbidden,
},
{
name: "Scoped user can write in their scope",
user: scopedUser,
method: http.MethodPut,
path: "/scopedfile.txt", // Path relative to scope (/public)
expectedStatus: http.StatusCreated, // 201 for new file
},
{
name: "User cannot write to not-viewable directory",
user: fullAccessUser,
method: http.MethodPut,
path: "/not-viewable/file.txt",
expectedStatus: 404, // Not viewable = acts like it doesn't exist
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var body io.Reader
if tc.method == http.MethodPut {
body = strings.NewReader("test content")
}
req := httptest.NewRequest(tc.method, "/dav/source1"+tc.path, body)
req.SetPathValue("source", "source1")
req.SetPathValue("path", tc.path)
w := httptest.NewRecorder()
ctx := &requestContext{user: tc.user}
status, err := webDAVHandler(w, req, ctx)
// If handler returned an error status, use that instead of response code
if status != 0 && status != http.StatusOK {
if w.Code == 0 || w.Code == http.StatusOK {
w.Code = status
}
}
// Check the actual HTTP response status code from ResponseWriter
if w.Code != tc.expectedStatus {
t.Errorf("expected status %d, got %d (err: %v)", tc.expectedStatus, w.Code, err)
}
})
}
}
// Test access control rules
func TestWebDAV_AccessControl(t *testing.T) {
source1Path, _ := setupWebDAVTestEnv(t)
// Create users
// IMPORTANT: SourceScope.Name should be the source PATH, not the source name
user1 := &users.User{
ID: 1,
Username: "user1",
Permissions: users.Permissions{
Download: true,
Create: true,
},
Scopes: []users.SourceScope{
{Name: source1Path, Scope: "/"},
},
}
user2 := &users.User{
ID: 2,
Username: "user2",
Permissions: users.Permissions{
Download: true,
},
Scopes: []users.SourceScope{
{Name: source1Path, Scope: "/"},
},
}
// Save users
if err := store.Users.Save(user1, true, true); err != nil {
t.Fatal(err)
}
if err := store.Users.Save(user2, true, true); err != nil {
t.Fatal(err)
}
// Initialize index
initTestIndex(t, "source1", source1Path)
// Create access rules by using the internal structure
// User1 can access /private, user2 cannot
// Set up a deny rule for user2 at /private
// IMPORTANT: Access system requires trailing slashes for exact match
rule := &access.AccessRule{
Deny: access.RuleSet{
Users: access.StringSet{"user2": struct{}{}},
},
}
// Add the rule to the access storage
if store.Access.AllRules == nil {
store.Access.AllRules = make(access.SourceRuleMap)
}
if store.Access.AllRules[source1Path] == nil {
store.Access.AllRules[source1Path] = make(access.RuleMap)
}
store.Access.AllRules[source1Path]["/private/"] = rule // Add trailing slash
testCases := []struct {
name string
user *users.User
path string
expectedStatus int
}{
{
name: "User1 can access private folder",
user: user1,
path: "/private",
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
},
{
name: "User2 cannot access private folder",
user: user2,
path: "/private",
expectedStatus: http.StatusForbidden,
},
{
name: "Both users can access public folder",
user: user1,
path: "/public",
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
},
{
name: "User2 can also access public folder",
user: user2,
path: "/public",
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest("PROPFIND", "/dav/source1"+tc.path, nil)
req.SetPathValue("source", "source1")
req.SetPathValue("path", tc.path)
w := httptest.NewRecorder()
ctx := &requestContext{user: tc.user}
status, err := webDAVHandler(w, req, ctx)
// If handler returned an error status, use that instead of response code
if status != 0 && status != http.StatusOK {
if w.Code == 0 || w.Code == http.StatusOK {
w.Code = status
}
}
// Check the actual HTTP response status code from ResponseWriter
if w.Code != tc.expectedStatus {
t.Errorf("expected status %d, got %d (err: %v)", tc.expectedStatus, w.Code, err)
}
})
}
}
// Test indexing states (indexed, viewable, not-viewable)
func TestWebDAV_IndexingStates(t *testing.T) {
source1Path, _ := setupWebDAVTestEnv(t)
// IMPORTANT: SourceScope.Name should be the source PATH, not the source name
user := &users.User{
ID: 1,
Username: "testuser",
Permissions: users.Permissions{
Download: true,
Create: true,
Modify: true,
Delete: true,
},
Scopes: []users.SourceScope{
{Name: source1Path, Scope: "/"},
},
}
if err := store.Users.Save(user, true, true); err != nil {
t.Fatal(err)
}
initTestIndex(t, "source1", source1Path)
testCases := []struct {
name string
path string
method string
expectedStatus int
description string
}{
{
name: "Can read indexed directory",
path: "/public",
method: "PROPFIND",
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
description: "Indexed directories should be accessible",
},
{
name: "Can read viewable-only directory",
path: "/viewable-only",
method: "PROPFIND",
expectedStatus: 207, // PROPFIND returns 207 Multi-Status
description: "Not indexed but viewable directories should be readable",
},
{
name: "Cannot read not-viewable directory",
path: "/not-viewable",
method: "PROPFIND",
expectedStatus: 405, // WebDAV returns 405 Method Not Allowed when resource denied
description: "Not viewable directories should be denied",
},
{
name: "Cannot write to viewable-only directory",
path: "/viewable-only/newfile.txt",
method: http.MethodPut,
expectedStatus: 404, // Returns 404 when trying to write to non-existent file in denied location
description: "Viewable-only directories should deny write",
},
{
name: "Cannot write to not-viewable directory",
path: "/not-viewable/newfile.txt",
method: http.MethodPut,
expectedStatus: 404, // Not viewable = acts like it doesn't exist
description: "Not viewable directories should deny all access",
},
{
name: "Can write to indexed directory",
path: "/public/newfile.txt",
method: http.MethodPut,
expectedStatus: http.StatusCreated, // 201 for new file creation
description: "Indexed directories should allow write with permissions",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var body io.Reader
if tc.method == http.MethodPut {
body = strings.NewReader("test content")
}
req := httptest.NewRequest(tc.method, "/dav/source1"+tc.path, body)
req.SetPathValue("source", "source1")
req.SetPathValue("path", tc.path)
w := httptest.NewRecorder()
ctx := &requestContext{user: user}
status, err := webDAVHandler(w, req, ctx)
// If handler returned an error status, use that instead of response code
if status != 0 && status != http.StatusOK {
if w.Code == 0 || w.Code == http.StatusOK {
w.Code = status
}
}
// Check the actual HTTP response status code from ResponseWriter
if w.Code != tc.expectedStatus {
t.Errorf("%s: expected status %d, got %d (err: %v)", tc.description, tc.expectedStatus, w.Code, err)
}
})
}
}
// Helper function to initialize a test index - simplified for WebDAV tests
func initTestIndex(t *testing.T, name, path string) {
// For WebDAV tests, indices are already initialized in setupWebDAVTestEnv
t.Helper()
}
+12
View File
@@ -233,7 +233,19 @@ func generateSingleIcon(previewService *preview.Service, sourceData []byte, icon
// All icons are generated as PNG for maximum compatibility (Apple devices, PWA, older browsers)
func GeneratePWAIcons() error {
logger.Info("Generating PWA icons...")
// Set PWA icons directory in cache
settings.Env.PWAIconsDir = filepath.Join(settings.Config.Server.CacheDir, "icons")
// Initialize with default paths (all icons will be generated)
settings.Env.PWAIcon192 = "icons/pwa-icon-192.png"
settings.Env.PWAIcon256 = "icons/pwa-icon-256.png"
settings.Env.PWAIcon512 = "icons/pwa-icon-512.png"
// Create icons directory with configured permissions
// Note: Parent cache directory should already exist from testCacheDirSpeed()
if err := os.MkdirAll(settings.Env.PWAIconsDir, fileutils.PermDir); err != nil {
logger.Warningf("Failed to create PWA icons directory %s: %v", settings.Env.PWAIconsDir, err)
}
// Load and prepare source data as PNG
sourceData, err := loadFaviconSource()
if err != nil {
+96
View File
@@ -0,0 +1,96 @@
package icons
import (
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
)
// PWAManifest represents the web app manifest structure
type PWAManifest struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
Icons []PWAIcon `json:"icons"`
StartURL string `json:"start_url"`
Display string `json:"display"`
BackgroundColor string `json:"background_color"`
ThemeColor string `json:"theme_color"`
Description string `json:"description"`
}
// PWAIcon represents an icon in the web app manifest
type PWAIcon struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
Purpose string `json:"purpose"`
}
// CachedManifest holds the generated PWA manifest at startup
var CachedManifest PWAManifest
// generatePWAManifest creates the PWA manifest structure
func generatePWAManifest(name, description, baseURL, themeColor, pwaIcon192, pwaIcon256, pwaIcon512 string) PWAManifest {
shortName := name
if len(name) > 12 {
shortName = name[:12]
}
return PWAManifest{
Name: name,
ShortName: shortName,
StartURL: baseURL,
Display: "standalone",
BackgroundColor: "#ffffff",
ThemeColor: themeColor,
Description: description,
Icons: []PWAIcon{
{
Src: pwaIcon192,
Sizes: "192x192",
Type: "image/png",
Purpose: "any",
},
{
Src: pwaIcon256,
Sizes: "256x256",
Type: "image/png",
Purpose: "any maskable",
},
{
Src: pwaIcon512,
Sizes: "512x512",
Type: "image/png",
Purpose: "any",
},
},
}
}
// InitializePWAManifest generates and caches the PWA manifest at startup
func InitializePWAManifest() {
config := &settings.Config
staticURL := config.Server.BaseURL + "public/static"
title := config.Frontend.Name
description := config.Frontend.Description
defaultThemeColor := "#455a64"
// Determine PWA icon URLs based on custom favicon settings
pwaIcon192 := staticURL + "/" + settings.Env.PWAIcon192
pwaIcon256 := staticURL + "/" + settings.Env.PWAIcon256
pwaIcon512 := staticURL + "/" + settings.Env.PWAIcon512
if settings.Env.FaviconIsCustom && strings.ToLower(filepath.Ext(settings.Env.FaviconPath)) == ".svg" {
favicon := staticURL + "/favicon"
pwaIcon192 = favicon
pwaIcon256 = favicon
pwaIcon512 = favicon
} else if settings.Env.FaviconIsCustom {
pwaIcon192 = staticURL + "/icons/pwa-icon-192.png"
pwaIcon256 = staticURL + "/icons/pwa-icon-256.png"
pwaIcon512 = staticURL + "/icons/pwa-icon-512.png"
}
CachedManifest = generatePWAManifest(title, description, config.Server.BaseURL, defaultThemeColor, pwaIcon192, pwaIcon256, pwaIcon512)
}
+147
View File
@@ -5,6 +5,7 @@ import (
"time"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
)
// setupConditionalTestIndex creates a test index with conditional rules configured
@@ -623,3 +624,149 @@ func TestIsNeverWatchPath(t *testing.T) {
})
}
}
// TestIsViewable_DoesNotMutateFolderPaths verifies that IsViewable doesn't modify FolderPaths
func TestIsViewable_DoesNotMutateFolderPaths(t *testing.T) {
idx := setupConditionalTestIndex(settings.ConditionalFilter{})
idx.Config.ResolvedRules.FolderNames["private"] = settings.ConditionalRule{
FolderNames: "private",
Viewable: false,
}
if len(idx.Config.ResolvedRules.FolderPaths) != 0 {
t.Fatalf("expected no preloaded folder path rules, got %d", len(idx.Config.ResolvedRules.FolderPaths))
}
if idx.IsViewable(true, "/private/", false, false) {
t.Fatal("expected /private/ to be non-viewable")
}
if len(idx.Config.ResolvedRules.FolderPaths) != 0 {
t.Fatalf("IsViewable should not mutate FolderPaths, got %d entries", len(idx.Config.ResolvedRules.FolderPaths))
}
}
// TestIsViewableWithParentCheck_RecursiveParentWalking tests API request parent checking
func TestIsViewableWithParentCheck_RecursiveParentWalking(t *testing.T) {
idx := setupConditionalTestIndex(settings.ConditionalFilter{})
idx.Config.ResolvedRules.FolderPaths["/private"] = settings.ConditionalRule{
FolderPath: "/private",
Viewable: false,
}
// Enable global hidden folder blocking
idx.Config.ResolvedRules.IgnoreAllHidden = true
tests := []struct {
name string
path string
isDir bool
expected bool
description string
}{
{
name: "Direct non-viewable folder",
path: "/private",
isDir: true,
expected: false,
description: "Folder with explicit non-viewable rule should not be viewable",
},
{
name: "Child of non-viewable folder",
path: "/private/subfolder",
isDir: true,
expected: false,
description: "Child should inherit parent's non-viewable status",
},
{
name: "Deep child of non-viewable folder",
path: "/private/subfolder/deep/nested",
isDir: true,
expected: false,
description: "Deeply nested child should inherit parent's non-viewable status",
},
{
name: "File in non-viewable folder",
path: "/private/secret.txt",
isDir: false,
expected: false,
description: "File should inherit parent folder's non-viewable status",
},
{
name: "Unrelated folder",
path: "/public/documents",
isDir: true,
expected: true,
description: "Unrelated folder should be viewable",
},
{
name: "Hidden folder",
path: "/.hiddenDir",
isDir: true,
expected: false,
description: "Hidden folder should not be viewable when IgnoreAllHidden is true",
},
{
name: "File in hidden folder",
path: "/.hiddenDir/nested.txt",
isDir: false,
expected: false,
description: "File in hidden folder should inherit parent's non-viewable status",
},
{
name: "Nested hidden folder",
path: "/subfolderExclusions/.hiddenDir",
isDir: true,
expected: false,
description: "Nested hidden folder should not be viewable",
},
{
name: "File in nested hidden folder",
path: "/subfolderExclusions/.hiddenDir/nested.txt",
isDir: false,
expected: false,
description: "File in nested hidden folder should not be viewable",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Determine if the path is hidden
realPath := utils.JoinPathAsUnix(idx.Path, tt.path)
isHidden := IsHidden(realPath)
result := idx.IsViewableWithParentCheck(tt.isDir, tt.path, false, isHidden)
if result != tt.expected {
t.Errorf("%s: expected %v, got %v (path=%s, isHidden=%v)",
tt.description, tt.expected, result, tt.path, isHidden)
}
})
}
}
// TestIsViewableWithParentCheck_DoesNotMutate verifies thread safety
func TestIsViewableWithParentCheck_DoesNotMutate(t *testing.T) {
idx := setupConditionalTestIndex(settings.ConditionalFilter{})
idx.Config.ResolvedRules.FolderNames["restricted"] = settings.ConditionalRule{
FolderNames: "restricted",
Viewable: false,
}
initialFolderPathCount := len(idx.Config.ResolvedRules.FolderPaths)
// Call IsViewableWithParentCheck multiple times with various paths
paths := []string{
"/restricted/data/file.txt",
"/projects/restricted/subfolder/doc.pdf",
"/restricted/deep/nested/path/file.csv",
}
for _, path := range paths {
_ = idx.IsViewableWithParentCheck(false, path, false, false)
}
// Verify no mutation occurred
if len(idx.Config.ResolvedRules.FolderPaths) != initialFolderPathCount {
t.Errorf("FolderPaths was mutated: expected %d, got %d",
initialFolderPathCount, len(idx.Config.ResolvedRules.FolderPaths))
}
}
+70 -58
View File
@@ -17,6 +17,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
"golang.org/x/net/webdav"
)
var (
@@ -85,6 +86,7 @@ type Stats struct {
Complexity uint `json:"complexity"`
LastScanned time.Time `json:"lastScanned"`
DiskUsed uint64 `json:"used"`
UsedAlt uint64 `json:"usedAlt"`
}
// reduced index is json exposed to the client
@@ -113,6 +115,8 @@ type Index struct {
// Scan session tracking: timestamp when scan session started (for timestamp-based conflict detection)
scanSessionStartTime int64 // Unix timestamp when current scan session started (0 if no active scan)
scanUpdatedPaths map[string]bool // Tracks directories updated by the scan (to distinguish from API updates)
// WebDAV lock system for this source (isolated per source)
WebdavLock webdav.LockSystem
}
var (
@@ -386,6 +390,7 @@ func Initialize(source *settings.Source, mock bool, isNewDb bool) {
scanUpdatedPaths: make(map[string]bool),
folderSizes: make(map[string]uint64), // In-memory folder size tracking
folderSizesUnsynced: make(map[string]struct{}), // Track changed folders
WebdavLock: webdav.NewMemLS(), // Initialize WebDAV lock system for this source
}
newIndex.ReducedIndex = ReducedIndex{
Status: "indexing",
@@ -421,11 +426,12 @@ type PathContext struct {
// FileInfoRequest specifies what information to retrieve
type FileInfoRequest struct {
IndexPath string
FollowSymlinks bool
ShowHidden bool
Expand bool // get child items for directories
IsRoutineScan bool // scanner vs API call
IndexPath string
FollowSymlinks bool
ShowHidden bool
Expand bool // get child items for directories
IsRoutineScan bool // scanner vs API call
SkipExtendedAttrs bool // whether to skip extended attributes
}
// resolvePathContext resolves all path characteristics in a SINGLE stat call
@@ -481,7 +487,14 @@ func (idx *Index) resolvePathContext(indexPath string, followSymlinks bool) (*Pa
// Returns whether the path is viewable and/or indexable
// Does NOT check user permissions (that's handled in the API layer)
func (idx *Index) evaluateIndexRules(ctx *PathContext, isRoutineScan bool) (isViewable bool, isIndexable bool) {
isViewable = idx.IsViewable(ctx.IsDir, ctx.IndexPath, ctx.IsSymlink)
// For scanner (routine scan), only check the item itself
// For API requests, check item and all parent paths
if isRoutineScan {
isViewable = idx.IsViewable(ctx.IsDir, ctx.IndexPath, ctx.IsSymlink, ctx.IsHidden)
} else {
isViewable = idx.IsViewableWithParentCheck(ctx.IsDir, ctx.IndexPath, ctx.IsSymlink, ctx.IsHidden)
}
shouldSkip := idx.ShouldSkip(ctx.IsDir, ctx.IndexPath, ctx.IsHidden, ctx.IsSymlink, isRoutineScan)
isIndexable = !shouldSkip
return isViewable, isIndexable
@@ -497,12 +510,11 @@ func (idx *Index) GetFileInfo(req FileInfoRequest) (*iteminfo.FileInfo, error) {
return nil, err
}
// 2. Apply index rules
isViewable, isIndexable := idx.evaluateIndexRules(ctx, req.IsRoutineScan)
// Path must be either viewable OR indexable
if !isViewable && !isIndexable {
return nil, errors.ErrNotIndexed
// 2. Apply index rules (non-routine scan)
isViewable, isIndexable := idx.evaluateIndexRules(ctx, false)
// API request: path must be viewable
if !isViewable {
return nil, errors.ErrNotViewable
}
// 3. Return appropriate info
@@ -553,7 +565,7 @@ func (idx *Index) getDirInfoFromContext(ctx *PathContext, isViewable, isIndexabl
Recursive: false,
CheckViewable: true,
IsRoutineScan: req.IsRoutineScan,
SkipExtendedAttrs: !isIndexable, // Only fetch extended attrs if indexable
SkipExtendedAttrs: req.SkipExtendedAttrs || !isIndexable,
FollowSymlinks: req.FollowSymlinks,
ShowHidden: req.ShowHidden,
}
@@ -647,16 +659,16 @@ func (idx *Index) GetFsInfoCore(indexPath string, opts Options) (*iteminfo.FileI
// Check if item is accessible
hidden := IsHidden(realPath)
isSymlink := dirInfo.Mode()&os.ModeSymlink != 0
isViewable := idx.IsViewable(dirInfo.IsDir(), indexPath, isSymlink)
isViewable := idx.IsViewable(dirInfo.IsDir(), indexPath, isSymlink, hidden)
isSkipped := idx.ShouldSkip(dirInfo.IsDir(), indexPath, hidden, isSymlink, false)
// Deny access if not viewable AND skipped
if opts.CheckViewable {
if !isViewable {
return nil, errors.ErrNotIndexed
return nil, errors.ErrNotViewable
}
} else if isSkipped {
return nil, errors.ErrNotIndexed
return nil, errors.ErrNotViewable
}
realSize, _ := idx.handleFile(dirInfo, indexPath, realPath, false, nil)
@@ -921,7 +933,7 @@ func (idx *Index) GetDirInfoCore(dirInfo *os.File, stat os.FileInfo, indexPath s
// Check viewable/indexable rules
if !shouldSkip {
isViewable := idx.IsViewable(isDir, fullCombined, isSymlink)
isViewable := idx.IsViewable(isDir, fullCombined, isSymlink, hidden)
isSkipped := idx.ShouldSkip(isDir, fullCombined, hidden, isSymlink, opts.IsRoutineScan)
if opts.CheckViewable {
shouldSkip = !isViewable && isSkipped
@@ -1329,53 +1341,41 @@ func setFilePreviewFlags(fileInfo *iteminfo.ItemInfo, realPath string) {
}
// IsViewable checks if a path is viewable (allows FS access)
func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool) bool {
if indexPath == "/" {
// Only checks the item itself, not parent paths
func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool, isHidden bool) bool {
if indexPath == "/" || idx.Config.ResolvedRules.NoRules {
return true
}
// Check symlinks
if isSymlink && idx.Config.ResolvedRules.IgnoreAllSymlinks {
rules := idx.Config.ResolvedRules
// Check if hidden and should be blocked
if isHidden && rules.IgnoreAllHidden {
return false
}
rules := idx.Config.ResolvedRules
baseName := filepath.Base(indexPath)
if indexPath == "/" {
baseName = filepath.Base(idx.Path)
// Check symlinks
if isSymlink && rules.IgnoreAllSymlinks {
return false
}
baseName := filepath.Base(indexPath)
if isDir {
// Check folder rules - return false if explicitly set to non-viewable
// Check folder rules
if rule, exists := rules.FolderNames[baseName]; exists {
// Cache this match in FolderPaths so children inherit via prefix matching
if _, ok := rules.FolderPaths[indexPath]; !ok {
rules.FolderPaths[indexPath] = rule
}
return rule.Viewable
}
if rule, exists := rules.FolderPaths[indexPath]; exists {
return rule.Viewable
}
for path, rule := range rules.FolderPaths {
if strings.HasPrefix(indexPath, path) {
return rule.Viewable
}
}
for _, rule := range rules.FolderEndsWith {
if strings.HasSuffix(baseName, rule.FolderEndsWith) {
// Cache this match in FolderPaths so children inherit
if _, ok := rules.FolderPaths[indexPath]; !ok {
rules.FolderPaths[indexPath] = rule
}
return rule.Viewable
}
}
for _, rule := range rules.FolderStartsWith {
if strings.HasPrefix(baseName, rule.FolderStartsWith) {
// Cache this match in FolderPaths so children inherit
if _, ok := rules.FolderPaths[indexPath]; !ok {
rules.FolderPaths[indexPath] = rule
}
return rule.Viewable
}
}
@@ -1387,11 +1387,6 @@ func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool) bool
if rule, exists := rules.FilePaths[indexPath]; exists {
return rule.Viewable
}
for path, rule := range rules.FilePaths {
if strings.HasPrefix(indexPath, path) {
return rule.Viewable
}
}
for _, rule := range rules.FileEndsWith {
if strings.HasSuffix(baseName, rule.FileEndsWith) {
return rule.Viewable
@@ -1402,27 +1397,44 @@ func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool) bool
return rule.Viewable
}
}
// Check if file inherits viewable status from parent folder
for path, rule := range rules.FolderPaths {
if strings.HasPrefix(indexPath, path) {
return rule.Viewable
}
}
}
return true // Default: viewable unless explicitly set to false
}
// IsViewableWithParentCheck checks if a path is viewable by walking up the directory tree
// Used for API requests where we need to check inherited rules from parent folders
func (idx *Index) IsViewableWithParentCheck(isDir bool, indexPath string, isSymlink bool, isHidden bool) bool {
if idx.Config.DisableIndexing || idx.Config.ResolvedRules.NoRules {
return true
}
// Check the item itself - if not viewable, stop here
if !idx.IsViewable(isDir, indexPath, isSymlink, isHidden) {
return false
}
// Item is viewable, now check parent recursively
parentDir := utils.GetParentDirectoryPath(indexPath)
if parentDir == "" || parentDir == "/" {
return true // Reached root with no non-viewable rules, default to viewable
}
// Check if parent is hidden
realPath, _, _ := idx.GetRealPath(parentDir)
return idx.IsViewableWithParentCheck(true, parentDir, false, IsHidden(realPath))
}
// ShouldSkip checks if a path should be skipped from indexing
// isRoutineScan: true for scanner (no parent checks), false for API (check parents)
func (idx *Index) ShouldSkip(isDir bool, adjustedPath string, isHidden bool, isSymlink bool, isRoutineScan bool) bool {
rules := idx.Config.ResolvedRules
if adjustedPath == "/" {
return false
}
if idx.Config.DisableIndexing {
return true
}
rules := idx.Config.ResolvedRules
if adjustedPath == "/" || rules.NoRules {
return false
}
if isHidden && rules.IgnoreAllHidden {
return true
}
+64 -15
View File
@@ -24,6 +24,7 @@ type Scanner struct {
lastScanned time.Time
quickScanTime int
fullScanTime int
statsMu sync.RWMutex
scanStartTime int64
isScanning bool // True when scanner is actively scanning or waiting for mutex
@@ -41,6 +42,18 @@ type Scanner struct {
stopOnce sync.Once // Ensures stopChan is only closed once
}
func (s *Scanner) withStatsLock(fn func()) {
s.statsMu.Lock()
defer s.statsMu.Unlock()
fn()
}
func (s *Scanner) withStatsRLock(fn func()) {
s.statsMu.RLock()
defer s.statsMu.RUnlock()
fn()
}
// start begins the scanner's main loop
func (s *Scanner) start() {
// Wait a bit to stagger child scanner initial scans (root goes first)
@@ -126,14 +139,20 @@ func (s *Scanner) runRootScan(quick bool) {
if quick {
s.runQuickScanRoot()
s.statsMu.Lock()
s.quickScanTime = int(time.Since(startTime).Seconds())
s.statsMu.Unlock()
} else {
s.runFullScanRoot()
s.statsMu.Lock()
s.fullScanTime = int(time.Since(startTime).Seconds())
s.statsMu.Unlock()
s.updateComplexity()
}
s.statsMu.Lock()
s.lastScanned = time.Now()
s.statsMu.Unlock()
s.checkForNewChildDirectories()
if err := s.idx.db.ShrinkMemory(); err != nil {
logger.Errorf("Failed to shrink memory: %v", err)
@@ -147,8 +166,10 @@ func (s *Scanner) runFullScanRoot() {
}
// Reset counters and initialize state for full scan
s.numDirs = 0
s.numFiles = 0
s.withStatsLock(func() {
s.numDirs = 0
s.numFiles = 0
})
s.scanStartTime = time.Now().Unix()
s.idx.mu.Lock()
if s.idx.scanSessionStartTime == 0 {
@@ -161,7 +182,9 @@ func (s *Scanner) runFullScanRoot() {
batchSize := s.idx.db.BatchSize
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
s.filesChanged = false
s.withStatsLock(func() {
s.filesChanged = false
})
_, _, err := s.idx.indexDirectory("/", config, s)
if err != nil {
@@ -184,7 +207,9 @@ func (s *Scanner) runFullScanRoot() {
func (s *Scanner) runQuickScanRoot() {
s.scanStartTime = time.Now().Unix()
s.filesChanged = false
s.withStatsLock(func() {
s.filesChanged = false
})
// Get root-level folders from folderSizes map
s.idx.folderSizesMu.RLock()
@@ -228,15 +253,21 @@ func (s *Scanner) runChildScan(quick bool) {
if quick {
// Quick scan: iterate folderSizes, check modtimes only (257x faster!)
s.runQuickScanChild()
s.quickScanTime = int(time.Since(startTime).Seconds())
s.withStatsLock(func() {
s.quickScanTime = int(time.Since(startTime).Seconds())
})
} else {
// Full scan: walk filesystem recursively, read all directory contents
s.runFullScanChild()
s.fullScanTime = int(time.Since(startTime).Seconds())
s.withStatsLock(func() {
s.fullScanTime = int(time.Since(startTime).Seconds())
})
s.updateComplexity()
}
s.lastScanned = time.Now()
s.withStatsLock(func() {
s.lastScanned = time.Now()
})
}
func (s *Scanner) runFullScanChild() {
@@ -246,8 +277,10 @@ func (s *Scanner) runFullScanChild() {
}
// Reset counters and initialize state for full scan
s.numDirs = 0
s.numFiles = 0
s.withStatsLock(func() {
s.numDirs = 0
s.numFiles = 0
})
s.scanStartTime = time.Now().Unix()
s.idx.mu.Lock()
if s.idx.scanSessionStartTime == 0 {
@@ -257,7 +290,9 @@ func (s *Scanner) runFullScanChild() {
s.processedInodes = make(map[uint64]struct{})
s.foundHardLinks = make(map[string]uint64)
s.filesChanged = false
s.withStatsLock(func() {
s.filesChanged = false
})
batchSize := s.idx.db.BatchSize
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
@@ -297,7 +332,9 @@ func (s *Scanner) runFullScanChild() {
func (s *Scanner) runQuickScanChild() {
s.scanStartTime = time.Now().Unix()
s.filesChanged = false
s.withStatsLock(func() {
s.filesChanged = false
})
// Get folders in this scanner's scope from folderSizes map
s.idx.folderSizesMu.RLock()
@@ -451,7 +488,10 @@ func (s *Scanner) getTopLevelDirs() []string {
// calculateSleepTime determines how long to wait before the next scan
func (s *Scanner) calculateSleepTime() time.Duration {
sleepTime := scanSchedule[s.currentSchedule] + s.smartModifier
var sleepTime time.Duration
s.withStatsRLock(func() {
sleepTime = scanSchedule[s.currentSchedule] + s.smartModifier
})
if s.idx.Config.IndexingInterval > 0 {
sleepTime = time.Duration(s.idx.Config.IndexingInterval) * time.Minute
}
@@ -461,6 +501,8 @@ func (s *Scanner) calculateSleepTime() time.Duration {
// updateSchedule adjusts the scanner's schedule based on whether files changed
func (s *Scanner) updateSchedule() {
s.statsMu.Lock()
defer s.statsMu.Unlock()
// Calculate sleep time BEFORE updating schedule (based on current/old schedule)
s.nextSleepTime = scanSchedule[s.currentSchedule] + s.smartModifier
@@ -506,6 +548,9 @@ func (s *Scanner) updateSchedule() {
// updateComplexity calculates the complexity level (1-10) for this scanner's directory
// 0: unknown
func (s *Scanner) updateComplexity() {
s.statsMu.Lock()
defer s.statsMu.Unlock()
s.complexity = calculateComplexity(s.fullScanTime, s.numDirs)
// Set smartModifier based on complexity level
@@ -564,8 +609,10 @@ func (s *Scanner) syncStatsWithDB() {
return
}
s.numDirs = dirs
s.numFiles = files
s.withStatsLock(func() {
s.numDirs = dirs
s.numFiles = files
})
}
// checkFolderModtime checks if a folder's modtime changed since last scan
@@ -590,7 +637,9 @@ func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
if modtimeChanged {
// Modtime changed - read directory contents and update database
s.filesChanged = true
s.withStatsLock(func() {
s.filesChanged = true
})
dir, err := os.Open(realPath)
if err != nil {
+40 -27
View File
@@ -116,7 +116,9 @@ func (idx *Index) markFilesChanged() {
idx.mu.RUnlock()
if exists {
scanner.filesChanged = true
scanner.withStatsLock(func() {
scanner.filesChanged = true
})
}
}
@@ -145,7 +147,9 @@ func (idx *Index) incrementScannerDirsUnlocked() {
}
scanner, exists := idx.scanners[activePath]
if exists {
scanner.numDirs++
scanner.withStatsLock(func() {
scanner.numDirs++
})
}
}
@@ -162,7 +166,9 @@ func (idx *Index) incrementScannerFiles() {
idx.mu.RUnlock()
if exists {
scanner.numFiles++
scanner.withStatsLock(func() {
scanner.numFiles++
})
}
}
@@ -252,13 +258,15 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
rootScanner := idx.createScanner("/")
if persistedScanners != nil {
if rootInfo, ok := persistedScanners["/"]; ok {
rootScanner.complexity = rootInfo.Complexity
rootScanner.currentSchedule = rootInfo.CurrentSchedule
rootScanner.quickScanTime = rootInfo.QuickScanTime
rootScanner.fullScanTime = rootInfo.FullScanTime
rootScanner.numDirs = rootInfo.NumDirs
rootScanner.numFiles = rootInfo.NumFiles
rootScanner.lastScanned = rootInfo.LastScanned
rootScanner.withStatsLock(func() {
rootScanner.complexity = rootInfo.Complexity
rootScanner.currentSchedule = rootInfo.CurrentSchedule
rootScanner.quickScanTime = rootInfo.QuickScanTime
rootScanner.fullScanTime = rootInfo.FullScanTime
rootScanner.numDirs = rootInfo.NumDirs
rootScanner.numFiles = rootInfo.NumFiles
rootScanner.lastScanned = rootInfo.LastScanned
})
}
}
idx.mu.Lock()
@@ -276,13 +284,15 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
// Restore persisted stats for child scanner if available (and DB is not new)
if persistedScanners != nil {
if childInfo, ok := persistedScanners[dirPath]; ok {
childScanner.complexity = childInfo.Complexity
childScanner.currentSchedule = childInfo.CurrentSchedule
childScanner.quickScanTime = childInfo.QuickScanTime
childScanner.fullScanTime = childInfo.FullScanTime
childScanner.numDirs = childInfo.NumDirs
childScanner.numFiles = childInfo.NumFiles
childScanner.lastScanned = childInfo.LastScanned
childScanner.withStatsLock(func() {
childScanner.complexity = childInfo.Complexity
childScanner.currentSchedule = childInfo.CurrentSchedule
childScanner.quickScanTime = childInfo.QuickScanTime
childScanner.fullScanTime = childInfo.FullScanTime
childScanner.numDirs = childInfo.NumDirs
childScanner.numFiles = childInfo.NumFiles
childScanner.lastScanned = childInfo.LastScanned
})
}
}
@@ -318,22 +328,25 @@ func (idx *Index) GetScannerStatus() map[string]interface{} {
activePath := idx.getActiveScannerPathUnlocked()
status["activeScanner"] = activePath
status["isScanning"] = activePath != ""
status["status"] = idx.Status
// Individual scanner stats
scannerStats := make([]map[string]interface{}, 0, len(idx.scanners))
for path, scanner := range idx.scanners {
scannerInfo := map[string]interface{}{
"path": path,
"lastScanned": scanner.lastScanned.Format(time.RFC3339),
"complexity": scanner.complexity,
"currentSchedule": scanner.currentSchedule,
"quickScanTime": scanner.quickScanTime,
"fullScanTime": scanner.fullScanTime,
"numDirs": scanner.numDirs,
"numFiles": scanner.numFiles,
"filesChanged": scanner.filesChanged,
"smartModifier": scanner.smartModifier.String(),
"path": path,
}
scanner.withStatsRLock(func() {
scannerInfo["lastScanned"] = scanner.lastScanned.Format(time.RFC3339)
scannerInfo["complexity"] = scanner.complexity
scannerInfo["currentSchedule"] = scanner.currentSchedule
scannerInfo["quickScanTime"] = scanner.quickScanTime
scannerInfo["fullScanTime"] = scanner.fullScanTime
scannerInfo["numDirs"] = scanner.numDirs
scannerInfo["numFiles"] = scanner.numFiles
scannerInfo["filesChanged"] = scanner.filesChanged
scannerInfo["smartModifier"] = scanner.smartModifier.String()
})
scannerStats = append(scannerStats, scannerInfo)
}
status["scanners"] = scannerStats
Loaded 100 of 314 files, more files were not shown because too many files have changed in this diff. Show more