Compare commits

..
Author SHA1 Message Date
Graham Steffaniak e4907225a1 fix download link that was incorrect for testing 2026-02-27 16:46:32 -06:00
Graham Steffaniak cc136043f9 Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-27 16:42:45 -06:00
Graham SteffaniakandStephan Paternotte 611dd8ea71 Merge v1.2.1 stable (#2044)
Co-authored-by: Stephan Paternotte <Stephan-P@users.noreply.github.com>
2026-02-27 16:37:25 -06:00
Graham Steffaniak 41e9ff27fa Merge branch 'main' into dev/v1.3.0 2026-02-27 16:33:33 -06:00
Graham Steffaniak f11d8df7e8 Updated screenshots mechanism (#2042) 2026-02-27 15:52:12 -06:00
Graham Steffaniak 3fd20bbee1 add jwt login support (#2036) 2026-02-27 10:34:02 -06:00
Graham Steffaniak 86a031be1d OIDC ldap user group (#2035) 2026-02-26 17:22:47 -06:00
d1b5a18c83 Resizable prompts (#2020)
Co-authored-by: Graham Steffaniak <graham.steffaniak@autodesk.com>
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-02-25 17:21:55 -06:00
Graham Steffaniak d9fc8c0275 add more prompts -- threejs and office debug (#2033) 2026-02-25 16:13:51 -06:00
Graham Steffaniak 6cad10afb5 Reorganize swagger (#2030) 2026-02-25 11:09:23 -06:00
Graham Steffaniak 3a53b13b3d Merge branch 'main' into dev/v1.3.0 2026-02-24 15:05:12 -06:00
Graham Steffaniak 3fca886136 Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-24 15:04:20 -06:00
Kevin B. 16994cae79 Make source in navigation mode like links (#2017) 2026-02-24 14:40:40 -05:00
Graham Steffaniak 7eb9f1b0fa Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-24 10:23:16 -06:00
Graham Steffaniak d81be14d12 allow more progress bar customization (#2022) 2026-02-24 10:16:19 -06:00
Graham Steffaniak 884a5fa221 Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-23 12:55:05 -06:00
Graham Steffaniak bbcf472fe9 Add loading spinner new api (#2012) 2026-02-22 14:04:20 -06:00
Graham Steffaniak 49075d85df Many style changes (#2010) 2026-02-22 12:15:50 -06:00
Kevin B. b95311dff8 Sidebar tree navigation (#2006) 2026-02-22 11:58:05 -05:00
Graham Steffaniak 7d3aa0201f added faster api for simple file/folder lists (#2005) 2026-02-21 20:18:51 -06:00
Graham Steffaniak 1048b528eb fix cve 2026-02-21 17:45:00 -06:00
Graham Steffaniak 6d4c74b883 updated translation for polish 2026-02-21 10:49:45 -06:00
Graham Steffaniakandzarev e4d6f4fa51 Update preview thumbnails (#2003)
Co-authored-by: zarev <60230221+zarevskaya@users.noreply.github.com>
2026-02-20 19:21:30 -06:00
Graham Steffaniak 2aa766000b Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-20 15:48:46 -06:00
Graham Steffaniakandzarev 315af7aa6e Archive creation feature (#2002)
Co-authored-by: zarev <60230221+zarevskaya@users.noreply.github.com>
2026-02-20 15:48:19 -06:00
Graham Steffaniak 9399392956 Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-20 11:05:09 -06:00
Graham Steffaniakandzarev 2a17ccb2a9 Add sharing icon (#2001)
Co-authored-by: zarev <60230221+zarevskaya@users.noreply.github.com>
2026-02-20 11:04:57 -06:00
Graham Steffaniak c56b8cd6dc Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-20 08:50:25 -06:00
Graham Steffaniakandzarev 28ce27397f Merge v1.2.5 v1.3.0 (#2000)
Co-authored-by: zarev <60230221+zarevskaya@users.noreply.github.com>
2026-02-20 08:49:53 -06:00
Graham Steffaniak 312e434bb5 Merge branch 'main' into dev/v1.3.0 2026-02-19 18:37:24 -06:00
Graham Steffaniak cfa1a8c120 updated with ldap support (#1994) 2026-02-18 18:00:26 -06:00
Graham Steffaniak 57487f7523 3d model viewing support (#1981) 2026-02-18 08:51:24 -06:00
Graham Steffaniak 7a80433b2c make update (#1989) 2026-02-16 18:46:35 -06:00
Joel Godfrey 30a00d59ef Update delete api endpoints to DELETE request method instead of POST against dev/1.3.0 (#1987)
Signed-off-by: Joel Godfrey <m.joelgodfrey@gmail.com>
2026-02-16 14:30:04 -06:00
Graham Steffaniak 1b08b968db updated with fixes for file watcher (#1980) 2026-02-13 18:30:45 -06:00
Graham Steffaniak fcce47203f Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-13 16:27:49 -06:00
Graham Steffaniakandzarev 4de432e8ce Merge v1.2.4 v1.3.0 (#1979)
Co-authored-by: zarev <60230221+zarevskaya@users.noreply.github.com>
2026-02-13 16:21:33 -06:00
Graham Steffaniak fc42b6fbc0 Merge tag 'v1.2.4-beta' into dev/v1.3.0 2026-02-13 16:07:13 -06:00
Graham Steffaniak 5c2e2f6af5 ensure folder sizes get updated (#1977) 2026-02-13 15:00:42 -06:00
Graham Steffaniak 5121a6fa82 added screenshots test (#1976) 2026-02-13 11:58:43 -06:00
Graham Steffaniak dc8d71ddb0 Fix image selection buttons (#1972) 2026-02-12 18:17:43 -06:00
Graham Steffaniak 961bdd475f fix new user name (#1971) 2026-02-12 17:19:02 -06:00
Kevin B. 34b957be0f fix share and user delete prompts (#1970) 2026-02-12 15:57:40 -06:00
zarevandGraham Steffaniak 86c41db9c5 Corrections (#1969)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-02-12 15:21:56 -06:00
Graham Steffaniak 89d99090ec fix otp settings update (#1967) 2026-02-12 13:14:18 -06:00
Kevin B. 0dde7cbe59 fix quick jump in previews (#1954) 2026-02-12 13:14:04 -06:00
Graham Steffaniak dfe217d020 filename rename fix (#1966) 2026-02-12 08:54:18 -06:00
zarevandGraham Steffaniak 1d2ac2ab06 Update fr.json (#1965)
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
2026-02-12 08:21:33 -06:00
Graham Steffaniak 87923add8a updated with dutch language (#1961) 2026-02-11 18:10:37 -06:00
Graham Steffaniak be7df9000a Fix subtitles (#1960) 2026-02-11 17:22:46 -06:00
Graham Steffaniak 1126fe7d95 Fix encoding issues (#1959) 2026-02-11 14:02:54 -06:00
Graham Steffaniak 4476d97f6e use generic prompt where possible (#1958) 2026-02-11 10:06:24 -06:00
Graham Steffaniak 888cad356e adjusted appearence of cards (#1953) 2026-02-10 10:16:06 -06:00
Kevin B. 18bd22ee95 Fix disabled toggle in create api token prompt (#1951) 2026-02-09 16:01:28 -06:00
Graham Steffaniak ae2fabfae6 Update prompt styling (#1952) 2026-02-09 16:01:18 -06:00
Graham Steffaniak 1e891452bc updated token api descriptions (#1949) 2026-02-08 11:55:00 -06:00
Graham Steffaniak dc177b634d updated with improved webdav (#1946) 2026-02-07 10:15:22 -06:00
Graham Steffaniak 1b0f53dead updated webdav to support permissions 2026-02-06 17:53:40 -06:00
Graham Steffaniak 19c19f70d7 Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-06 14:29:45 -06:00
Graham Steffaniak bf11fc43f3 updated with filebrowser user default (#1942) 2026-02-06 14:29:40 -06:00
Graham Steffaniak 7b39fcc9ac Merge branch 'dev/v1.3.0' of github.com:gtsteffaniak/filebrowser into dev/v1.3.0 2026-02-06 14:29:22 -06:00
Graham Steffaniak 683c837d0c updated readme (#1937) 2026-02-05 12:07:18 -06:00
Graham Steffaniak b95ce79d50 updated favicon logic more 2026-02-05 11:48:12 -06:00
Graham Steffaniak a0c6b123de updated share apis (#1936) 2026-02-05 10:38:46 -06:00
Graham Steffaniak 5832a2ebfd updated with some api changes 2026-02-05 10:19:07 -06:00
Graham Steffaniak a9d738d419 Make icon package (#1932) 2026-02-04 18:14:50 -06:00
Graham Steffaniak 952484cd3d fix motion preview sticking (#1931) 2026-02-04 13:13:51 -06:00
224 changed files with 7691 additions and 15194 deletions

No files matched your search

+2 -11
View File
@@ -14,7 +14,7 @@ jobs:
with:
go-version: 'stable'
- working-directory: backend
run: go test -race -v ./... -timeout 30s
run: go test -race -v ./...
lint-backend:
runs-on: ubuntu-latest
steps:
@@ -56,13 +56,4 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- working-directory: frontend
run: npm i && npm run i18n:check
check-docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 'stable'
- run: make generate-docs
- run: git diff --exit-code
run: npm i && npm run i18n:check
-119
View File
@@ -2,109 +2,6 @@
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.4
**New Features**:
- Epub placementg url anchors to bookmark a specific location on the doc.
- Add Requirement for Current Password When Changing Account Password (#2112)
**Notes**:
- user scope editing has path picker and filesystem validation
**BugFixes**:
- Swiping media files doesn't respect Playback Queue (#2243)
- Audio metadata and album thumbnails doesn't work (#2236)
- Search spinner wouldn't show up sometimes while searching
- The default landing page source should always be the first source in the sidebar, that wasn't always respected.
## v1.3.3
**New Features**:
- Copy/paste files and folders (`CTRL+C`/`CTRL+V`) from other apps (win explorer, thunar, finder, etc) to upload them directly (#2197)
- Caption font size can be adjusted in video settings
- Ability to adjust the startup check method for sql database via `server.startupIntegrityCheck` (#2221)
- Status bar for editor and markdown viewer (#2226)
**Notes**:
- `.fbx` 3d model files added to supported previews.
- removed upx compression on docker image (#2193)
- videos double tap to fast-forward and rewind added.
- Adjust Image album swipe behavior (#2068)
- swipe animation for next/previous
- swipe down on image to close and go to parent folder.
- supports videos
- chunked uploads will save to a temporary file at the destination and renamed on completion. Better upload pause handling (#2129)
- The Icons in the UI were updated! (#2203)
- More supported icons in the Icon Picker tool.
- More file types have new icons across all the listings (such as `.md`, `.apk`, etc).
- `.fbx` 3d model files added to supported previews.
- removed upx compression on docker image (#2193)
- chunked uploads will save to a temporary file at the destination and renamed on completion. Better upload pause handling (#2129)
- deprecated `source.config.CreateUserDir`, now its always `true`. If a user directory doesn't exist it will get created empty.
- `CTRL`+`Mouse Wheel` shortcut to change listing size. Also for change font size in editor (#2227)
**BugFixes**:
- Archives don't preserve file metadata (#2063)
- Chunked upload setting fails: "0" does not disable chunking as expected (#2202)
- Incorrect Chinese labels for Upload controls in Quantum UI (#2191)
- Chunked download stops after first chunk, add message explaining (#2074)
- Esc to Cancel and Enter to confirm popup (#2079)
- Language-tagged subtitle files not detected for videos (#2199)
- Unable to use proxy auth since 1.3.0-beta (#2173)
- Updating setting on a LDAP user turns the "Login Method" to password. Thus preventing further logins. (#2179)
## 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.
@@ -162,22 +59,6 @@ Note: a potentially breaking change for docker users: \the default user is now "
**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**:
+44 -32
View File
@@ -17,7 +17,7 @@
## Pinned
:loudspeaker: [New Announcement](https://github.com/gtsteffaniak/filebrowser/discussions/2048)
:loudspeaker: [What's Coming Soon](https://github.com/gtsteffaniak/filebrowser/discussions/1622)
: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 and 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 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. ✅ 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.
1. ✅ Add and configure multiple sources
2. ✅ Login support for OIDC, password + 2FA, and proxy.
3. ✅ Beautiful, Responsive, and Customizable user interface.
4. ✅ Streamlined configuration via `config.yaml` config file.
5. ✅ Ultra-efficient Sqlite [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing)
5. ✅ Ultra-efficient [indexing](https://github.com/gtsteffaniak/filebrowser/wiki/Indexing) and real-time updates
- Real-time search results as you type.
- Real-time monitoring and updates in the UI.
- Search supports file and folder sizes, along with various filters.
6. ✅ Better listing browsing
- Better thumbnail support including **office**, **video**, and **album artwork**, and **3D models**.
- Better thumbnail support including **office**, **video**, and **album artwork**
- Faster and more responsive views with animations.
- Folder sizes are displayed and support thumbnails
- Folder sizes are displayed and support for thumbnails
- Navigating remembers the last scroll position.
7. ✅ Highly configurable and [customizable sharing options](https://filebrowserquantum.com/en/docs/shares/options/)
7. ✅ Highly configurable and customizable sharing options
- share expiration time
- users who can access share (including anonymous)
- styling and themes
@@ -59,10 +59,20 @@ 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:
@@ -94,45 +104,47 @@ Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
webdav support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
webdav support | :warning:* | ❌ | ✅ | ✅ | ❌ | ✅ |
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 | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Single sign-on support | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
LDAP sign-on support | :construction: | ❌ | ❌ | ✅ | ❌ | ✅ |
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 | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
Can browse archive files | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
Can convert documents | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
Can convert videos | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
Can convert photos | :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.
+3 -2
View File
@@ -1,15 +1,16 @@
FROM gtstef/ffmpeg:8.1-decode AS ffmpeg
FROM gtstef/ffmpeg:8.0.1-decode AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add --no-cache gcc musl-dev
RUN apk update && apk add --no-cache gcc musl-dev upx
ENV CGO_ENABLED=1
RUN go build -tags mupdf,musl -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
-o filebrowser .
RUN upx filebrowser
FROM node:jod-slim AS nbuild
WORKDIR /app
-3
View File
@@ -14,9 +14,6 @@ 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
+2 -1
View File
@@ -3,11 +3,12 @@ ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add
RUN apk update && apk add upx
RUN go build -ldflags="-w -s \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.Version=${VERSION}' \
-X 'github.com/gtsteffaniak/filebrowser/backend/common/version.CommitSHA=${REVISION}'" \
-o filebrowser .
RUN upx filebrowser
FROM node:jod-slim AS nbuild
WORKDIR /app
+1 -3
View File
@@ -8,10 +8,8 @@ userDefaults:
image: true
popup: true
video: true
audio: true
models: true
office: true
highQuality: true
permissions:
download: true
create: true
create: true
+1 -3
View File
@@ -26,10 +26,8 @@ userDefaults:
image: true
popup: true
video: true
audio: true
office: true
models: true
highQuality: true
permissions:
download: true
create: true
create: true
@@ -11,14 +11,14 @@ import { defineConfig, devices } from "@playwright/test";
*/
export default defineConfig({
globalSetup: "./tests/playwright/proxy-setup.ts",
timeout: 10000,
timeout: 5000,
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: 3,
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 */
-217
View File
@@ -1,12 +1,10 @@
package files
import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
@@ -411,218 +409,3 @@ 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)
})
}
}
+69 -73
View File
@@ -25,39 +25,6 @@ import (
"github.com/gtsteffaniak/go-logger/logger"
)
// CheckPermissionsFunc allows tests to override CheckPermissions behavior
var CheckPermissionsFunc = checkPermissionsImpl
// addMetadataToChildren runs concurrent ffmpeg/tag extraction for audio/video children.
// Each goroutine is registered on wg. Caller must invoke wg.Wait() after scheduling.
func addMetadataToChildren(response *iteminfo.ExtendedFileInfo, opts utils.FileOptions, wg *sync.WaitGroup) {
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
if sharedFFmpegService == nil {
return
}
for i := range response.Files {
fileItem := &response.Files[i]
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
if !isItemAudio && !isItemVideo {
continue
}
isAudio := isItemAudio
wg.Go(func() {
fullPath := response.RealPath + "/" + fileItem.Name
if isAudio {
if err := extractAudioMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, opts.Metadata, sharedFFmpegService); err != nil {
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
}
} else {
if err := extractVideoMetadata(context.Background(), fileItem, fullPath, sharedFFmpegService); err != nil {
logger.Debugf("failed to extract video metadata for file: %s, error: %v", fileItem.Name, err)
}
}
})
}
}
// processDirectoryMetadata extracts metadata for audio/video files in directories
func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts utils.FileOptions) {
metadataCount := 0
@@ -69,8 +36,11 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
metadataCount++
}
}
// Process files concurrently using goroutines
var wg sync.WaitGroup
var mu sync.Mutex // Protects processedCount
// Refresh directory to update folder sizes
wg.Go(func() {
indexPath := idx.MakeIndexPath(response.Path, true)
err := idx.RefreshDirectory(indexPath, false)
@@ -79,9 +49,54 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
}
})
if opts.Metadata && metadataCount > 0 {
addMetadataToChildren(response, opts, &wg)
// Set hasMetadata flag if there are files with potential metadata
if metadataCount > 0 {
response.HasMetadata = true
}
// Only process metadata if explicitly requested
if opts.Metadata && metadataCount > 0 {
processedCount := 0
// Create a single shared FFmpegService instance for all files to coordinate concurrency
sharedFFmpegService := ffmpeg.NewFFmpegService(10, false, "")
if sharedFFmpegService != nil {
for i := range response.Files {
fileItem := &response.Files[i]
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
if isItemAudio || isItemVideo {
wg.Go(func() {
// Extract metadata for audio files (without album art for performance)
if isItemAudio {
err := extractAudioMetadata(context.Background(), fileItem, response.RealPath+"/"+fileItem.Name, opts.AlbumArt, opts.Metadata, sharedFFmpegService)
if err != nil {
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
} else {
mu.Lock()
processedCount++
mu.Unlock()
}
} else {
// Extract duration for video files
err := extractVideoMetadata(context.Background(), fileItem, response.RealPath+"/"+fileItem.Name, sharedFFmpegService)
if err != nil {
logger.Debugf("failed to extract video metadata for file: %s, error: %v", fileItem.Name, err)
} else {
mu.Lock()
processedCount++
mu.Unlock()
}
}
})
}
}
}
}
// Wait for all goroutines to complete
wg.Wait()
}
@@ -101,13 +116,7 @@ func finalizeResponse(response *iteminfo.ExtendedFileInfo, info *iteminfo.FileIn
}
}
// 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 "", "", fmt.Errorf("access not provided")
}
@@ -194,15 +203,7 @@ func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.Use
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 {
@@ -238,21 +239,22 @@ func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *us
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
}
if share != nil && user.Permissions.Share {
for i := range response.Files {
file := &response.Files[i]
file.IsShared = share.IsShared(response.Path+file.Name, idx.Path)
}
for i := range response.Folders {
folder := &response.Folders[i]
folder.IsShared = share.IsShared(response.Path+folder.Name, idx.Path)
}
response.IsShared = share.IsShared(response.Path, idx.Path)
}
// Process directory metadata if requested
if info.Type == "directory" {
processDirectoryMetadata(response, idx, opts)
@@ -272,13 +274,9 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
if isFolder {
return
}
if isVideo && !opts.Metadata {
return
}
if isAudio && !(opts.Metadata || opts.AlbumArt) {
return
}
if isVideo {
// Extract duration for video
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
@@ -289,6 +287,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
info.Metadata = extItem.Metadata
}
// Handle subtitles if requested
parentPath := filepath.Dir(info.Path)
parentInfo, exists := idx.GetMetadataInfo(parentPath, true, false)
if exists {
@@ -298,12 +297,12 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
subtitles := ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
info.Subtitles = append(info.Subtitles, subtitles...)
}
return
}
// Audio: run tag/album-art extraction when full metadata or preview (AlbumArt) is requested.
// Preview uses AlbumArt without Metadata so we do not load arbitrary files as text content.
if isAudio {
// Create an ExtendedItemInfo to hold the metadata
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
@@ -311,7 +310,9 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
if err != nil {
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
} else {
// Copy metadata to ExtendedFileInfo
info.Metadata = extItem.Metadata
info.HasMetadata = true
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
}
return
@@ -440,11 +441,6 @@ 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)
+13 -108
View File
@@ -108,6 +108,7 @@ var types = map[string]string{
".chrt": "application/vnd.kde.kchart",
".class": "application/java",
".com": ContentTextHeaderValue,
".conf": ContentTextHeaderValue,
".cpio": "application/x-cpio",
".cpp": "text/x-c",
".cpt": "application/mac-compactpro",
@@ -148,11 +149,7 @@ var types = map[string]string{
".es": "application/x-esrehber",
".etx": "text/x-setext",
".evy": "application/envoy",
".exe": "application/x-msdownload",
".dll": "application/x-msdownload",
".bat": "application/x-msdos-program",
".cmd": "application/x-msdos-program",
".msi": "application/x-msdownload",
".exe": "application/octet-stream",
".f77": "text/x-fortran",
".f90": "text/x-fortran",
".f": "text/x-fortran",
@@ -228,6 +225,7 @@ var types = map[string]string{
".js": ContentJavascriptHeaderValue,
".mjs": ContentJavascriptHeaderValue,
".json": ContentJSONHeaderValue,
".vue": ContentJavascriptHeaderValue,
".jut": "image/jutvision",
".kar": "audio/midi",
".karbon": "application/vnd.kde.karbon",
@@ -245,8 +243,8 @@ var types = map[string]string{
".la": "audio/nspaudio",
".lam": "audio/x-liveaudio",
".latex": "application/x-latex",
".lha": "application/x-lzh-compressed",
".lhx": "application/x-lzh-compressed",
".lha": "application/lha",
".lhx": "application/octet-stream",
".list": ContentTextHeaderValue,
".lma": "audio/nspaudio",
".log": ContentTextHeaderValue,
@@ -259,6 +257,7 @@ var types = map[string]string{
".m1v": "video/mpeg",
".m2a": "audio/mpeg",
".m2v": "video/mpeg",
".m3u": "audio/x-mpegurl",
".m": "text/x-m",
".man": "application/x-troff-man",
".manifest": "text/cache-manifest",
@@ -308,6 +307,7 @@ var types = map[string]string{
".ncm": "application/vndnokiaconfiguration-message",
".nif": "image/x-niff",
".niff": "image/x-niff",
".nix": "application/x-mix-transfer",
".nsc": "application/x-conference",
".nvd": "application/x-navidoc",
".o": "application/octet-stream",
@@ -343,14 +343,15 @@ var types = map[string]string{
".p7m": "application/pkcs7-mime",
".p7r": "application/x-pkcs7-certreqresp",
".p7s": "application/pkcs7-signature",
".p": "text/x-pascal",
".part": "application/pro_eng",
".pas": "text/pascal",
".pbm": "image/x-portable-bitmap",
".pcl": "application/vndhp-pcl",
".pct": "image/x-pict",
".pcx": "image/x-pcx",
".pdb": "chemical/x-pdb",
".pdf": "application/pdf",
".pdfa": "application/pdf",
".pfunk": "audio/make",
".pgm": "image/x-portable-graymap",
".pic": "image/pict",
@@ -374,7 +375,7 @@ var types = map[string]string{
".pre": "application/x-freelance",
".prt": "application/pro_eng",
".ps": "application/postscript",
".psd": "image/vnd.adobe.photoshop",
".psd": "application/octet-stream",
".pvu": "paleovu/x-pv",
".pwz": "application/vndms-powerpoint",
".py": "text/x-scriptphyton",
@@ -442,11 +443,9 @@ var types = map[string]string{
".sprite": "application/x-sprite",
".spx": "audio/ogg",
".src": "application/x-wais-source",
".srt": "text/subtitle-srt",
".ass": "text/subtitle-ass",
".vtt": "text/subtitle-vtt",
".ssa": "text/subtitle-ssa",
".srt": "text/plain",
".sbv": "text/plain",
".ssa": "text/plain",
".ssi": "text/x-server-parsed-html",
".ssm": "application/streamingmedia",
".sst": "application/vndms-pkicertstore",
@@ -598,105 +597,11 @@ 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",
".m3u": "text/x-mpegurl", // .m3u and .m3u8 are playlist files - They contain paths, we set it as text to allow edition.
".m3u8": "text/x-mpegURL",
".pls": "text/x-scpls",
".xspf": "application/xspf+xml",
".m3u8": "application/x-mpegURL",
".mpd": "application/dash+xml",
".webp": "image/webp",
".epub": "application/epub+zip",
".db": "application/x-db",
".db2": "application/x-db",
".db3": "application/x-db",
".sql": "application/sql",
".sqlite": "application/vnd.sqlite3",
".sqlite3": "application/vnd.sqlite3",
".yaml": "application/yaml",
".app": "application/x-application",
".geojson": "application/geo+json",
".sketch": "application/x-sketch",
".bak": "application/backup",
".backup": "application/backup",
".xfc": "application/x-xcf",
".vue": "text/vue",
".conf": "text/config-file",
".ini": "text/ini",
".ps1": "application/x-powershell",
".ps2": "application/x-powershell",
".ps3": "application/x-powershell",
".dart": "application/vnd.dart",
".flutter": "text/flutter",
".tsx": "text/x-typescript",
".ts": "text/x-typescript",
".php": "application/x-php",
".php2": "application/x-php",
".php3": "application/x-php",
".phtml": "application/x-php",
".p": "text/x-pascal",
".pas": "text/x-pascal",
".zig": "text/x-zig",
".ex": "text/x-elixir",
".exs": "text/x-elixir",
".json5": "application/json5",
".pmx": "model/x-pmx",
".nix": "text/nix-lang",
".cjs": "text/javascript",
".torrent": "application/x-bittorrent",
".tmp": "text/tmp",
".temp": "text/tmp",
// 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
+30 -13
View File
@@ -19,7 +19,7 @@ func MakeUserDir(fullPath string) error {
return nil
}
func MakeUserDirs(u *users.User, createDir bool) error {
func MakeUserDirs(u *users.User, disableScopeChange bool) error {
cleanedUserName := users.CleanUsername(u.Username)
if cleanedUserName == "" || cleanedUserName == "-" || cleanedUserName == "." {
return fmt.Errorf("MakeUserDirs: invalid user for home dir creation: [%s]", u.Username)
@@ -27,31 +27,48 @@ func MakeUserDirs(u *users.User, createDir bool) error {
for i, scope := range u.Scopes {
source, ok := settings.Config.Server.SourceMap[scope.Name]
if !ok {
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
logger.Errorf("MakeUserDirs: source not found: %s", scope.Name)
continue
}
fullPath := filepath.Join(source.Path, scope.Scope)
parentDir := filepath.Dir(fullPath)
if createDir {
if source.Config.CreateUserDir && filepath.Base(scope.Scope) != cleanedUserName {
scope.Scope = utils.JoinPathAsUnix(scope.Scope, cleanedUserName)
fullPath = filepath.Join(fullPath, cleanedUserName)
}
// create directory and append user name
if filepath.Base(scope.Scope) != cleanedUserName && source.Config.CreateUserDir && !disableScopeChange {
fullPath := filepath.Join(source.Path, scope.Scope, cleanedUserName)
parentDir := filepath.Join(source.Path, scope.Scope)
// If parent directory doesn't exist and createUserDir is enabled, create it
if !Exists(parentDir) {
if err := MakeUserDir(parentDir); err != nil {
logger.Errorf("MakeUserDirs: failed to create parent scope directory: %s - %v", scope.Scope, err)
continue
}
}
// Use JoinPathAsUnix to ensure scope remains in Unix format (forward slashes)
scope.Scope = utils.JoinPathAsUnix(scope.Scope, cleanedUserName)
err := MakeUserDir(fullPath)
if err != nil {
return fmt.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
logger.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
// Continue to next scope even if this one failed
}
} else if filepath.Base(scope.Scope) == cleanedUserName && source.Config.CreateUserDir {
// create directory exactly as specified
fullPath := filepath.Join(source.Path, scope.Scope)
parentDir := filepath.Dir(fullPath)
if !Exists(parentDir) {
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", parentDir)
continue
}
err := MakeUserDir(fullPath)
if err != nil {
logger.Errorf("create user: failed to create user home dir: %s", err)
// Continue to next scope even if this one failed
}
} else {
if !Exists(fullPath) {
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", fullPath)
// just assigning scope to path provided, so just check that it exists
path := filepath.Join(source.Path, scope.Scope)
if !Exists(path) {
logger.Errorf("MakeUserDirs: scope folder does not exist: %s", path)
continue
}
}
u.Scopes[i] = scope
}
return nil
+5 -12
View File
@@ -9,7 +9,6 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
@@ -43,21 +42,15 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
}
}
user, getErr := userStore.Get(username)
var passwordHash string
if getErr != nil {
passwordHash = utils.InvalidPasswordHash
} else {
passwordHash = user.Password
user, err := userStore.Get(username)
if err != nil {
return nil, fmt.Errorf("unable to get user from store: %v", err)
}
// always run checkPwd to prevent timing attacks
err = utils.CheckPwd(password, passwordHash)
err = users.CheckPwd(password, user.Password)
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
@@ -1,375 +0,0 @@
// 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")
}
-5
View File
@@ -89,11 +89,6 @@ 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))
+1
View File
@@ -156,6 +156,7 @@ func TestUpdateUserScopes_Phases(t *testing.T) {
sourceA = settings.Source{Path: "/pathA", Name: "sourceA", Config: settings.SourceConfig{
DefaultEnabled: true,
DefaultUserScope: "/defaultA",
CreateUserDir: true,
}}
sourceB = settings.Source{Path: "/pathB", Name: "sourceB", Config: settings.SourceConfig{DefaultEnabled: true, DefaultUserScope: "/defaultB"}}
settings.Config.Server.Sources = []*settings.Source{&sourceA, &sourceB}
-1
View File
@@ -22,7 +22,6 @@ 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")
+41 -23
View File
@@ -667,9 +667,14 @@ func setDefaults(generate bool) Settings {
database := os.Getenv("FILEBROWSER_DATABASE")
if database == "" {
database = "database.db"
} else {
// check if database file exists
if _, err := os.Stat(database); os.IsNotExist(err) {
database = "database.db"
}
}
if _, err := os.Stat(database); os.IsNotExist(err) {
logger.Warning("database file could not be found. If this is unexpected, please set the FILEBROWSER_DATABASE environment variable to the correct path.")
logger.Warning("database file could not be found. If this is unexpected, the default path is `./database.db`, but it can be configured in the config file under `server.database`.")
}
s := Settings{
Server: Server{
@@ -679,14 +684,13 @@ 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,
CacheSizeMB: 32,
DisableReuse: false,
StartupIntegrityCheck: IndexStartupIntegrityQuickCheck,
WalMode: false,
BatchSize: 1000,
CacheSizeMB: 32,
DisableReuse: false,
},
Filesystem: Filesystem{
CreateFilePermission: "644",
@@ -708,19 +712,16 @@ 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),
DisableSettings: false,
ViewMode: "normal",
Locale: "en",
GallerySize: 3,
ThemeColor: "var(--blue)",
DisableOnlyOfficeExt: ".md .txt .pdf .html .xml",
StickySidebar: true,
LockPassword: false,
ShowHidden: false,
DarkMode: boolPtr(true),
DisableSettings: false,
ViewMode: "normal",
Locale: "en",
GallerySize: 3,
ThemeColor: "var(--blue)",
Permissions: UserDefaultsPermissions{
Modify: false,
Share: false,
@@ -731,13 +732,11 @@ func setDefaults(generate bool) Settings {
Preview: UserDefaultsPreview{
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,
@@ -870,7 +869,26 @@ func setConditionals(config *Source) {
IgnoreAllZeroSizeFolders: false,
IgnoreAllSymlinks: false,
IndexingDisabled: false,
NoRules: len(rules) == 0,
}
// 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
}
// Process all rules and infer global flags from root-level rules
+1 -6
View File
@@ -68,8 +68,6 @@ 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)
@@ -92,14 +90,12 @@ func ApplyUserDefaults(u *users.User) {
u.Preview.DisableHideSidebar = Config.UserDefaults.Preview.DisableHideSidebar
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
@@ -111,8 +107,7 @@ func ApplyUserDefaults(u *users.User) {
u.DisableOnlyOfficeExt = Config.UserDefaults.DisableOnlyOfficeExt
u.FileLoading = Config.UserDefaults.FileLoading
u.DisableOfficePreviewExt = Config.UserDefaults.DisableOfficePreviewExt
u.PreferEditorForMarkdown = Config.UserDefaults.PreferEditorForMarkdown
if len(u.Scopes) == 0 && u.Username != "anonymous" {
if len(u.Scopes) == 0 {
for _, source := range Config.Server.Sources {
if source.Config.DefaultEnabled {
u.Scopes = append(u.Scopes, users.SourceScope{
+6 -23
View File
@@ -62,7 +62,7 @@ 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"` // maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
Filesystem Filesystem `json:"filesystem"` // filesystem settings
IndexSqlConfig IndexSqlConfig `json:"indexSqlConfig"` // Index database SQL configuration
DisableWebDAV bool `json:"disableWebDAV"` // disable webdav support (default: false)
@@ -76,22 +76,11 @@ type Filesystem struct {
CreateDirectoryPermission string `json:"createDirectoryPermission" validate:"required,file_permission"` // Unix permissions like 755, 2755, 1777 (default: 755)
}
// Index SQL startup integrity modes (IndexSqlConfig.StartupIntegrityCheck).
const (
// IndexStartupIntegrityQuickCheck runs PRAGMA quick_check (default). Slower on very large DBs but thorough.
IndexStartupIntegrityQuickCheck = "quickCheck"
// IndexStartupIntegrityProbe verifies sqlite_master and optionally one row read only; fast for large indexes.
IndexStartupIntegrityProbe = "probe"
// IndexStartupIntegrityOff skips startup checks beyond sql.Open Ping; least safe, fastest boot.
IndexStartupIntegrityOff = "off"
)
type IndexSqlConfig struct {
BatchSize int `json:"batchSize"` // number of items to batch in a single transaction, typically 500-5000. higher = faster but could use more memory.
CacheSizeMB int `json:"cacheSizeMB"` // size of the SQLite cache in MB
WalMode bool `json:"walMode"` // enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
DisableReuse bool `json:"disableReuse"` // enable to always create a new indexing database on startup.
StartupIntegrityCheck string `json:"startupIntegrityCheck,omitempty" validate:"omitempty,oneof=quickCheck probe off"` // the method used to check the integrity of the index database on startup (default: quickCheck)
BatchSize int `json:"batchSize"` // number of items to batch in a single transaction, typically 500-5000. higher = faster but could use more memory.
CacheSizeMB int `json:"cacheSizeMB"` // size of the SQLite cache in MB
WalMode bool `json:"walMode"` // enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
DisableReuse bool `json:"disableReuse"` // enable to always create a new indexing database on startup.
}
type Integrations struct {
@@ -214,7 +203,7 @@ type SourceConfig struct {
Rules []ConditionalRule `json:"rules"` // list of item rules to apply to specific paths
DefaultUserScope string `json:"defaultUserScope"` // defaults to root of index "/" should match folders under path
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
CreateUserDir bool `json:"createUserDir"` // deprecated: create a user directory for each user under defaultUserScope + username (always true now)
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user under defaultUserScope + username
UseLogicalSize bool `json:"useLogicalSize"` // calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
// hidden but used internally - optimized map lookups for conditional rules
ResolvedRules ResolvedRulesConfig `json:"-"`
@@ -272,7 +261,6 @@ 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 {
@@ -318,14 +306,12 @@ type UserDefaultsPreview struct {
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
@@ -348,7 +334,6 @@ 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
@@ -368,12 +353,10 @@ 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
@@ -1,46 +0,0 @@
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)
}
-5
View File
@@ -60,7 +60,6 @@ type FileOptions struct {
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.
@@ -80,9 +79,5 @@ 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,6 +2,9 @@ package utils
import (
"cmp"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
math "math/rand"
"os"
@@ -20,6 +23,15 @@ 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 {
@@ -80,6 +92,11 @@ 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
View File
@@ -8,4 +8,3 @@ userDefaults:
image: true
popup: true
video: true
audio: true
+1 -6
View File
@@ -11,7 +11,6 @@ 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
@@ -28,14 +27,12 @@ userDefaults:
disableHideSidebar: false # disable the hide sidebar preview for previews and editors
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
@@ -47,9 +44,7 @@ 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
preferEditorForMarkdown: false # prefer editor first for markdown files instead of the Markdown Viewer
debugOffice: false # debug onlyoffice editor
+3 -69
View File
@@ -2,13 +2,10 @@ 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).
@@ -147,73 +144,10 @@ func (l *Link) HasReachedUserLimit(username string) bool {
return count >= l.DownloadsLimit
}
func (l *Link) GetSourceName() string {
func (l *Link) GetSourceName() (string, error) {
sourceInfo, ok := settings.Config.Server.SourceMap[l.Source]
if !ok {
return ""
return "", fmt.Errorf("source not found")
}
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")
if sourceName == "" || assetPath == "" {
return "", "", fmt.Errorf("source or path not found in asset configuration")
}
return sourceName, assetPath, nil
return sourceInfo.Name, nil
}
-2
View File
@@ -45,8 +45,6 @@ type CommonShare struct {
HasPassword bool `json:"hasPassword,omitempty"`
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
+1 -6
View File
@@ -309,13 +309,8 @@ func (s *Storage) GetBySourcePath(path, source string) ([]*Link, error) {
}
// IsShared returns whether the given path and source have any shares in the cache.
func (s *Storage) IsShared(path, source string, id uint) bool {
func (s *Storage) IsShared(path, source string) bool {
links, _ := s.GetBySourcePath(path, source)
for _, l := range links {
if l.UserID == id {
return true
}
}
return len(links) > 0
}
+18 -66
View File
@@ -2,7 +2,6 @@ package sql
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
@@ -91,8 +90,7 @@ func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int,
// For persistent databases, check integrity on startup
// If corrupted, delete and recreate the database
if !disableReuse {
mode := normalizeStartupIntegrityMode(settings.Config.Server.IndexSqlConfig.StartupIntegrityCheck)
if err := idxDB.runStartupIntegrityCheck(mode); err != nil {
if err := idxDB.checkIntegrityAndRecreateIfNeeded(); err != nil {
// Database was recreated due to corruption
logger.Warningf("[DB_INIT] SQLite database was found with issues which could be due to a dirty shutdown, recreating: tmp/index_%s.db", name)
isNewDb = true
@@ -114,73 +112,10 @@ func NewIndexDB(name string, journalMode string, batchSize int, cacheSizeMB int,
return idxDB, isNewDb, nil
}
func normalizeStartupIntegrityMode(m string) string {
switch m {
case "", settings.IndexStartupIntegrityQuickCheck:
return settings.IndexStartupIntegrityQuickCheck
case settings.IndexStartupIntegrityProbe:
return settings.IndexStartupIntegrityProbe
case settings.IndexStartupIntegrityOff:
return settings.IndexStartupIntegrityOff
default:
logger.Warningf("[DB_INIT] unknown indexSqlConfig.startupIntegrityCheck %q, using %s", m, settings.IndexStartupIntegrityQuickCheck)
return settings.IndexStartupIntegrityQuickCheck
}
}
// runStartupIntegrityCheck runs the configured startup check (quick_check, probe, or off).
func (db *IndexDB) runStartupIntegrityCheck(mode string) error {
switch mode {
case settings.IndexStartupIntegrityOff:
logger.Debugf("[DB_INTEGRITY] startup integrity skipped (startupIntegrityCheck=off)")
return nil
case settings.IndexStartupIntegrityProbe:
return db.checkAccessibilityProbe()
default:
// quickCheck (including normalized default)
return db.checkIntegrityAndRecreateIfNeeded()
}
}
// checkAccessibilityProbe verifies the DB file is readable: sqlite_master and optionally one row from index_items.
// If index_items is absent (brand-new file), succeeds so CreateIndexTable can create it.
func (db *IndexDB) checkAccessibilityProbe() error {
start := time.Now()
defer func() {
logger.Debugf("[DB_INTEGRITY] startup probe completed in %v", time.Since(start))
}()
var n int
err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'index_items'`).Scan(&n)
if err != nil {
logger.Warningf("[DB_INTEGRITY] probe: cannot read sqlite_master: %v", err)
return db.deleteCorruptedDatabase()
}
if n == 0 {
logger.Debugf("[DB_INTEGRITY] probe: index_items not present yet (new database), skipping row read")
return nil
}
var dummy int
err = db.QueryRow(`SELECT 1 FROM index_items LIMIT 1`).Scan(&dummy)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
logger.Warningf("[DB_INTEGRITY] probe: cannot read index_items: %v", err)
return db.deleteCorruptedDatabase()
}
return nil
}
// checkIntegrityAndRecreateIfNeeded checks database integrity.
// If corruption is detected, deletes the database file and returns an error to trigger recreation.
// Returns nil if integrity is OK, error if database needs to be recreated.
func (db *IndexDB) checkIntegrityAndRecreateIfNeeded() error {
start := time.Now()
defer func() {
logger.Debugf("[DB_INTEGRITY] PRAGMA quick_check completed in %v", time.Since(start))
}()
// Quick integrity check
var result string
err := db.QueryRow("PRAGMA quick_check").Scan(&result)
@@ -1263,6 +1198,23 @@ func (db *IndexDB) GetSizeGroupsForDuplicates(source string, minSize int64, path
return sizes, sizeCounts, rows.Err()
}
// GetTotalSize returns the sum of all file sizes in the index for a specific source (excluding directories).
func (db *IndexDB) GetTotalSize(source string) (uint64, error) {
query := `SELECT COALESCE(SUM(size), 0) FROM index_items WHERE source = ? AND is_dir = 0`
var totalSize int64
err := db.QueryRow(query, source).Scan(&totalSize)
if err != nil {
// Soft failure: DB is busy or locked, return 0
if isBusyError(err) || isTransactionError(err) {
return 0, nil
}
return 0, err
}
return uint64(totalSize), nil
}
// Helper functions
// getNextPathPrefix returns the lexicographically next string after the given prefix.
-129
View File
@@ -1,129 +0,0 @@
package sql
import (
"os"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func TestMain(m *testing.M) {
if fileutils.PermDir == 0 {
fileutils.SetFsPermissions(0o644, 0o755)
}
os.Exit(m.Run())
}
func TestNormalizeStartupIntegrityMode(t *testing.T) {
tests := []struct {
in string
want string
}{
{"", settings.IndexStartupIntegrityQuickCheck},
{"quickCheck", settings.IndexStartupIntegrityQuickCheck},
{"probe", settings.IndexStartupIntegrityProbe},
{"off", settings.IndexStartupIntegrityOff},
{"not-a-real-mode", settings.IndexStartupIntegrityQuickCheck},
}
for _, tt := range tests {
if got := normalizeStartupIntegrityMode(tt.in); got != tt.want {
t.Errorf("normalizeStartupIntegrityMode(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
// pushTestIndexConfig sets CacheDir and index SQL options for NewIndexDB; returned func restores prior values.
func pushTestIndexConfig(t *testing.T, cacheDir string, idx settings.IndexSqlConfig) func() {
t.Helper()
prevDir := settings.Config.Server.CacheDir
prevIdx := settings.Config.Server.IndexSqlConfig
settings.Config.Server.CacheDir = cacheDir
settings.Config.Server.IndexSqlConfig = idx
return func() {
settings.Config.Server.CacheDir = prevDir
settings.Config.Server.IndexSqlConfig = prevIdx
}
}
func testIndexSQLConfig(mode string) settings.IndexSqlConfig {
return settings.IndexSqlConfig{
WalMode: false,
BatchSize: 1000,
CacheSizeMB: 32,
DisableReuse: false,
StartupIntegrityCheck: mode,
}
}
func TestNewIndexDB_StartupProbe_NewFile(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityProbe))
defer pop()
db, _, err := NewIndexDB("probe_new", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
}
func TestNewIndexDB_StartupProbe_ReopenWithData(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityProbe))
defer pop()
db, _, err := NewIndexDB("probe_reopen", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
info := &iteminfo.FileInfo{
Path: "/a.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "a.txt",
Size: 12,
ModTime: time.Unix(1, 0),
Type: "text/plain",
Hidden: false,
},
}
if err = db.InsertItem("src1", "/a.txt", info); err != nil {
t.Fatal(err)
}
if err = db.Close(); err != nil {
t.Fatal(err)
}
var db2 *IndexDB
db2, _, err = NewIndexDB("probe_reopen", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db2.Close()
}
func TestNewIndexDB_StartupQuickCheckStillWorks(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityQuickCheck))
defer pop()
db, _, err := NewIndexDB("quick_default", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
}
func TestNewIndexDB_StartupOff(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(settings.IndexStartupIntegrityOff))
defer pop()
db, _, err := NewIndexDB("off_mode", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
}
+5 -11
View File
@@ -113,16 +113,10 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
if err != nil {
return err
}
for _, scope := range adjustedScopes {
// ensure full path exists
if scope.Scope == "" {
return fmt.Errorf("scope name is required")
}
}
user.Scopes = adjustedScopes
err = files.MakeUserDirs(user, false)
err = files.MakeUserDirs(user, true)
if err != nil {
return err
logger.Error(err.Error())
}
}
// converting scopes to map of paths intead of names (names can change)
@@ -179,7 +173,7 @@ func (st usersBackend) Save(user *users.User, changePass, disableScopeChange boo
if err != nil {
return err
}
pass, err := utils.HashPwd(user.Password)
pass, err := users.HashPwd(user.Password)
if err != nil {
return err
}
@@ -192,7 +186,7 @@ func (st usersBackend) Save(user *users.User, changePass, disableScopeChange boo
return err
}
user.Scopes = adjustedScopes
err = files.MakeUserDirs(user, true)
err = files.MakeUserDirs(user, disableScopeChange)
if err != nil {
logger.Error(err.Error())
}
@@ -298,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 := utils.HashPwd(user.Password)
value, err := users.HashPwd(user.Password)
if err != nil {
logger.Error(err.Error())
}
+1 -2
View File
@@ -6,12 +6,11 @@ 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() {
utils.BcryptCost = 4 // bcrypt.MinCost for faster tests
users.BcryptCost = 4 // bcrypt.MinCost for faster tests
// Initialize test source configuration
settings.Config.Server.SourceMap = map[string]*settings.Source{
+1 -2
View File
@@ -18,7 +18,6 @@ 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)
@@ -118,7 +117,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 := utils.HashPwd(userInfo.Username)
hashpass, err := users.HashPwd(userInfo.Username)
if err != nil {
return err
}
+18
View File
@@ -0,0 +1,18 @@
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))
}
-6
View File
@@ -56,14 +56,12 @@ type Preview struct {
DisableHideSidebar bool `json:"disableHideSidebar"` // disable the hide sidebar preview for previews and editors
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.
@@ -121,13 +119,9 @@ type NonAdminEditable struct {
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
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 {
+43 -43
View File
@@ -4,35 +4,35 @@ go 1.25.0
require (
github.com/asdine/storm/v3 v3.2.1
github.com/coreos/go-oidc/v3 v3.18.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/coreos/go-systemd/v22 v22.7.0
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/gen2brain/go-fitz v1.24.15
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.30.2
github.com/go-ldap/ldap/v3 v3.4.12
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.21
github.com/mattn/go-sqlite3 v1.14.42
github.com/kovidgoyal/imaging v1.8.19
github.com/mattn/go-sqlite3 v1.14.34
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.50.0
golang.org/x/image v0.39.0
golang.org/x/mod v0.35.0
golang.org/x/net v0.52.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.43.0
golang.org/x/time v0.15.0
golang.org/x/tools v0.43.0
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
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.48.2
modernc.org/sqlite v1.45.0
)
require (
@@ -55,13 +55,13 @@ require (
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
github.com/MirrexOne/unqueryvet v1.5.3 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.65.0 // indirect
github.com/air-verse/air v1.64.5 // indirect
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.1.0 // indirect
github.com/alexkohler/prealloc v1.0.2 // indirect
github.com/alfatraining/structtag v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
@@ -101,9 +101,9 @@ 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 // indirect
github.com/ebitengine/purego v0.10.0-alpha.4 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/firefart/nonamedreturns v1.0.6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
@@ -113,17 +113,17 @@ require (
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.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-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-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
@@ -139,13 +139,13 @@ 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-20260401045841-3289a7742193 // indirect
github.com/golang/geo v0.0.0-20260129164528-943061e2742c // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golangci-lint/v2 v2.11.4 // indirect
github.com/golangci/golangci-lint/v2 v2.10.1 // indirect
github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
@@ -171,7 +171,7 @@ require (
github.com/julz/importas v0.2.0 // indirect
github.com/jupiterrider/ffi v0.6.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.10.0 // indirect
github.com/kisielk/errcheck v1.9.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/kovidgoyal/go-parallel v1.1.1 // indirect
github.com/kovidgoyal/go-shm v1.0.0 // indirect
@@ -196,10 +196,10 @@ require (
github.com/maratori/testpackage v1.1.2 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/mgechev/revive v1.15.0 // indirect
github.com/mgechev/revive v1.14.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/moricho/tparallel v0.3.2 // indirect
@@ -228,15 +228,15 @@ require (
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect
github.com/ryanrolds/sqlclosecheck v0.5.1 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 // indirect
github.com/securego/gosec/v2 v2.23.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.5.1 // indirect
github.com/sonatard/noctx v0.4.0 // indirect
github.com/sourcegraph/go-diff v0.7.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/cobra v1.10.2 // indirect
@@ -257,7 +257,7 @@ require (
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/urfave/cli/v2 v2.3.0 // indirect
github.com/uudashr/gocognit v1.2.1 // indirect
github.com/uudashr/gocognit v1.2.0 // indirect
github.com/uudashr/iface v1.4.1 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
@@ -273,15 +273,15 @@ require (
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.34.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.70.0 // indirect
modernc.org/libc v1.68.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
mvdan.cc/gofumpt v0.9.2 // indirect
+75 -96
View File
@@ -77,16 +77,12 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/MirrexOne/unqueryvet v1.5.3 h1:LpT3rsH+IY3cQddWF9bg4C7jsbASdGnrOSofY8IPEiw=
github.com/MirrexOne/unqueryvet v1.5.3/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgyLJgsQ=
github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4=
github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863 h1:BRrxwOZBolJN4gIwvZMJY1tzqBvQgpaZiQRuIDD40jM=
github.com/Sereal/Sereal v0.0.0-20190618215532-0b8ac451a863/go.mod h1:D0JMgToj/WdxCgd30Kc1UcA9E+WdZoJqeVOuYW7iTBM=
github.com/air-verse/air v1.64.5 h1:+gs/NgTzYYe+gGPyfHy3XxpJReQWC1pIsiKIg0LgNt4=
github.com/air-verse/air v1.64.5/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/air-verse/air v1.65.0 h1:MCJTmXL+UBS1Ji05jsIaUBXokeW10W+Y6ODkx6GcfZk=
github.com/air-verse/air v1.65.0/go.mod h1:OaJZSfZqf7wyjS2oP/CcEVyIt0JmZuPh5x1gdtklmmY=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
@@ -106,8 +102,6 @@ github.com/alexkohler/nakedret/v2 v2.0.6 h1:ME3Qef1/KIKr3kWX3nti3hhgNxw6aqN5pZmQ
github.com/alexkohler/nakedret/v2 v2.0.6/go.mod h1:l3RKju/IzOMQHmsEvXwkqMDzHHvurNQfAgE1eVmT40Q=
github.com/alexkohler/prealloc v1.0.2 h1:MPo8cIkGkZytq7WNH9UHv3DIX1mPz1RatPXnZb0zHWQ=
github.com/alexkohler/prealloc v1.0.2/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
github.com/alexkohler/prealloc v1.1.0 h1:cKGRBqlXw5iyQGLYhrXrDlcHxugXpTq4tQ5c91wkf8M=
github.com/alexkohler/prealloc v1.1.0/go.mod h1:fT39Jge3bQrfA7nPMDngUfvUbQGQeJyGQnR+913SCig=
github.com/alfatraining/structtag v1.0.0 h1:2qmcUqNcCoyVJ0up879K614L9PazjBSFruTB0GOFjCc=
github.com/alfatraining/structtag v1.0.0/go.mod h1:p3Xi5SwzTi+Ryj64DqjLWz7XurHxbGsq6y3ubePJPus=
github.com/alingse/asasalint v0.0.11 h1:SFwnQXJ49Kx/1GghOFz1XGqHYKp21Kq1nHad/0WQRnw=
@@ -209,8 +203,8 @@ github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyM
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
@@ -254,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 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
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/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=
@@ -266,8 +260,6 @@ github.com/evanw/esbuild v0.25.9 h1:aU7GVC4lxJGC1AyaPwySWjSIaNLAdVEEuq3chD0Khxs=
github.com/evanw/esbuild v0.25.9/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
github.com/firefart/nonamedreturns v1.0.6 h1:vmiBcKV/3EqKY3ZiPxCINmpS431OcE1S47AQUwhrg8E=
@@ -302,53 +294,53 @@ github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
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-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
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.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/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/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
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-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-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
@@ -409,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-20260401045841-3289a7742193 h1:TWcQGetTogk7h5+TRKI6dnvXRQCtEdSPXLu4RdouuM8=
github.com/golang/geo v0.0.0-20260401045841-3289a7742193/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
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/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=
@@ -452,8 +444,6 @@ github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0a
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
github.com/golangci/golangci-lint/v2 v2.10.1 h1:flhw5Px6ojbLyEFzXvJn5B2HEdkkRlkhE1SnmCbQBiE=
github.com/golangci/golangci-lint/v2 v2.10.1/go.mod h1:dBsrOk6zj0vDhlTv+IiJGqkDokR24IVTS7W3EVfPTQY=
github.com/golangci/golangci-lint/v2 v2.11.4 h1:GK+UlZBN5y7rh2PBnHA93XLSX6RaF7uhzJQ3JwU1wuA=
github.com/golangci/golangci-lint/v2 v2.11.4/go.mod h1:ODQDCASMA3VqfZYIbbQLpTRTzV7O/vjmIRF6u8NyFwI=
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10=
github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=
@@ -581,8 +571,6 @@ github.com/karamaru-alpha/copyloopvar v1.2.2 h1:yfNQvP9YaGQR7VaWLYcfZUlRP2eo2vhE
github.com/karamaru-alpha/copyloopvar v1.2.2/go.mod h1:oY4rGZqZ879JkJMtX3RRkcXRkmUvH0x35ykgaKgsgJY=
github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
github.com/kisielk/errcheck v1.10.0 h1:Lvs/YAHP24YKg08LA8oDw2z9fJVme090RAXd90S+rrw=
github.com/kisielk/errcheck v1.10.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kkHAIKE/contextcheck v1.1.6 h1:7HIyRcnyzxL9Lz06NGhiKvenXq7Zw6Q0UQu/ttjfJCE=
github.com/kkHAIKE/contextcheck v1.1.6/go.mod h1:3dDbMRNBFaq8HFXWC1JyvDSPm43CmE6IuHam8Wr0rkg=
@@ -592,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.21 h1:95S2+dowTeKJJHNpf6lnScvIennTr2H0zQotu+ptNQw=
github.com/kovidgoyal/imaging v1.8.21/go.mod h1:976F+zjiQeZ7sd87Pxlm0a64S/w9bImSIWg3sSk1rdQ=
github.com/kovidgoyal/imaging v1.8.19 h1:zWJdQqF2tfSKjvoB7XpLRhVGbYsze++M0iaqZ4ZkhNk=
github.com/kovidgoyal/imaging v1.8.19/go.mod h1:I0q8RdoEuyc4G8GFOF9CaluTUHQSf68d6TmsqpvfRI8=
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=
@@ -652,18 +640,16 @@ github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
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.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
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/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=
github.com/mgechev/revive v1.14.0/go.mod h1:MvnujelCZBZCaoDv5B3foPo6WWgULSSFxvfxp7GsPfo=
github.com/mgechev/revive v1.15.0 h1:vJ0HzSBzfNyPbHKolgiFjHxLek9KUijhqh42yGoqZ8Q=
github.com/mgechev/revive v1.15.0/go.mod h1:LlAKO3QQe9OJ0pVZzI2GPa8CbXGZ/9lNpCGvK4T/a8A=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
@@ -785,8 +771,6 @@ github.com/ryancurrah/gomodguard v1.4.1 h1:eWC8eUMNZ/wM/PWuZBv7JxxqT5fiIKSIyTvjb
github.com/ryancurrah/gomodguard v1.4.1/go.mod h1:qnMJwV1hX9m+YJseXEBhd2s90+1Xn6x9dLz11ualI1I=
github.com/ryanrolds/sqlclosecheck v0.5.1 h1:dibWW826u0P8jNLsLN+En7+RqWWTYrjCB9fJfSfdyCU=
github.com/ryanrolds/sqlclosecheck v0.5.1/go.mod h1:2g3dUjoS6AL4huFdv6wn55WpLIDjY7ZgUR4J8HOO/XQ=
github.com/ryanrolds/sqlclosecheck v0.6.0 h1:pEyL9okISdg1F1SEpJNlrEotkTGerv5BMk7U4AG0eVg=
github.com/ryanrolds/sqlclosecheck v0.6.0/go.mod h1:xyX16hsDaCMXHrMJ3JMzGf5OpDfHTOTTQrT7HOFUmeU=
github.com/sanposhiho/wastedassign/v2 v2.1.0 h1:crurBF7fJKIORrV85u9UUpePDYGWnwvv3+A96WvwXT0=
github.com/sanposhiho/wastedassign/v2 v2.1.0/go.mod h1:+oSmSC+9bQ+VUAxA66nBb0Z7N8CK7mscKTDYC6aIek4=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
@@ -797,8 +781,6 @@ github.com/sashamelentyev/usestdlibvars v1.29.0 h1:8J0MoRrw4/NAXtjQqTHrbW9NN+3iM
github.com/sashamelentyev/usestdlibvars v1.29.0/go.mod h1:8PpnjHMk5VdeWlVb4wCdrB8PNbLqZ3wBZTZWkrpZZL8=
github.com/securego/gosec/v2 v2.23.0 h1:h4TtF64qFzvnkqvsHC/knT7YC5fqyOCItlVR8+ptEBo=
github.com/securego/gosec/v2 v2.23.0/go.mod h1:qRHEgXLFuYUDkI2T7W7NJAmOkxVhkR0x9xyHOIcMNZ0=
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08 h1:AoLtJX4WUtZkhhUUMFy3GgecAALp/Mb4S1iyQOA2s0U=
github.com/securego/gosec/v2 v2.24.8-0.20260309165252-619ce2117e08/go.mod h1:+XLCJiRE95ga77XInNELh2M6zQP+PdqiT9Zpm0D9Wpk=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
@@ -813,8 +795,6 @@ github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+W
github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4=
github.com/sonatard/noctx v0.4.0 h1:7MC/5Gg4SQ4lhLYR6mvOP6mQVSxCrdyiExo7atBs27o=
github.com/sonatard/noctx v0.4.0/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
github.com/sonatard/noctx v0.5.1 h1:wklWg9c9ZYugOAk7qG4yP4PBrlQsmSLPTvW1K4PRQMs=
github.com/sonatard/noctx v0.5.1/go.mod h1:64XdbzFb18XL4LporKXp8poqZtPKbCrqQ402CV+kJas=
github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0=
github.com/sourcegraph/go-diff v0.7.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@@ -882,8 +862,6 @@ github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
github.com/uudashr/gocognit v1.2.0 h1:3BU9aMr1xbhPlvJLSydKwdLN3tEUUrzPSSM8S4hDYRA=
github.com/uudashr/gocognit v1.2.0/go.mod h1:k/DdKPI6XBZO1q7HgoV2juESI2/Ofj9AcHPZhBBdrTU=
github.com/uudashr/gocognit v1.2.1 h1:CSJynt5txTnORn/DkhiB4mZjwPuifyASC8/6Q0I/QS4=
github.com/uudashr/gocognit v1.2.1/go.mod h1:acaubQc6xYlXFEMb9nWX2dYBzJ/bIjEkc1zzvyIZg5Q=
github.com/uudashr/iface v1.4.1 h1:J16Xl1wyNX9ofhpHmQ9h9gk5rnv2A6lX/2+APLTo0zU=
github.com/uudashr/iface v1.4.1/go.mod h1:pbeBPlbuU2qkNDn0mmfrxP2X+wjPMIQAy+r1MBXSXtg=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
@@ -950,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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
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=
@@ -962,16 +940,16 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20230203172020-98cc5a0785f9/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk=
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.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
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/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=
@@ -997,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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
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=
@@ -1041,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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
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/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.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1066,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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
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/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=
@@ -1116,11 +1094,12 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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/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=
@@ -1137,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.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
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/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.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -1194,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.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
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/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=
@@ -1314,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.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/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/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.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
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=
@@ -1334,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.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs=
modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
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=
+205 -266
View File
@@ -8,12 +8,10 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
@@ -27,7 +25,7 @@ import (
// 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 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.
// @Description
// @Description **Request body parameters:**
// @Description - **fromSource** (string, required): Source name where the paths to archive live. Example: `"default"`
@@ -45,7 +43,6 @@ import (
// @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) {
@@ -158,30 +155,22 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestCont
return http.StatusBadRequest, fmt.Errorf("no paths accessible; add at least one path you have access to")
}
// Check archive size limit if configured
estimatedSize, err := computeArchiveSize(req.FromSource, req.Paths, d)
if err != nil {
return http.StatusInternalServerError, err
}
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)
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)
}
}
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...)
createErr = createZip(d, req.FromSource, destRealPath, itemPaths...)
} else {
createErr = createTarGzWithLevel(d, req.FromSource, file, compression, itemPaths...)
createErr = createTarGzWithLevel(d, req.FromSource, destRealPath, compression, itemPaths...)
}
if createErr != nil {
return http.StatusInternalServerError, createErr
@@ -220,7 +209,7 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestCont
// 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 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.
// @Description
// @Description **Request body parameters:**
// @Description - **fromSource** (string, required): Source name where the archive file lives. Example: `"default"`
@@ -236,7 +225,6 @@ func archiveCreateHandler(w http.ResponseWriter, r *http.Request, d *requestCont
// @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) {
@@ -260,7 +248,7 @@ func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
pathClean, err := utils.SanitizeUserPath(req.Path)
if err != nil {
return http.StatusBadRequest, err
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
destClean, err := utils.SanitizeUserPath(req.Destination)
if err != nil {
@@ -325,14 +313,6 @@ func unarchiveHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
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") {
@@ -364,10 +344,22 @@ func addFile(source string, path string, d *requestContext, tarWriter *tar.Write
}
// 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
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, store.Share)
if err != nil {
return err
}
realPath, _, _ := idx.GetRealPath(path)
info, err := os.Stat(realPath)
if err != nil {
@@ -418,21 +410,16 @@ func addFile(source string, path string, d *requestContext, tarWriter *tar.Write
if fileInfo.IsDir() {
if tarWriter != nil {
header, err := tar.FileInfoHeader(fileInfo, "")
if err != nil {
return err
header := &tar.Header{
Name: relPath + "/",
Mode: int64(fileutils.PermDir),
Typeflag: tar.TypeDir,
ModTime: fileInfo.ModTime(),
}
header.Name = relPath + "/"
return tarWriter.WriteHeader(header)
}
if zipWriter != nil {
zh, err := zip.FileInfoHeader(fileInfo)
if err != nil {
return err
}
zh.Name = relPath + "/"
zh.Method = zip.Store
_, err = zipWriter.CreateHeader(zh)
_, err := zipWriter.Create(relPath + "/")
return err
}
return nil
@@ -494,14 +481,66 @@ func addSingleFile(realPath, archivePath string, zipWriter *zip.Writer, tarWrite
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)
// 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 _, filepath := range filenames {
err := addFile(source, filepath, d, nil, zipWriter, false)
var userScope string
var err error
if d.share == nil {
userScope, err = d.user.GetScopeForSourceName(source)
if err != nil {
logger.Errorf("Failed to add %s to ZIP: %v", filepath, err)
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)
if store.Access != nil && !store.Access.Permitted(idx.Path, fullPath, d.user.Username) {
continue
}
} else {
fullPath = path
}
realPath, isDir, err := idx.GetRealPath(fullPath)
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
}
// createZip writes a ZIP archive to tmpPath containing the given paths; access rules apply.
func createZip(d *requestContext, source string, tmpPath string, filenames ...string) error {
file, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
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 {
logger.Errorf("Failed to add %s to ZIP: %v", fname, err)
return err
}
}
@@ -512,15 +551,21 @@ func createZip(d *requestContext, source string, w io.Writer, filenames ...strin
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)
// createTarGz writes a tar.gz archive to tmpPath containing the given paths; access rules apply.
func createTarGz(d *requestContext, source string, tmpPath string, filenames ...string) error {
file, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
}
defer file.Close()
gzWriter := gzip.NewWriter(file)
tarWriter := tar.NewWriter(gzWriter)
for _, filepath := range filenames {
err := addFile(source, filepath, d, tarWriter, nil, false)
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", filepath, err)
logger.Errorf("Failed to add %s to TAR.GZ: %v", fname, err)
return err
}
}
@@ -534,26 +579,31 @@ func createTarGz(d *requestContext, source string, w io.Writer, filenames ...str
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 {
// createTarGzWithLevel writes a tar.gz archive with the given gzip compression level (0=default, 1-9).
func createTarGzWithLevel(d *requestContext, source string, destPath string, level int, filenames ...string) error {
file, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
}
defer file.Close()
var gzWriter *gzip.Writer
if level >= 1 && level <= 9 {
var err error
gzWriter, err = gzip.NewWriterLevel(w, level)
gzWriter, err = gzip.NewWriterLevel(file, level)
if err != nil {
return err
}
} else {
gzWriter = gzip.NewWriter(w)
gzWriter = gzip.NewWriter(file)
}
defer gzWriter.Close()
tarWriter := tar.NewWriter(gzWriter)
defer tarWriter.Close()
for _, filepath := range filenames {
err := addFile(source, filepath, d, tarWriter, nil, false)
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", filepath, err)
logger.Errorf("Failed to add %s to TAR.GZ: %v", fname, err)
return err
}
}
@@ -564,15 +614,39 @@ func createTarGzWithLevel(d *requestContext, source string, w io.Writer, level i
}
// 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.
// It respects access rules and max archive size. 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) {
firstFilePath := fileList[0]
var userscope string
var err error
if d.share == nil {
userscope, err = d.user.GetScopeForSourceName(source)
if err != nil {
return http.StatusForbidden, err
}
firstFilePath = utils.JoinPathAsUnix(userscope, firstFilePath)
}
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])
realPath, isDir, err := idx.GetRealPath(firstFilePath)
if err != nil {
return http.StatusInternalServerError, fmt.Errorf("failed to get real path for %s: %v", fileList[0], err)
return http.StatusInternalServerError, err
}
estimatedSize, err := computeArchiveSize(source, fileList, d)
if err != nil {
return http.StatusInternalServerError, err
}
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)
}
}
algo := r.URL.Query().Get("algo")
@@ -586,7 +660,7 @@ func BuildAndStreamArchive(w http.ResponseWriter, r *http.Request, d *requestCon
return http.StatusInternalServerError, errors.New("format not implemented")
}
baseDirName := filepath.Base(filepath.Dir(realPath))
baseDirName := filepath.Base(filepath.Dir(firstFilePath))
if baseDirName == "" || baseDirName == "/" {
baseDirName = "download"
}
@@ -595,25 +669,51 @@ func BuildAndStreamArchive(w http.ResponseWriter, r *http.Request, d *requestCon
}
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())
}
archiveData := filepath.Join(config.Server.CacheDir, utils.InsecureRandomIdentifier(10))
if extension == ".zip" {
err = createZip(d, source, writer, fileList...)
archiveData = archiveData + ".zip"
err = createZip(d, source, archiveData, fileList...)
} else {
err = createTarGz(d, source, writer, fileList...)
archiveData = archiveData + ".tar.gz"
err = createTarGz(d, source, archiveData, fileList...)
}
if err != nil {
return http.StatusInternalServerError, err
}
fd, err := os.Open(archiveData)
if err != nil {
return http.StatusInternalServerError, err
}
defer fd.Close()
fileInfo, err := fd.Stat()
if err != nil {
os.Remove(archiveData)
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)
}
setContentDisposition(w, r, originalFileName)
w.Header().Set("Content-Length", fmt.Sprintf("%d", fileInfo.Size()))
w.Header().Set("Content-Type", "application/octet-stream")
var reader io.Reader = fd
if d.share != nil && d.share.MaxBandwidth > 0 {
limit := rate.Limit(d.share.MaxBandwidth * 1024)
burst := d.share.MaxBandwidth * 1024
reader = newThrottledReadSeeker(fd, limit, burst, r.Context())
}
_, err = io.Copy(w, reader)
os.Remove(archiveData)
if err != nil {
logger.Errorf("Failed to copy archive data to response: %v", err)
return http.StatusInternalServerError, err
}
return 0, nil
}
@@ -670,117 +770,6 @@ func safeExtractPath(destDir, name string) (string, error) {
return abs, nil
}
// applyArchivedTimesAndPerm sets permission bits and modification time from an archive entry using
func applyArchivedTimesAndPerm(path string, mode fs.FileMode, modTime time.Time) error {
switch {
case mode.Type() == fs.ModeSymlink:
if !modTime.IsZero() {
_ = os.Chtimes(path, modTime, modTime)
}
return nil
case mode.Type() == fs.ModeDir:
if p := mode.Perm(); p != 0 {
_ = os.Chmod(path, p)
}
if modTime.IsZero() {
return nil
}
return os.Chtimes(path, modTime, modTime)
case mode.IsRegular():
if p := mode.Perm(); p != 0 {
_ = os.Chmod(path, p)
}
if modTime.IsZero() {
return nil
}
return os.Chtimes(path, modTime, modTime)
default:
if !modTime.IsZero() {
_ = os.Chtimes(path, modTime, modTime)
}
return nil
}
}
// symlinkTargetStaysUnderDest rejects absolute targets and paths that would resolve outside destRoot.
func symlinkTargetStaysUnderDest(destRoot, destPath, linkname string) error {
if linkname == "" {
return fmt.Errorf("empty symlink target")
}
if filepath.IsAbs(linkname) {
return fmt.Errorf("symlink has absolute target")
}
destAbs, err := filepath.Abs(destRoot)
if err != nil {
return err
}
resolved := filepath.Clean(filepath.Join(filepath.Dir(destPath), linkname))
resAbs, err := filepath.Abs(resolved)
if err != nil {
return err
}
sep := string(filepath.Separator)
if resAbs != destAbs && !strings.HasPrefix(resAbs, destAbs+sep) {
return fmt.Errorf("symlink target escapes destination directory")
}
return nil
}
// extractArchivedDir creates a directory from an archive entry and reapplies mode and mtime.
func extractArchivedDir(destPath string, mode fs.FileMode, modTime time.Time) error {
perm := mode.Perm()
if perm == 0 {
perm = fileutils.PermDir
}
if err := os.MkdirAll(destPath, perm); err != nil {
return err
}
return applyArchivedTimesAndPerm(destPath, mode, modTime)
}
// extractArchivedRegularFile writes one regular file from src (closed by this function) and reapplies mode and mtime.
func extractArchivedRegularFile(destPath string, mode fs.FileMode, modTime time.Time, src io.ReadCloser) error {
defer func() { _ = src.Close() }()
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
return err
}
perm := mode.Perm()
if perm == 0 {
perm = fileutils.PermFile
}
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
if err != nil {
return err
}
_, copyErr := io.Copy(out, src)
cerr := out.Close()
if copyErr != nil {
return copyErr
}
if cerr != nil {
return cerr
}
return applyArchivedTimesAndPerm(destPath, mode, modTime)
}
// extractArchivedSymlink creates a symlink after validating the target stays under destRoot.
func extractArchivedSymlink(destRoot, destPath, target string, modTime time.Time) error {
target = strings.TrimSpace(target)
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
return err
}
if err := symlinkTargetStaysUnderDest(destRoot, destPath, target); err != nil {
return err
}
if err := os.Remove(destPath); err != nil && !os.IsNotExist(err) {
return err
}
if err := os.Symlink(target, destPath); err != nil {
return err
}
return applyArchivedTimesAndPerm(destPath, fs.ModeSymlink, modTime)
}
func extractZip(archivePath, destDir string) error {
r, err := zip.OpenReader(archivePath)
if err != nil {
@@ -789,49 +778,33 @@ func extractZip(archivePath, destDir string) error {
defer r.Close()
for _, f := range r.File {
var destPath string
destPath, err = safeExtractPath(destDir, f.Name)
destPath, err := safeExtractPath(destDir, f.Name)
if err != nil {
return err
}
fi := f.FileInfo()
mode := fi.Mode()
if mode.Type() == fs.ModeDir || strings.HasSuffix(f.Name, "/") {
if err = extractArchivedDir(destPath, mode, f.Modified); err != nil {
if f.FileInfo().IsDir() {
if err = os.MkdirAll(destPath, fileutils.PermDir); err != nil {
return err
}
continue
}
if mode.Type() == fs.ModeSymlink {
var rc io.ReadCloser
rc, err = f.Open()
if err != nil {
return err
}
buf, rerr := io.ReadAll(rc)
rc.Close()
if rerr != nil {
return rerr
}
if err = extractArchivedSymlink(destDir, destPath, string(buf), f.Modified); err != nil {
return err
}
continue
if err = os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
return err
}
if !mode.IsRegular() {
continue
}
var rc io.ReadCloser
rc, err = f.Open()
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
}
if err = extractArchivedRegularFile(destPath, mode, f.Modified, rc); err != nil {
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
}
}
@@ -860,64 +833,30 @@ func extractTarGz(archivePath, destDir string) error {
if err != nil {
return err
}
var destPath string
destPath, err = safeExtractPath(destDir, h.Name)
destPath, err := safeExtractPath(destDir, h.Name)
if err != nil {
return err
}
switch h.Typeflag {
case tar.TypeDir:
mode := h.FileInfo().Mode()
if err = extractArchivedDir(destPath, mode, h.ModTime); err != nil {
if err := os.MkdirAll(destPath, fileutils.PermDir); err != nil {
return err
}
case tar.TypeReg:
mode := h.FileInfo().Mode()
if err = extractArchivedRegularFile(destPath, mode, h.ModTime, io.NopCloser(tr)); err != nil {
if err := os.MkdirAll(filepath.Dir(destPath), fileutils.PermDir); err != nil {
return err
}
case tar.TypeSymlink:
if err = extractArchivedSymlink(destDir, destPath, h.Linkname, h.ModTime); err != nil {
out, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
}
default:
if h.Size > 0 {
if _, err = io.CopyN(io.Discard, tr, h.Size); 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
}
+11 -36
View File
@@ -24,13 +24,6 @@ type throttledReadSeeker struct {
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{
@@ -58,29 +51,6 @@ 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
@@ -169,7 +139,6 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
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 {
@@ -191,11 +160,8 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
return http.StatusForbidden, err
}
for i, filePath := range fileList {
fileList[i] = utils.JoinPathAsUnix(userscope, filePath)
}
firstFilePath = utils.JoinPathAsUnix(userscope, firstFilePath)
}
firstFilePath = fileList[0]
// For shares, the path is already correctly resolved by publicRawHandler
idx := indexing.GetIndex(source)
if idx == nil {
@@ -219,7 +185,11 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
}
return http.StatusInternalServerError, err
}
// Compute estimated download size (for single-file branch and archive size check)
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
@@ -279,6 +249,11 @@ func rawFilesHandler(w http.ResponseWriter, r *http.Request, d *requestContext,
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
+2 -2
View File
@@ -139,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, err
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
// Parse lines parameter
@@ -275,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, err
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
path = cleanPath
+2 -6
View File
@@ -6,10 +6,10 @@ import (
"embed"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"net"
"net/http"
"text/template"
"time"
_ "net/http/pprof"
@@ -119,7 +119,6 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
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)))
api.HandleFunc("POST /resources/pause", withUser(resourcePauseHandler))
publicApi.HandleFunc("GET /resources", withHashFile(publicGetResourceHandler))
publicApi.HandleFunc("GET /resources/items", withHashFile(publicItemsGetHandler))
publicApi.HandleFunc("POST /resources", withHashFile(publicUploadHandler))
@@ -129,7 +128,6 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
publicApi.HandleFunc("PATCH /resources", withHashFile(publicPatchHandler))
publicApi.HandleFunc("GET /resources/download", withHashFile(publicDownloadHandler))
publicApi.HandleFunc("GET /resources/preview", withHashFile(publicPreviewHandler))
publicApi.HandleFunc("POST /resources/pause", withHashFile(publicPauseHandler))
// Legacy routes (backwards compatibility)
api.HandleFunc("GET /raw", withUser(downloadHandler))
publicApi.HandleFunc("GET /raw", withHashFile(publicDownloadHandler))
@@ -173,11 +171,9 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
api.HandleFunc("GET /tools/fileWatcher/sse", withUser(fileWatchSSEHandler))
// ========================================
// Media Routes - /api/media/ (with public routes)
// Media Routes - /api/media/
// ========================================
api.HandleFunc("GET /media/subtitles", withUser(subtitlesHandler))
api.HandleFunc("GET /media/metadata", withUser(metadataHandler))
publicApi.HandleFunc("GET /media/metadata", withHashFile(publicMetadataHandler))
// ========================================
// OnlyOffice Routes - /api/office/ (with public routes)
+6 -6
View File
@@ -24,7 +24,7 @@ func AuthenticateLDAPUser(username, password string) (*users.User, error) {
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
@@ -36,7 +36,7 @@ func AuthenticateLDAPUser(username, password string) (*users.User, error) {
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)
}
@@ -123,7 +123,7 @@ func authenticateLDAP(username, password string) ([]string, map[string]string, e
}
groups := entry.GetAttributeValues(groupAttr)
// Extract user attributes for identifier mapping
userAttributes := make(map[string]string)
if c.UserIdentifier != "" {
@@ -132,7 +132,7 @@ func authenticateLDAP(username, password string) ([]string, map[string]string, e
userAttributes[c.UserIdentifier] = values[0]
}
}
return groups, userAttributes, nil
}
@@ -187,7 +187,7 @@ func ldapGroupMatchesAdmin(groupDN, adminGroup string) bool {
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
@@ -208,7 +208,7 @@ func getOrCreateLdapUser(username string, groups []string) (*users.User, error)
}
logger.Debugf("User %s is in required group, allowing access.", username)
}
isAdmin := false
if ldapCfg.AdminGroup != "" {
for _, g := range groups {
+10 -4
View File
@@ -80,11 +80,17 @@ func TestPickUserEntry(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := pickUserEntry(tt.entries)
if tt.wantNil && got != nil {
t.Errorf("pickUserEntry() = %v, want nil", got)
if tt.wantNil {
if got != nil {
t.Errorf("pickUserEntry() = %v, want nil", got)
}
return
}
if !tt.wantNil && got == nil {
t.Errorf("pickUserEntry() = nil, want non-nil entry")
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)
}
})
}
-76
View File
@@ -8,8 +8,6 @@ import (
"path/filepath"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
@@ -101,77 +99,3 @@ func subtitlesHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
http.ServeContent(w, r, name, time.Now(), bytes.NewReader([]byte(content)))
return http.StatusOK, nil
}
// metadataHandler returns the same directory resource shape as GET /api/resources with metadata enabled,
// for client-side patching after a fast listing load.
// @Summary Directory with media metadata
// @Description Same ExtendedFileInfo as resources GET with metadata=true (typically used for directories).
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "Path to the directory or file"
// @Param source query string true "Source name"
// @Param albumArt query bool false "When true, include embedded album art bytes in audio metadata"
// @Success 200 {object} iteminfo.ExtendedFileInfo
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Not found"
// @Router /api/media/metadata [get]
func metadataHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
albumArt := r.URL.Query().Get("albumArt") == "true"
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: path,
Source: source,
Expand: true,
Content: false,
Metadata: true,
AlbumArt: albumArt,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
ShowHidden: d.user.ShowHidden,
SkipExtendedAttrs: false,
ShowSharedAttr: true,
}, store.Access, d.user, store.Share)
if err != nil {
return errToStatus(err), err
}
return renderJSON(w, r, fileInfo)
}
// publicMetadataHandler is the share-link variant of metadataHandler.
// @Summary Directory with media metadata (public share)
// @Tags Shares
// @Produce json
// @Param hash query string true "Share hash"
// @Param path query string false "Path within the share"
// @Param albumArt query bool false "When true, include embedded album art bytes in audio metadata (heavier)"
// @Success 200 {object} iteminfo.ExtendedFileInfo
// @Router /public/api/media/metadata [get]
func publicMetadataHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if d.share.ShareType == "upload" {
return http.StatusNotImplemented, fmt.Errorf("browsing is disabled for upload shares")
}
path := r.URL.Query().Get("path")
albumArt := r.URL.Query().Get("albumArt") == "true"
sourceCfg, ok := config.Server.SourceMap[d.share.Source]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
}
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Path: d.IndexPath,
Source: sourceCfg.Name,
Expand: true,
Content: false,
Metadata: true,
AlbumArt: albumArt,
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles && d.share.ExtractEmbeddedSubtitles,
ShowHidden: d.share.ShowHidden,
FollowSymlinks: false,
}, store.Access, d.shareUser, store.Share)
if err != nil {
return errToStatus(err), err
}
fileInfo.Path = utils.AddTrailingSlashIfNotExists(path)
return renderJSON(w, r, fileInfo)
}
+37 -46
View File
@@ -5,9 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"runtime"
"slices"
"strings"
@@ -116,9 +114,7 @@ func withHashFileHelper(fn handleFunc) handleFunc {
}
data.IndexPath = pathWithoutUserScope
// 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")) ||
(r.Method == "GET" && strings.Contains(r.URL.Path, "/media/metadata")) {
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{
@@ -316,24 +312,38 @@ 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) {
if config.Auth.Methods.ProxyAuth.Enabled {
proxyUser := r.Header.Get(config.Auth.Methods.ProxyAuth.Header)
if proxyUser != "" {
return getProxyUser(w, r, d, fn, proxyUser)
// 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)
}
}
}
}
}
// No valid admin token - proceed with username/password authentication
username := r.URL.Query().Get("username")
password := r.Header.Get("X-Password")
// 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 {
@@ -381,7 +391,7 @@ 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)
@@ -389,12 +399,12 @@ func withUserHelper(fn handleFunc) handleFunc {
// 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) {
@@ -475,7 +485,7 @@ func getJwtUser(w http.ResponseWriter, r *http.Request, data *requestContext, fn
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 {
@@ -486,7 +496,7 @@ func getJwtUser(w http.ResponseWriter, r *http.Request, data *requestContext, fn
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)
@@ -497,7 +507,7 @@ func getJwtUser(w http.ResponseWriter, r *http.Request, data *requestContext, fn
}
data.token = tokenString
}
// Call the handler function, passing in the context (or return OK if no handler)
if fn == nil {
return http.StatusOK, nil
@@ -748,26 +758,6 @@ 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) {
@@ -776,6 +766,7 @@ 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
@@ -788,7 +779,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, getRemoteIP(r), stackTrace)
rcv, username, method, url, remoteAddr, 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.
@@ -831,7 +822,7 @@ func LoggingMiddleware(next http.Handler) http.Handler {
fmt.Sprintf("%-7s | %3d | %-15s | %-12s | %-12s | \"%s\"",
r.Method,
wrappedWriter.StatusCode,
getRemoteIP(r),
r.RemoteAddr,
truncUser,
fmt.Sprintf("%vms", duration.Milliseconds()),
fullURL))
+23 -102
View File
@@ -4,7 +4,6 @@ import (
"crypto/tls"
"encoding/json"
"fmt"
"maps"
"net/http"
"net/url"
"slices"
@@ -42,112 +41,32 @@ func (u *userInfoUnmarshaller) UnmarshalJSON(data []byte) error {
return err
}
// Extract groups if configured (supports nested claims like "resource_access.filebrowser-client.roles")
// Extract groups if configured
if u.groupsClaim != "" {
u.userInfo.Groups = extractGroupsFromOIDCClaims(raw, u.groupsClaim)
}
u.userInfo.Claims = raw
return nil
}
// extractGroupsFromOIDCClaims extracts groups from OIDC claims supporting nested paths.
// Supports both direct claims (e.g., "groups") and nested claims using '.' separator (e.g., "resource_access.filebrowser-client.roles")
func extractGroupsFromOIDCClaims(claims map[string]interface{}, groupsClaimField string) []string {
var groups []string
if groupsClaimField == "" {
return groups
}
// Try to resolve nested path first (e.g., "custom.groups")
groupsVal := resolveOIDCNestedClaim(claims, groupsClaimField)
if groupsVal == nil {
// If nested path didn't work, try direct lookup
if val, ok := claims[groupsClaimField]; ok {
groupsVal = val
}
}
if groupsVal != nil {
groups = parseOIDCGroupsValue(groupsVal)
}
return groups
}
// resolveOIDCNestedClaim resolves nested OIDC claims using '.' as a separator
func resolveOIDCNestedClaim(claims map[string]interface{}, path string) interface{} {
parts := strings.Split(path, ".")
current := interface{}(claims)
for _, part := range parts {
switch v := current.(type) {
case map[string]interface{}:
if val, ok := v[part]; ok {
current = val
} else {
return nil
}
default:
return nil
}
}
return current
}
// parseOIDCGroupsValue parses OIDC claim values into a string slice of groups
// Returns nil for missing/empty non-array values, []string{} for empty arrays
func parseOIDCGroupsValue(groupsVal interface{}) []string {
var groups []string
switch val := groupsVal.(type) {
case map[string]interface{}:
// Groups as map keys - return empty slice for empty map
groups = slices.Collect(maps.Keys(val))
case []interface{}:
// Groups as array of interfaces - always initialize as empty slice for arrays
groups = []string{}
for _, g := range val {
if groupStr, ok := g.(string); ok {
groups = append(groups, strings.TrimSpace(groupStr))
}
}
case []string:
// Groups as array of strings - always initialize as empty slice for arrays
groups = val
if groups == nil {
groups = []string{}
}
case string:
// Single group as string or comma-separated groups
// Return nil for empty strings, parsed array otherwise
if val != "" {
parts := strings.Split(val, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
groups = parts
}
// else: groups stays nil for empty string
default:
// Try to unmarshal as JSON array
if jsonBytes, err := json.Marshal(val); err == nil {
var groupsArray []string
if err := json.Unmarshal(jsonBytes, &groupsArray); err == nil {
// Initialize as empty slice if array was parsed
if groupsArray == nil {
groups = []string{}
} else {
groups = groupsArray
if v, ok := raw[u.groupsClaim]; ok {
switch val := v.(type) {
case []interface{}:
groups := make([]string, len(val))
for i, g := range val {
if s, ok := g.(string); ok {
groups[i] = strings.TrimSpace(s)
}
}
u.userInfo.Groups = groups
case string:
if val != "" {
parts := strings.Split(val, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
u.userInfo.Groups = parts
}
}
}
}
return groups
u.userInfo.Claims = raw
return nil
}
// oidcLoginHandler initiates OIDC login.
@@ -239,6 +158,7 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
logger.Errorf("failed to exchange token: %v", err)
return http.StatusInternalServerError, fmt.Errorf("failed to exchange token: %v", err)
}
fmt.Println("token", token)
rawIDToken, ok := token.Extra("id_token").(string)
// accessToken := token.AccessToken // Access token is needed for UserInfo, already in 'token'
@@ -255,7 +175,7 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// --- Attempt to process ID Token ---
if ok && rawIDToken != "" {
logger.Debugf("ID token found in token response, attempting verification. Raw ID Token: %s", rawIDToken)
logger.Debug("ID token found in token response, attempting verification.")
// Verify the ID token
// This uses the verifier initialized with the provider's JWKS endpoint and client ID
@@ -280,6 +200,7 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
claimsFromIDToken = true
}
}
logger.Debugf("Failed to verify ID token: %v", verify_err)
}
} else {
-58
View File
@@ -114,64 +114,6 @@ func TestUserInfoUnmarshaller(t *testing.T) {
Groups: []string{"admins"},
},
},
{
name: "nested single-level claim",
jsonData: `{"username":"john","custom":{"groups":["admin","users"]}}`,
groupsClaim: "custom.groups",
expected: userInfo{
Claims: map[string]interface{}{
"username": "john",
"custom": map[string]interface{}{
"groups": []interface{}{"admin","users"},
},
},
Groups: []string{"admin", "users"},
},
},
{
name: "keycloak resource_access nested structure",
jsonData: `{"sub":"user123","resource_access":{"filebrowser-client":{"roles":["admin","editor","viewer"]}}}`,
groupsClaim: "resource_access.filebrowser-client.roles",
expected: userInfo{
Claims: map[string]interface{}{
"sub": "user123",
"resource_access": map[string]interface{}{
"filebrowser-client": map[string]interface{}{
"roles": []interface{}{"admin","editor","viewer"},
},
},
},
Groups: []string{"admin", "editor", "viewer"},
},
},
{
name: "nested claim with single string value",
jsonData: `{"username":"bob","auth":{"group":"admin"}}`,
groupsClaim: "auth.group",
expected: userInfo{
Claims: map[string]interface{}{
"username": "bob",
"auth": map[string]interface{}{
"group": "admin",
},
},
Groups: []string{"admin"},
},
},
{
name: "invalid nested path returns empty groups",
jsonData: `{"username":"alice","custom":{"roles":["user"]}}`,
groupsClaim: "custom.nonexistent.roles",
expected: userInfo{
Claims: map[string]interface{}{
"username": "alice",
"custom": map[string]interface{}{
"roles": []interface{}{"user"},
},
},
Groups: nil,
},
},
}
for _, tt := range tests {
+42 -17
View File
@@ -99,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, err
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
providedPath = cleanPath
@@ -280,12 +280,14 @@ func buildOnlyOfficeDownloadURL(r *http.Request, source, path, hash, token strin
}
escapedPath := url.QueryEscape(path)
downloadURL := fmt.Sprintf("%s/api/resources/download?file=%s&auth=%s&source=%s",
strings.TrimSuffix(baseURL, "/"), escapedPath, token, url.QueryEscape(source))
downloadURL := fmt.Sprintf("%s/api/resources/download?file=%s&auth=%s",
strings.TrimSuffix(baseURL, "/"), escapedPath, token)
if hash != "" {
downloadURL = fmt.Sprintf("%s/public/api/resources/download?file=%s&auth=%s&hash=%s",
strings.TrimSuffix(baseURL, "/"), escapedPath, token, hash)
downloadURL = downloadURL + "&hash=" + hash
} else {
downloadURL = downloadURL + "&source=" + url.QueryEscape(source)
}
return downloadURL
}
@@ -352,13 +354,6 @@ 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 == "" {
@@ -369,10 +364,40 @@ 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 {
return returnOnlyOfficeError(w, r, 400, err.Error())
logger.Errorf("OnlyOffice callback: invalid path: %v", err)
return returnOnlyOfficeError(w, r, 400, "invalid path")
}
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 {
@@ -477,14 +502,14 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
}
// Check share permissions first if this is a share request
if d.share != nil {
if d.fileInfo.Hash != "" {
if !d.share.AllowModify {
logger.Errorf("OnlyOffice callback: edit permission not allowed for this share")
return returnOnlyOfficeError(w, r, 403, "edit permission not allowed for this share")
}
} else {
// Verify user has modify permissions
if !user.Permissions.Modify {
if !d.user.Permissions.Modify {
logger.Errorf("OnlyOffice callback: user %s lacks modify permissions for path=%s",
d.user.Username, path)
return returnOnlyOfficeError(w, r, 403, "user lacks modify permissions")
@@ -518,7 +543,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
_, err = files.FileInfoFaster(utils.FileOptions{
Source: source,
Path: path,
}, store.Access, user, store.Share)
}, store.Access, d.user, store.Share)
if err != nil {
logger.Errorf("OnlyOffice callback: original file no longer exists at path=%s: %v",
path, err)
@@ -533,7 +558,7 @@ func processOnlyOfficeCallback(w http.ResponseWriter, r *http.Request, d *reques
}
// Get user scope to resolve full index path for write operation
userScope, err := user.GetScopeForSourceName(source)
userScope, err := d.user.GetScopeForSourceName(source)
if err != nil {
return returnOnlyOfficeError(w, r, 403, "user scope not found")
}
+1 -3
View File
@@ -119,7 +119,6 @@ func getDirectoryPreview(r *http.Request, d *requestContext, frameIndex int) (*i
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 {
@@ -127,7 +126,6 @@ func getDirectoryPreview(r *http.Request, d *requestContext, frameIndex int) (*i
}
source = sourceInfo.Name
path = d.IndexPath + name
user = d.shareUser
}
fileInfo, err := files.FileInfoFaster(
utils.FileOptions{
@@ -135,7 +133,7 @@ func getDirectoryPreview(r *http.Request, d *requestContext, frameIndex int) (*i
Source: source,
AlbumArt: true,
Metadata: true,
}, store.Access, user, store.Share)
}, store.Access, d.user, store.Share)
if err != nil {
return nil, err
}
+36 -9
View File
@@ -4,12 +4,14 @@ 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"
@@ -243,8 +245,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")
}
sourceName := d.share.GetSourceName()
if sourceName == "" {
source, err := d.share.GetSourceName()
if err != nil {
return http.StatusNotFound, fmt.Errorf("source not available")
}
@@ -257,11 +259,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, err
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
resolvedPath := utils.JoinPathAsUnix(d.share.Path, cleanPath)
err = files.WriteFile(sourceName, resolvedPath, r.Body)
err = files.WriteFile(source, resolvedPath, r.Body)
// hide the error
if err != nil {
logger.Errorf("public put handler: error updating resource with error %v", err)
@@ -341,8 +343,8 @@ func publicPatchHandler(w http.ResponseWriter, r *http.Request, d *requestContex
}
// Get the source from the share
sourceName := d.share.GetSourceName()
if sourceName == "" {
source, err := d.share.GetSourceName()
if err != nil {
return http.StatusNotFound, fmt.Errorf("source not available")
}
@@ -368,9 +370,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 = sourceName
req.Items[i].FromSource = source
req.Items[i].FromPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].FromPath)
req.Items[i].ToSource = sourceName
req.Items[i].ToSource = source
req.Items[i].ToPath = utils.JoinPathAsUnix(d.share.Path, req.Items[i].ToPath)
}
d.Data = req
@@ -416,7 +418,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 := d.share.GetShareImagePartsHelper(isBanner)
sourceName, assetPath, err := getShareImagePartsHelper(d.share, isBanner)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid asset configuration: %v", err)
}
@@ -462,6 +464,31 @@ 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
}
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
}
// 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.
+24 -129
View File
@@ -13,7 +13,6 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
@@ -23,24 +22,9 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/go-cache/cache"
"github.com/gtsteffaniak/go-logger/logger"
)
var pauseCache = cache.NewCache[string](1 * time.Minute)
var publicPauseCache = cache.NewCache[string](1 * time.Minute)
// pauseCacheKeySep separates segments in pause cache keys (not used in source names/paths).
const pauseCacheKeySep = "\x1e"
func pauseUploadCacheKey(source, path string) string {
return source + pauseCacheKeySep + path
}
func publicPauseUploadCacheKey(shareHash, source, indexPath string) string {
return shareHash + pauseCacheKeySep + source + pauseCacheKeySep + indexPath
}
// validateMoveOperation checks if a move/rename operation is valid at the HTTP level
// It prevents moving a directory into itself or its subdirectories
func validateMoveOperation(src, dst string, isSrcDir bool) error {
@@ -77,10 +61,10 @@ 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 "When true, omit index-level extended fields (e.g. hasPreview); does not disable ffmpeg/media extraction"
// @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 "When true, run audio/video metadata extraction, subtitles, and directory media batch processing"
// @Param metadata query string false "Extract audio/video metadata if true"
// @Param checksum query string false "Optional checksum validation"
// @Success 200 {object} iteminfo.FileInfo "Resource metadata"
// @Failure 404 {object} map[string]string "Resource not found"
@@ -102,7 +86,6 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
ExtractEmbeddedSubtitles: settings.Config.Integrations.Media.ExtractEmbeddedSubtitles,
ShowHidden: d.user.ShowHidden,
SkipExtendedAttrs: skipExtendedAttrs,
ShowSharedAttr: true,
}, store.Access, d.user, store.Share)
if err != nil {
return errToStatus(err), err
@@ -272,12 +255,12 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
}
if d.share != nil {
sourceName := d.share.GetSourceName()
if sourceName == "" {
source, err := d.share.GetSourceName()
if err != nil {
return http.StatusNotFound, fmt.Errorf("source not available")
}
// get user scope path from share
userScope, err := d.shareUser.GetScopeForSourceName(sourceName)
userScope, err := d.shareUser.GetScopeForSourceName(source)
if err != nil {
response.Failed = append(response.Failed, BulkDeleteItem{
Source: item.Source,
@@ -292,7 +275,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: indexPath,
Source: sourceName,
Source: source,
ShowHidden: true,
}, store.Access, filePermUser, store.Share)
if err != nil {
@@ -300,7 +283,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
}
// Delete the file/directory
err = files.DeleteFiles(sourceName, fileInfo.RealPath, fileInfo.Type == "directory")
err = files.DeleteFiles(source, 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{
@@ -347,7 +330,7 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
// Get file info
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
FollowSymlinks: true,
Path: item.Path,
Path: idx.MakeIndexPath(item.Path, false),
Source: item.Source,
ShowHidden: true,
}, store.Access, filePermUser, store.Share)
@@ -383,74 +366,6 @@ func resourceBulkDeleteHandler(w http.ResponseWriter, r *http.Request, d *reques
return renderJSON(w, r, response, statusCode)
}
// resourcePauseHandler registers a graceful pause for an in-flight chunked upload.
// Call immediately before aborting the chunk POST so the server keeps partial data.
// @Summary Register graceful chunked upload pause
// @Description Sets a short-lived server flag for the given source and path. When the active chunk POST then fails (e.g. client abort), the server trims the incomplete chunk but keeps the temp partial file for resume.
// @Tags Resources
// @Accept json
// @Produce json
// @Param path query string true "Destination path (same as upload)"
// @Param source query string true "Source name (same as upload)"
// @Success 200 "Pause registered"
// @Failure 400 {object} map[string]string "Bad request"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Source not found"
// @Router /api/resources/pause [post]
func resourcePauseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if d.share != nil {
return http.StatusBadRequest, fmt.Errorf("use public pause endpoint for share uploads")
}
if !d.user.Permissions.Create {
return http.StatusForbidden, fmt.Errorf("user is not allowed to pause uploads")
}
path := r.URL.Query().Get("path")
source := r.URL.Query().Get("source")
cleanPath, err := utils.SanitizeUserPath(path)
if err != nil {
return http.StatusBadRequest, err
}
path = cleanPath
idx := indexing.GetIndex(source)
if idx == nil {
logger.Debugf("source %s not found", source)
return http.StatusNotFound, fmt.Errorf("source %s not found", source)
}
if _, err := d.user.GetScopeForSourceName(source); err != nil {
return http.StatusForbidden, err
}
if !store.Access.Permitted(idx.Path, path, d.user.Username) {
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
}
pauseCache.Set(pauseUploadCacheKey(source, path), "1")
return http.StatusOK, nil
}
// publicPauseHandler registers a graceful pause for a public share chunked upload.
// @Summary Register graceful chunked upload pause (public share)
// @Description Same as authenticated pause, for upload shares. Uses hash and path query parameters like other public resource APIs.
// @Tags Shares
// @Accept json
// @Produce json
// @Param hash query string true "Share hash"
// @Param path query string true "Path within the share (same as upload)"
// @Success 200 "Pause registered"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 404 {object} map[string]string "Source not found"
// @Router /public/api/resources/pause [post]
func publicPauseHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
if d.share.ShareType != "upload" && !d.share.AllowCreate {
return http.StatusForbidden, fmt.Errorf("pausing uploads is not allowed for this share")
}
src, ok := config.Server.SourceMap[d.share.Source]
if !ok {
return http.StatusNotFound, fmt.Errorf("source not found")
}
sourceName := src.Name
publicPauseCache.Set(publicPauseUploadCacheKey(d.share.Hash, sourceName, d.IndexPath), "1")
return http.StatusOK, nil
}
// resourcePostHandler creates or uploads a new resource.
// @Summary Create or upload a resource
// @Description Creates a new resource or uploads a file at the specified path. Supports file uploads and directory creation.
@@ -474,7 +389,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, err
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
path = cleanPath
@@ -590,7 +505,13 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
// Create a unique name for the temporary file to avoid collisions.
hasher := md5.New()
hasher.Write([]byte(realPath))
tempFilePath := fmt.Sprintf("%s.%s.uploading.tmp", realPath, hex.EncodeToString(hasher.Sum(nil)))
uploadID := hex.EncodeToString(hasher.Sum(nil))
tempFilePath := filepath.Join(settings.Config.Server.CacheDir, "uploads", uploadID)
if err = os.MkdirAll(filepath.Dir(tempFilePath), fileutils.PermDir); err != nil {
logger.Debugf("could not create temp dir: %v", err)
return http.StatusInternalServerError, fmt.Errorf("could not create temp dir: %v", err)
}
// Create or open the temporary file
var outFile *os.File
outFile, err = os.OpenFile(tempFilePath, os.O_CREATE|os.O_WRONLY, fileutils.PermFile)
@@ -612,31 +533,6 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
chunkSize, err = io.Copy(outFile, r.Body)
if err != nil {
logger.Debugf("could not write chunk to temp file: %v", err)
if truncErr := outFile.Truncate(offset); truncErr != nil {
logger.Debugf("could not truncate temp file after failed chunk (offset=%d): %v", offset, truncErr)
}
_ = outFile.Sync()
gracefulPause := false
if d.share != nil {
k := publicPauseUploadCacheKey(d.share.Hash, source, path)
if _, ok := publicPauseCache.Get(k); ok {
gracefulPause = true
publicPauseCache.Delete(k)
}
} else {
k := pauseUploadCacheKey(source, path)
if _, ok := pauseCache.Get(k); ok {
gracefulPause = true
pauseCache.Delete(k)
}
}
if gracefulPause {
logger.Debugf("chunk upload ended after graceful pause; keeping partial file (source=%s path=%s)", source, path)
return 499, nil
}
_ = os.Remove(tempFilePath)
return http.StatusInternalServerError, fmt.Errorf("could not write chunk to temp file: %v", err)
}
// check if the file is complete
@@ -692,15 +588,21 @@ 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, err
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", 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 {
@@ -711,14 +613,7 @@ func resourcePutHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return http.StatusForbidden, fmt.Errorf("access denied to path %s", path)
}
// 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)
err = files.WriteFile(source, utils.JoinPathAsUnix(userScope, path), r.Body)
return errToStatus(err), err
}
+14 -55
View File
@@ -2,10 +2,8 @@ package http
import (
"fmt"
"math"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
@@ -14,14 +12,12 @@ import (
)
type searchOptions struct {
query string
sources []string
searchScope string
combinedPath map[string]string // source -> combinedPath
sessionId string
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.
query string
sources []string
searchScope string
combinedPath map[string]string // source -> combinedPath
sessionId string
largest bool
}
// searchHandler handles search requests for files based on the provided query.
@@ -43,8 +39,6 @@ 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):
//
@@ -79,8 +73,6 @@ type searchOptions struct {
// @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"
@@ -91,11 +83,6 @@ func searchHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
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
@@ -104,10 +91,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, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix)
response = index.Search(searchOptions.query, combinedPath, searchOptions.sessionId, searchOptions.largest, indexing.DefaultSearchResults)
} else {
// Multiple sources - use the new SearchMultiSources function
response = indexing.SearchMultiSources(searchOptions.query, searchOptions.sources, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, searchSize, searchOptions.olderThanUnix, searchOptions.newerThanUnix)
response = indexing.SearchMultiSources(searchOptions.query, searchOptions.sources, searchOptions.combinedPath, searchOptions.sessionId, searchOptions.largest, indexing.DefaultSearchResults)
}
// Filter out items that are not permitted according to access rules and trim user scope from paths
@@ -135,17 +122,6 @@ 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
}
if scope == "" {
scope = "/"
}
cleanScope, err := utils.SanitizeUserPath(scope)
if err != nil {
return nil, fmt.Errorf("invalid scope: %v", err)
@@ -195,28 +171,11 @@ func prepSearchOptions(r *http.Request, d *requestContext) (*searchOptions, erro
}
return &searchOptions{
query: query,
sources: sources,
searchScope: searchScope,
combinedPath: combinedPathMap,
sessionId: sessionId,
largest: largest,
olderThanUnix: olderThanUnix,
newerThanUnix: newerThanUnix,
query: query,
sources: sources,
searchScope: searchScope,
combinedPath: combinedPathMap,
sessionId: sessionId,
largest: largest,
}, 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
}
+18 -45
View File
@@ -33,14 +33,13 @@ type ShareResponse struct {
}
// convertToFrontendShareResponse converts shares to response format with usernames
func convertToFrontendShareResponse(r *http.Request, shares []*share.Link, user *users.User) ([]*ShareResponse, error) {
func convertToFrontendShareResponse(r *http.Request, shares []*share.Link) ([]*ShareResponse, error) {
responses := make([]*ShareResponse, 0, len(shares))
for _, s := range shares {
// Look for the username of the user who created the share
creator, err := store.Users.Get(s.UserID)
user, err := store.Users.Get(s.UserID)
username := ""
if err == nil {
username = creator.Username
username = user.Username
}
// Get source info to convert path to name for frontend
@@ -63,9 +62,6 @@ func convertToFrontendShareResponse(r *http.Request, shares []*share.Link, user
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,
@@ -98,7 +94,7 @@ func shareListHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusInternalServerError, err
}
shares = utils.NonNilSlice(shares)
sharesWithUsernames, err := convertToFrontendShareResponse(r, shares, d.user)
sharesWithUsernames, err := convertToFrontendShareResponse(r, shares)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -138,7 +134,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, d.user)
sharesWithUsernames, err := convertToFrontendShareResponse(r, s)
if err != nil {
return http.StatusInternalServerError, err
}
@@ -163,16 +159,7 @@ func shareDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return http.StatusBadRequest, nil
}
// 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)
err := store.Share.Delete(hash)
if err != nil {
return errToStatus(err), err
}
@@ -206,16 +193,8 @@ 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
}
@@ -227,7 +206,7 @@ func sharePatchHandler(w http.ResponseWriter, r *http.Request, d *requestContext
}
// Convert to response format
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{updatedShare}, d.user)
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{updatedShare})
if err != nil {
return http.StatusInternalServerError, err
}
@@ -380,7 +359,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, err
return http.StatusBadRequest, fmt.Errorf("invalid path: %v", err)
}
body.Path = utils.JoinPathAsUnix(userscope, cleanPath)
@@ -407,7 +386,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}, d.user)
sharesWithUsernames, err := convertToFrontendShareResponse(r, []*share.Link{s})
if err != nil {
return http.StatusInternalServerError, err
}
@@ -629,11 +608,16 @@ func shareInfoHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
}
commonShare := shareLink.CommonShare
commonShare.ShareURL = getShareURL(r, hash, false, "")
commonShare.BannerUrl = shareLink.BannerURL()
commonShare.FaviconUrl = shareLink.FaviconURL()
_, _, 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.Source = ""
commonShare.Path = ""
commonShare.DownloadURL = ""
commonShare.SidebarLinks = []users.SidebarLink{}
for _, link := range shareLink.SidebarLinks {
if link.Category == "download" && shareLink.ShareType == "upload" {
@@ -642,17 +626,6 @@ 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)
}
+29 -24
View File
@@ -3,12 +3,12 @@ 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"
@@ -25,18 +25,10 @@ type TemplateRenderer struct {
// Render renders a template document with headers and data
func (t *TemplateRenderer) Render(w http.ResponseWriter, name string, data interface{}) error {
templates := t.templates
// If in dev mode, reload templates on every render by creating a fresh instance
// If in dev mode, reload templates on every render.
if t.devMode {
var err error
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")
t.templates, err = t.templates.ParseFS(assetFs, "public/index.html")
if err != nil {
return fmt.Errorf("error reloading template: %w", err)
}
@@ -47,7 +39,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 templates.ExecuteTemplate(w, name, data)
return t.templates.ExecuteTemplate(w, name, data)
}
func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestContext, file, contentType string) (int, error) {
@@ -119,10 +111,21 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
if d.share.DisableSidebar {
disableSidebar = true
}
banner = d.share.BannerURL()
shareFavicon := d.share.FaviconURL()
if shareFavicon != "" {
favicon = shareFavicon
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)
}
}
shareHash = d.share.Hash
}
@@ -177,8 +180,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
data["htmlVars"] = map[string]interface{}{
"title": title,
"customCSS": template.CSS(config.Frontend.Styling.CustomCSSRaw),
"userSelectedTheme": template.CSS(userSelectedTheme),
"customCSS": config.Frontend.Styling.CustomCSSRaw,
"userSelectedTheme": userSelectedTheme,
"lightBackground": config.Frontend.Styling.LightBackground,
"darkBackground": config.Frontend.Styling.DarkBackground,
"staticURL": staticURL,
@@ -189,7 +192,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": template.CSS(loadingSpinnersCSS),
"loadingSpinnersCSS": loadingSpinnersCSS,
"banner": banner,
"image": ogImage,
"url": fullURL,
@@ -214,7 +217,6 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
"externalUrl": strings.TrimSuffix(config.Server.ExternalUrl, "/"),
"onlyOfficeUrl": settings.Config.Integrations.OnlyOffice.Url,
"oidcAvailable": config.Auth.Methods.OidcAuth.Enabled,
"jwtAvailable": config.Auth.Methods.JwtAuth.Enabled,
"proxyAvailable": config.Auth.Methods.ProxyAuth.Enabled,
"passwordAvailable": config.Auth.Methods.PasswordAuth.Enabled,
"ldapAvailable": config.Auth.Methods.LdapAuth.Enabled,
@@ -239,8 +241,8 @@ func handleWithStaticData(w http.ResponseWriter, r *http.Request, d *requestCont
return http.StatusInternalServerError, fmt.Errorf("error marshaling globalVars: %w", err)
}
// Mark as JS for html/template to avoid escaping
data["globalVars"] = template.JS(globalVarsJSON)
// Replace with JSON strings for direct template usage
data["globalVars"] = string(globalVarsJSON)
// Render the template with global variables
if err := templateRenderer.Render(w, file, data); err != nil {
@@ -295,8 +297,11 @@ 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 := strings.TrimPrefix(r.URL.Path, "/public")
path = strings.TrimPrefix(path, "/static/")
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
// Handle special routes that need path mapping
var assetPath string
switch path {
+1 -54
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"slices"
"sort"
"strconv"
@@ -13,7 +12,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/storage"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
@@ -257,59 +255,16 @@ func usersPostHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
return http.StatusCreated, nil
}
// passwordUpdateRequested reports whether the client intends to change the target user's password
// (non-empty new password and "password" in the which list). This matches storage parseFields behavior.
func passwordUpdateRequested(req *UserRequest) bool {
if req.User.Password == "" {
return false
}
for _, w := range req.Which {
if strings.EqualFold(w, "password") {
return true
}
}
return false
}
// verifyActorPasswordForUserPasswordChange ensures the authenticated actor re-proves their password
// when changing another user's or their own password via PUT. Requires URL-encoded X-Password (same as login).
func verifyActorPasswordForUserPasswordChange(r *http.Request, d *requestContext, target *users.User) (int, error) {
if d.user.LoginMethod != users.LoginMethodPassword || target.LoginMethod != users.LoginMethodPassword {
return http.StatusForbidden, fmt.Errorf("password can only be changed when both accounts use password login")
}
encoded := r.Header.Get("X-Password")
if encoded == "" {
return http.StatusUnauthorized, fmt.Errorf("X-Password header is required to confirm your password")
}
plain, err := url.QueryUnescape(encoded)
if err != nil {
return http.StatusBadRequest, fmt.Errorf("invalid password encoding")
}
if plain == "" {
return http.StatusUnauthorized, fmt.Errorf("X-Password header is required to confirm your password")
}
actor, err := store.Users.Get(d.user.ID)
if err != nil {
return http.StatusInternalServerError, err
}
if err := utils.CheckPwd(plain, actor.Password); err != nil {
return http.StatusUnauthorized, fmt.Errorf("invalid password")
}
return 0, nil
}
// userPutHandler updates an existing user's details.
// @Summary Update a user's details
// @Description Updates the details of a user identified by ID. When updating the target user's password the actor must send their current password in the X-Password header
// @Description Updates the details of a user identified by ID.
// @Tags Users
// @Accept json
// @Param id query string false "user ID to update"
// @Param id query string false "usename to update"
// @Param X-Password header string false "Actor's current password (URL-encoded); required when changing a password user's password"
// @Param data body users.User true "User data to update"
// @Success 204 "No Content - User updated successfully"
// @Failure 400 {object} map[string]string "Bad Request"
// @Failure 401 {object} map[string]string "Unauthorized - invalid or missing actor password for password change"
// @Failure 403 {object} map[string]string "Forbidden"
// @Failure 500 {object} map[string]string "Internal Server Error"
// @Router /api/users [put]
@@ -361,14 +316,6 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
return http.StatusBadRequest, fmt.Errorf("failed to get user: %w", err)
}
if passwordUpdateRequested(&req) {
var status int
status, err = verifyActorPasswordForUserPasswordChange(r, d, oldUser)
if err != nil {
return status, err
}
}
err = store.Users.Update(&req.User, d.user.Permissions.Admin, req.Which...)
if err != nil {
return http.StatusBadRequest, err
+66 -198
View File
@@ -2,7 +2,6 @@ package http
import (
"context"
"errors"
"fmt"
"net/http"
"os"
@@ -14,7 +13,6 @@ import (
"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"
@@ -45,22 +43,20 @@ type filteredFileSystem struct {
fs webdav.FileSystem
source string
user *users.User
userScope string
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) {
// getCachedFileInfo retrieves cached FileInfoFaster result, or calls FileInfoFaster and caches it
func (ffs *filteredFileSystem) getCachedFileInfo(permissionPath 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
// Check cache - first try exact match
cacheKey := permissionPath
if expand {
cacheKey += ":expand"
}
@@ -71,33 +67,34 @@ func (ffs *filteredFileSystem) getFileInfo(requestPath string, expand bool) (*it
// 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"
expandedKey := permissionPath + ":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
// Call FileInfoFaster
fileInfo, err := files.FileInfoFaster(utils.FileOptions{
Path: requestPath,
Path: permissionPath,
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
// Cache result
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
dirPath := permissionPath
if !strings.HasSuffix(dirPath, "/") && dirPath != "" {
dirPath += "/"
}
// Cache each file and folder from the directory listing
for _, file := range fileInfo.Files {
filePath := utils.JoinPathAsUnix(dirPath, file.Name)
@@ -124,83 +121,12 @@ func (ffs *filteredFileSystem) getFileInfo(requestPath string, expand bool) (*it
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
fs *filteredFileSystem
dirPath string // The directory path this file represents (relative to scope root)
isDir bool // Whether this is a directory
}
func (ff *filteredFile) Readdir(count int) ([]os.FileInfo, error) {
@@ -209,23 +135,21 @@ func (ff *filteredFile) Readdir(count int) ([]os.FileInfo, error) {
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
// For directories, use FileInfoFaster to get filtered contents
// Convert dirPath (relative to scope root) to permission path (relative to userscope)
// Handle root path specially
var permissionPath string
if ff.dirPath == "" || ff.dirPath == "/" {
permissionPath = ff.fs.userscope
} else {
permissionPath = utils.JoinPathAsUnix(ff.fs.userscope, ff.dirPath)
}
// Use cached FileInfoFaster result if available
fileInfo, err := ff.fs.getCachedFileInfo(permissionPath, true)
if err != nil {
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))
@@ -251,34 +175,11 @@ func (ffs *filteredFileSystem) Mkdir(ctx context.Context, name string, perm os.F
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)
func (ffs *filteredFileSystem) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
file, err := ffs.fs.OpenFile(ctx, name, flag, perm)
if err != nil {
return nil, err
}
@@ -290,77 +191,51 @@ func (ffs *filteredFileSystem) OpenFile(ctx context.Context, requestPath string,
}
// 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(),
File: file,
fs: ffs,
dirPath: name,
isDir: stat.IsDir(),
}, nil
}
func (ffs *filteredFileSystem) RemoveAll(ctx context.Context, requestPath string) error {
func (ffs *filteredFileSystem) RemoveAll(ctx context.Context, name 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)
return ffs.fs.RemoveAll(ctx, name)
}
func (ffs *filteredFileSystem) Rename(ctx context.Context, oldPath, newPath string) error {
func (ffs *filteredFileSystem) Rename(ctx context.Context, oldName, newName 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)
return ffs.fs.Rename(ctx, oldName, newName)
}
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)
func (ffs *filteredFileSystem) Stat(ctx context.Context, name string) (os.FileInfo, error) {
// Root path "/" should always be allowed
if name == "" || name == "/" {
return ffs.fs.Stat(ctx, name)
}
// Handle specific error cases
if errors.Is(err, commonerrors.ErrAccessDenied) || errors.Is(err, commonerrors.ErrNotViewable) {
return nil, os.ErrPermission
// Check permission before stat using cached result if available
permissionPath := utils.JoinPathAsUnix(ffs.userscope, name)
_, err := ffs.getCachedFileInfo(permissionPath, false)
if err != nil {
return nil, err
}
// 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)
return ffs.fs.Stat(ctx, name)
}
// webDAVHandler serves WebDAV requests.
func webDAVHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
path := r.PathValue("path")
source := r.PathValue("source")
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if !d.user.Permissions.Download {
return http.StatusForbidden, fmt.Errorf("download permission required")
}
@@ -371,19 +246,15 @@ func webDAVHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
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{
logger.Debugf("webdav: method=%s, request=%s, source=%s, path=%s", r.Method, r.URL.Path, source, path)
indexPath, userScope, err := files.CheckPermissions(utils.FileOptions{
FollowSymlinks: false,
Path: requestPath,
Path: path,
Source: source,
ShowHidden: d.user.ShowHidden,
}, store.Access, d.user)
if err != nil {
logger.Debugf("error checking file permissions for path %s: %v", path, err)
return http.StatusForbidden, err
}
@@ -391,25 +262,28 @@ func webDAVHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
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 {
logger.Debugf("error resolving scope path: %v", err)
return http.StatusNotFound, err
}
// Construct the WebDAV prefix from BaseURL
webDavPrefix := config.Server.BaseURL + "dav"
prefix := webDavPrefix + "/" + source
logger.Debugf("webdav: virtual_path=%s, indexPath=%s, scope_path=%s", path, indexPath, scopePath)
// Wrap the filesystem to filter directory listings using FileInfoFaster
// We pass requestPath (without scope) to FileInfoFaster, which applies scope internally
// This prevents items the user can't access from appearing in listings,
// which stops clients from repeatedly trying to access them
filteredFS := &filteredFileSystem{
fs: webdav.Dir(scopePath),
source: source,
user: d.user,
userScope: userScope,
userscope: userScope,
}
wd := &webdav.Handler{
@@ -417,13 +291,7 @@ func webDAVHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (i
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
}
if err != nil && !strings.Contains(err.Error(), "no such file or directory") {
logger.Errorf("webdav handler failed on path %s: %s", req.URL.Path, err)
}
},
-803
View File
@@ -1,803 +0,0 @@
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()
}
-147
View File
@@ -5,7 +5,6 @@ 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
@@ -624,149 +623,3 @@ 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))
}
}
+4
View File
@@ -210,6 +210,10 @@ func TestProcessDirectoryItem(t *testing.T) {
return
}
if itemInfo == nil {
t.Fatalf("expected non-nil itemInfo")
}
if size != tt.expectSize {
t.Errorf("expected size=%d, got %d", tt.expectSize, size)
}
+51 -57
View File
@@ -487,14 +487,7 @@ 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) {
// 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)
}
isViewable = idx.IsViewable(ctx.IsDir, ctx.IndexPath, ctx.IsSymlink)
shouldSkip := idx.ShouldSkip(ctx.IsDir, ctx.IndexPath, ctx.IsHidden, ctx.IsSymlink, isRoutineScan)
isIndexable = !shouldSkip
return isViewable, isIndexable
@@ -510,11 +503,12 @@ func (idx *Index) GetFileInfo(req FileInfoRequest) (*iteminfo.FileInfo, error) {
return nil, err
}
// 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
// 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
}
// 3. Return appropriate info
@@ -659,16 +653,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, hidden)
isViewable := idx.IsViewable(dirInfo.IsDir(), indexPath, isSymlink)
isSkipped := idx.ShouldSkip(dirInfo.IsDir(), indexPath, hidden, isSymlink, false)
// Deny access if not viewable AND skipped
if opts.CheckViewable {
if !isViewable {
return nil, errors.ErrNotViewable
return nil, errors.ErrNotIndexed
}
} else if isSkipped {
return nil, errors.ErrNotViewable
return nil, errors.ErrNotIndexed
}
realSize, _ := idx.handleFile(dirInfo, indexPath, realPath, false, nil)
@@ -933,7 +927,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, hidden)
isViewable := idx.IsViewable(isDir, fullCombined, isSymlink)
isSkipped := idx.ShouldSkip(isDir, fullCombined, hidden, isSymlink, opts.IsRoutineScan)
if opts.CheckViewable {
shouldSkip = !isViewable && isSkipped
@@ -1341,41 +1335,53 @@ func setFilePreviewFlags(fileInfo *iteminfo.ItemInfo, realPath string) {
}
// IsViewable checks if a path is viewable (allows FS access)
// 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 {
func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool) bool {
if indexPath == "/" {
return true
}
rules := idx.Config.ResolvedRules
// Check if hidden and should be blocked
if isHidden && rules.IgnoreAllHidden {
return false
}
// Check symlinks
if isSymlink && rules.IgnoreAllSymlinks {
if isSymlink && idx.Config.ResolvedRules.IgnoreAllSymlinks {
return false
}
rules := idx.Config.ResolvedRules
baseName := filepath.Base(indexPath)
if indexPath == "/" {
baseName = filepath.Base(idx.Path)
}
if isDir {
// Check folder rules
// Check folder rules - return false if explicitly set to non-viewable
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,6 +1393,11 @@ func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool, isHid
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
@@ -1397,44 +1408,27 @@ func (idx *Index) IsViewable(isDir bool, indexPath string, isSymlink bool, isHid
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
}
+15 -64
View File
@@ -24,7 +24,6 @@ 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
@@ -42,18 +41,6 @@ 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)
@@ -139,20 +126,14 @@ 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)
@@ -166,10 +147,8 @@ func (s *Scanner) runFullScanRoot() {
}
// Reset counters and initialize state for full scan
s.withStatsLock(func() {
s.numDirs = 0
s.numFiles = 0
})
s.numDirs = 0
s.numFiles = 0
s.scanStartTime = time.Now().Unix()
s.idx.mu.Lock()
if s.idx.scanSessionStartTime == 0 {
@@ -182,9 +161,7 @@ func (s *Scanner) runFullScanRoot() {
batchSize := s.idx.db.BatchSize
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
s.withStatsLock(func() {
s.filesChanged = false
})
s.filesChanged = false
_, _, err := s.idx.indexDirectory("/", config, s)
if err != nil {
@@ -207,9 +184,7 @@ func (s *Scanner) runFullScanRoot() {
func (s *Scanner) runQuickScanRoot() {
s.scanStartTime = time.Now().Unix()
s.withStatsLock(func() {
s.filesChanged = false
})
s.filesChanged = false
// Get root-level folders from folderSizes map
s.idx.folderSizesMu.RLock()
@@ -253,21 +228,15 @@ func (s *Scanner) runChildScan(quick bool) {
if quick {
// Quick scan: iterate folderSizes, check modtimes only (257x faster!)
s.runQuickScanChild()
s.withStatsLock(func() {
s.quickScanTime = int(time.Since(startTime).Seconds())
})
s.quickScanTime = int(time.Since(startTime).Seconds())
} else {
// Full scan: walk filesystem recursively, read all directory contents
s.runFullScanChild()
s.withStatsLock(func() {
s.fullScanTime = int(time.Since(startTime).Seconds())
})
s.fullScanTime = int(time.Since(startTime).Seconds())
s.updateComplexity()
}
s.withStatsLock(func() {
s.lastScanned = time.Now()
})
s.lastScanned = time.Now()
}
func (s *Scanner) runFullScanChild() {
@@ -277,10 +246,8 @@ func (s *Scanner) runFullScanChild() {
}
// Reset counters and initialize state for full scan
s.withStatsLock(func() {
s.numDirs = 0
s.numFiles = 0
})
s.numDirs = 0
s.numFiles = 0
s.scanStartTime = time.Now().Unix()
s.idx.mu.Lock()
if s.idx.scanSessionStartTime == 0 {
@@ -290,9 +257,7 @@ func (s *Scanner) runFullScanChild() {
s.processedInodes = make(map[uint64]struct{})
s.foundHardLinks = make(map[string]uint64)
s.withStatsLock(func() {
s.filesChanged = false
})
s.filesChanged = false
batchSize := s.idx.db.BatchSize
s.batchItems = make([]*iteminfo.FileInfo, 0, batchSize)
@@ -332,9 +297,7 @@ func (s *Scanner) runFullScanChild() {
func (s *Scanner) runQuickScanChild() {
s.scanStartTime = time.Now().Unix()
s.withStatsLock(func() {
s.filesChanged = false
})
s.filesChanged = false
// Get folders in this scanner's scope from folderSizes map
s.idx.folderSizesMu.RLock()
@@ -488,10 +451,7 @@ func (s *Scanner) getTopLevelDirs() []string {
// calculateSleepTime determines how long to wait before the next scan
func (s *Scanner) calculateSleepTime() time.Duration {
var sleepTime time.Duration
s.withStatsRLock(func() {
sleepTime = scanSchedule[s.currentSchedule] + s.smartModifier
})
sleepTime := scanSchedule[s.currentSchedule] + s.smartModifier
if s.idx.Config.IndexingInterval > 0 {
sleepTime = time.Duration(s.idx.Config.IndexingInterval) * time.Minute
}
@@ -501,8 +461,6 @@ 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
@@ -548,9 +506,6 @@ 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
@@ -609,10 +564,8 @@ func (s *Scanner) syncStatsWithDB() {
return
}
s.withStatsLock(func() {
s.numDirs = dirs
s.numFiles = files
})
s.numDirs = dirs
s.numFiles = files
}
// checkFolderModtime checks if a folder's modtime changed since last scan
@@ -637,9 +590,7 @@ func (s *Scanner) checkFolderModtime(folderPath string) (bool, error) {
if modtimeChanged {
// Modtime changed - read directory contents and update database
s.withStatsLock(func() {
s.filesChanged = true
})
s.filesChanged = true
dir, err := os.Open(realPath)
if err != nil {
+27 -40
View File
@@ -116,9 +116,7 @@ func (idx *Index) markFilesChanged() {
idx.mu.RUnlock()
if exists {
scanner.withStatsLock(func() {
scanner.filesChanged = true
})
scanner.filesChanged = true
}
}
@@ -147,9 +145,7 @@ func (idx *Index) incrementScannerDirsUnlocked() {
}
scanner, exists := idx.scanners[activePath]
if exists {
scanner.withStatsLock(func() {
scanner.numDirs++
})
scanner.numDirs++
}
}
@@ -166,9 +162,7 @@ func (idx *Index) incrementScannerFiles() {
idx.mu.RUnlock()
if exists {
scanner.withStatsLock(func() {
scanner.numFiles++
})
scanner.numFiles++
}
}
@@ -258,15 +252,13 @@ func (idx *Index) setupMultiScanner(isNewDb bool) {
rootScanner := idx.createScanner("/")
if persistedScanners != nil {
if rootInfo, ok := persistedScanners["/"]; ok {
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
})
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()
@@ -284,15 +276,13 @@ 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.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
})
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
}
}
@@ -328,25 +318,22 @@ 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,
"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(),
}
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
+1 -5
View File
@@ -128,11 +128,7 @@ func TestMultiScannerMutex(t *testing.T) {
// Verify markFilesChanged works
idx.markFilesChanged()
var changed bool
scanner2.withStatsRLock(func() {
changed = scanner2.filesChanged
})
if !changed {
if !scanner2.filesChanged {
t.Error("Expected scanner2.filesChanged=true")
}
-1
View File
@@ -282,7 +282,6 @@ var model3DMimeType = map[string]string{
".gltf": "model/gltf+json",
".glb": "model/gltf-binary",
".obj": "model/obj",
".fbx": "model/fbx",
".stl": "model/stl",
".ply": "model/ply",
".dae": "model/vnd.collada+xml",
+1
View File
@@ -56,5 +56,6 @@ type ExtendedFileInfo struct {
OnlyOfficeId string `json:"onlyOfficeId,omitempty"` // id for onlyoffice files
Source string `json:"source,omitempty"` // associated index source for the file
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
HasMetadata bool `json:"hasMetadata"` // whether the file or folder has metadata
RealPath string `json:"-"`
}
-7
View File
@@ -375,13 +375,6 @@ func (fi ItemInfo) ContainsSearchTerm(searchTerm string, options SearchOptions)
}
}
if options.ModifiedNewerThan > 0 && fi.ModTime.Unix() < options.ModifiedNewerThan {
return false
}
if options.ModifiedOlderThan > 0 && fi.ModTime.Unix() >= options.ModifiedOlderThan {
return false
}
return true
}
+1 -31
View File
@@ -3,7 +3,6 @@ package iteminfo
import (
"regexp"
"strings"
"time"
)
var typeRegexp = regexp.MustCompile(`type:(\S+)`)
@@ -12,11 +11,7 @@ type SearchOptions struct {
Conditions map[string]bool
LargerThan int
SmallerThan int
// ModifiedOlderThan is Unix seconds (UTC start of day). If > 0, item must have ModTime strictly before this instant.
ModifiedOlderThan int64
// ModifiedNewerThan is Unix seconds (UTC start of day). If > 0, item must have ModTime >= this instant.
ModifiedNewerThan int64
Terms []string
Terms []string
}
func ParseSearch(value string) SearchOptions {
@@ -66,18 +61,6 @@ func ParseSearch(value string) SearchOptions {
size := strings.TrimPrefix(filter, "smallerThan=")
opts.SmallerThan = UpdateSize(size)
}
if strings.HasPrefix(filter, "olderThan=") {
dateStr := strings.TrimPrefix(filter, "olderThan=")
if ts, ok := parseSearchDateUTCStart(dateStr); ok {
opts.ModifiedOlderThan = ts
}
}
if strings.HasPrefix(filter, "newerThan=") {
dateStr := strings.TrimPrefix(filter, "newerThan=")
if ts, ok := parseSearchDateUTCStart(dateStr); ok {
opts.ModifiedNewerThan = ts
}
}
}
if len(types) > 0 {
@@ -102,16 +85,3 @@ func ParseSearch(value string) SearchOptions {
opts.Terms = strings.Split(value, "|")
return opts
}
// parseSearchDateUTCStart parses YYYY-MM-DD as midnight UTC; used for search date filters.
func parseSearchDateUTCStart(s string) (int64, bool) {
s = strings.TrimSpace(s)
if len(s) != 10 {
return 0, false
}
t, err := time.ParseInLocation("2006-01-02", s, time.UTC)
if err != nil {
return 0, false
}
return t.Unix(), true
}
+27 -19
View File
@@ -27,8 +27,6 @@ func (i *ExtendedFileInfo) GetSubtitles(parentInfo *FileInfo) {
if !strings.HasPrefix(i.Type, "video") {
return
}
// could be myvideo.mov
ext := filepath.Ext(i.Name)
baseWithoutExt := strings.TrimSuffix(i.Name, ext)
@@ -36,29 +34,25 @@ func (i *ExtendedFileInfo) GetSubtitles(parentInfo *FileInfo) {
var externalSubs []utils.SubtitleTrack
if parentInfo != nil && parentInfo.Files != nil {
for _, f := range parentInfo.Files {
// handle case myvideo.srt
fileExt := filepath.Ext(f.Name)
fileBaseWithoutExt := strings.TrimSuffix(f.Name, fileExt)
// handle case myvideo.en.srt
langExt := filepath.Ext(fileBaseWithoutExt)
fileBaseWithoutExt2 := strings.TrimSuffix(fileBaseWithoutExt, langExt)
matches := fileBaseWithoutExt == baseWithoutExt || fileBaseWithoutExt2 == baseWithoutExt
fileBase := strings.TrimSuffix(f.Name, fileExt)
// Check if this file has the same base name and a subtitle extension
if matches && SubtitleExts[strings.ToLower(fileExt)] {
if fileBase == baseWithoutExt && SubtitleExts[strings.ToLower(fileExt)] {
track := utils.SubtitleTrack{
Name: f.Name,
Embedded: false,
}
// Try to infer language from filename patterns like "video.en.srt"
parts := strings.Split(fileBaseWithoutExt, ".")
parts := strings.Split(fileBase, ".")
if len(parts) > 1 {
lastPart := parts[len(parts)-1]
if len(lastPart) == 2 || len(lastPart) == 3 {
track.Language = lastPart
}
}
externalSubs = append(externalSubs, track)
}
}
@@ -106,15 +100,29 @@ func (info *FileInfo) SortItems() {
}
// ResolveSymlinks resolves symlinks in the given path and returns
// Uses Go's filepath.EvalSymlinks which properly detects circular symlinks.
// the final resolved path, whether it's a directory (considering bundle logic), and any error.
func ResolveSymlinks(path string) (string, bool, error) {
resolvedPath, err := filepath.EvalSymlinks(path)
if err != nil {
return path, false, fmt.Errorf("could not resolve symlinks for %s: %v", path, err)
for {
// Get the file info using os.Lstat to handle symlinks
info, err := os.Lstat(path)
if err != nil {
return path, false, fmt.Errorf("could not stat path: %s, %v", path, err)
}
// Check if the path is a symlink
if info.Mode()&os.ModeSymlink != 0 {
// Read the symlink target
target, err := os.Readlink(path)
if err != nil {
return path, false, fmt.Errorf("could not read symlink: %s, %v", path, err)
}
// Resolve the symlink's target relative to its directory
path = filepath.Join(filepath.Dir(path), target)
} else {
// Not a symlink, check with bundle-aware directory logic
isDir := IsDirectory(info)
return path, isDir, nil
}
}
info, err := os.Lstat(resolvedPath)
if err != nil {
return resolvedPath, false, fmt.Errorf("could not stat resolved path %s: %v", resolvedPath, err)
}
return resolvedPath, IsDirectory(info), nil
}
+14 -18
View File
@@ -289,9 +289,7 @@ func (idx *Index) IterateFiles(fn func(path, name string, size, modTime int64))
}
func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, error) {
indexesMutex.Lock()
idx, ok := indexes[sourceName]
indexesMutex.Unlock()
if !ok {
return ReducedIndex{}, fmt.Errorf("index %s not found", sourceName)
}
@@ -316,7 +314,7 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
}
idx.SetUsage(totalBytes)
// Also fetch OS-reported partition used space (total - free)
usedAlt, err := fileutils.GetPartitionUsed(sourcePath)
if err != nil {
@@ -326,7 +324,7 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
idx.mu.Lock()
idx.UsedAlt = usedAlt
idx.mu.Unlock()
utils.DiskUsageCache.Set(cacheKey, true)
}
@@ -337,24 +335,22 @@ func GetIndexInfo(sourceName string, forceCacheRefresh bool) (ReducedIndex, erro
scannerInfos = append(scannerInfos, &ScannerInfo{
Path: scanner.scanPath,
CurrentSchedule: scanner.currentSchedule,
Stats: Stats{
LastScanned: scanner.lastScanned,
Complexity: scanner.complexity,
QuickScanTime: scanner.quickScanTime,
FullScanTime: scanner.fullScanTime,
NumDirs: scanner.numDirs,
NumFiles: scanner.numFiles,
},
Stats: Stats{
LastScanned: scanner.lastScanned,
Complexity: scanner.complexity,
QuickScanTime: scanner.quickScanTime,
FullScanTime: scanner.fullScanTime,
NumDirs: scanner.numDirs,
NumFiles: scanner.numFiles,
},
})
}
reducedIdx := idx.ReducedIndex
var diskUsed uint64
rootTotal, ok := idx.GetFolderSize("/")
if !ok {
logger.Errorf("Failed to get root-level indexed total for index %s", sourceName)
// Compute DiskUsed from database (total size of all files indexed)
diskUsed, err := idx.db.GetTotalSize(idx.Name)
if err != nil {
logger.Errorf("Failed to get total size for index %s: %v", sourceName, err)
diskUsed = 0
} else {
diskUsed = rootTotal
}
reducedIdx.DiskUsed = diskUsed
reducedIdx.UsedAlt = idx.UsedAlt
+4 -28
View File
@@ -28,19 +28,13 @@ type SearchResult struct {
Source string `json:"source"`
}
func (idx *Index) Search(search string, scope string, sourceSession string, largest bool, limit int, olderThanUnix, newerThanUnix int64) []*SearchResult {
func (idx *Index) Search(search string, scope string, sourceSession string, largest bool, limit int) []*SearchResult {
// Ensure scope has consistent trailing slash for directory matching
scope = utils.AddTrailingSlashIfNotExists(scope)
runningHash := utils.InsecureRandomIdentifier(4)
sessionInProgress.Store(sourceSession, runningHash) // Store the value in the sync.Map
searchOptions := iteminfo.ParseSearch(search)
if olderThanUnix > 0 {
searchOptions.ModifiedOlderThan = olderThanUnix
}
if newerThanUnix > 0 {
searchOptions.ModifiedNewerThan = newerThanUnix
}
results := make(map[string]*SearchResult, 0)
count := 0
@@ -108,8 +102,7 @@ func (idx *Index) Search(search string, scope string, sourceSession string, larg
} else {
typeMatches = !hasDirCondition || (hasDirCondition && !dirCondition)
}
dateMatches := searchDateMatches(item.ModTime.Unix(), searchOptions)
matches = sizeMatches && typeMatches && dateMatches
matches = sizeMatches && typeMatches
} else {
matches = item.ContainsSearchTerm(searchTerm, searchOptions)
}
@@ -150,22 +143,12 @@ func (idx *Index) Search(search string, scope string, sourceSession string, larg
return sortedKeys
}
func searchDateMatches(modUnix int64, opts iteminfo.SearchOptions) bool {
if opts.ModifiedNewerThan > 0 && modUnix < opts.ModifiedNewerThan {
return false
}
if opts.ModifiedOlderThan > 0 && modUnix >= opts.ModifiedOlderThan {
return false
}
return true
}
// SearchMultiSources searches across multiple sources in a single database query.
// sources is a list of source names to search.
// sourceScopes is a map from source name to the scope path for that source.
// sourceSession is used for cancellation tracking.
// largest and limit work the same as in Search.
func SearchMultiSources(search string, sources []string, sourceScopes map[string]string, sourceSession string, largest bool, limit int, olderThanUnix, newerThanUnix int64) []*SearchResult {
func SearchMultiSources(search string, sources []string, sourceScopes map[string]string, sourceSession string, largest bool, limit int) []*SearchResult {
if len(sources) == 0 {
return []*SearchResult{}
}
@@ -185,12 +168,6 @@ func SearchMultiSources(search string, sources []string, sourceScopes map[string
runningHash := utils.InsecureRandomIdentifier(4)
sessionInProgress.Store(sourceSession, runningHash)
searchOptions := iteminfo.ParseSearch(search)
if olderThanUnix > 0 {
searchOptions.ModifiedOlderThan = olderThanUnix
}
if newerThanUnix > 0 {
searchOptions.ModifiedNewerThan = newerThanUnix
}
results := make(map[string]*SearchResult, 0)
count := 0
@@ -260,8 +237,7 @@ func SearchMultiSources(search string, sources []string, sourceScopes map[string
} else {
typeMatches = !hasDirCondition || (hasDirCondition && !dirCondition)
}
dateMatches := searchDateMatches(item.ModTime.Unix(), searchOptions)
matches = sizeMatches && typeMatches && dateMatches
matches = sizeMatches && typeMatches
} else {
matches = item.ContainsSearchTerm(searchTerm, searchOptions)
}
+5 -14
View File
@@ -62,7 +62,7 @@ func BenchmarkSearchAllIndexes(b *testing.B) {
for i := 0; i < b.N; i++ {
// Execute the SearchAllIndexes function
for _, term := range searchTerms {
idx.Search(term, "/", "test", false, DefaultSearchResults, 0, 0)
idx.Search(term, "/", "test", false, DefaultSearchResults)
}
}
}
@@ -102,15 +102,6 @@ func TestParseSearch(t *testing.T) {
Terms: []string{"thisfile"},
},
},
{
input: "type:olderThan=2024-06-01 type:newerThan=2024-01-01 findme",
want: iteminfo.SearchOptions{
Conditions: map[string]bool{"exact": false},
Terms: []string{"findme"},
ModifiedOlderThan: time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC).Unix(),
ModifiedNewerThan: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC).Unix(),
},
},
}
for _, tt := range tests {
@@ -174,7 +165,7 @@ func TestSearchWhileIndexing(t *testing.T) {
wg.Add(1)
go func(searchTerm string) {
defer wg.Done()
idx.Search(searchTerm, "/", "test", false, DefaultSearchResults, 0, 0)
idx.Search(searchTerm, "/", "test", false, DefaultSearchResults)
}(term)
}
@@ -452,7 +443,7 @@ func TestSearchIndexes(t *testing.T) {
for _, tt := range tests {
t.Run(tt.search, func(t *testing.T) {
result := index.Search(tt.search, tt.scope, "", false, DefaultSearchResults, 0, 0)
result := index.Search(tt.search, tt.scope, "", false, DefaultSearchResults)
// Convert results to comparable format (without Modified field)
expected := make([]SearchResult, len(tt.expectedResult))
for i, r := range tt.expectedResult {
@@ -544,7 +535,7 @@ func TestSearchLargestModeExcludesRoot(t *testing.T) {
}
// Test that when largest=true and scope="/", the root directory "/" is NOT included
result := index.Search("", "/", "test-session", true, DefaultSearchResults, 0, 0)
result := index.Search("", "/", "test-session", true, DefaultSearchResults)
// Verify that "/" is NOT in the results
rootFound := false
@@ -640,7 +631,7 @@ func TestSearchLargestModeExcludesScopeDirectory(t *testing.T) {
}
// Test that when largest=true and scope="/test/", the scope directory "/test/" is NOT included
result := index.Search("", "/test/", "test-session", true, DefaultSearchResults, 0, 0)
result := index.Search("", "/test/", "test-session", true, DefaultSearchResults)
// Verify that "/test/" is NOT in the results
scopeDirFound := false
-30
View File
@@ -1,30 +0,0 @@
package indexing
import (
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"golang.org/x/net/webdav"
)
// SetTestIndex allows tests to register mock indices without database initialization
// This is useful for testing code that depends on GetIndex() without needing full setup
func SetTestIndex(name string, path string) {
indexesMutex.Lock()
defer indexesMutex.Unlock()
idx := &Index{
Source: settings.Source{
Name: name,
Path: path,
},
WebdavLock: webdav.NewMemLS(),
mock: true,
}
indexes[name] = idx
}
// ClearTestIndices removes all test indices - call in test cleanup
func ClearTestIndices() {
indexesMutex.Lock()
defer indexesMutex.Unlock()
indexes = make(map[string]*Index)
}
+7 -25
View File
@@ -11,7 +11,6 @@ import (
"os"
"runtime"
"strings"
"time"
"github.com/gen2brain/go-fitz"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
@@ -33,14 +32,10 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
s.docGenMutex.Lock()
defer s.docGenMutex.Unlock()
// Lock the current goroutine to a single OS thread for CGo calls
// 2. Lock the current goroutine to a single OS thread for CGo calls
runtime.LockOSThread()
defer runtime.UnlockOSThread()
// Create a 2-second timeout for document generation after acquiring locks
timeoutCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
docPath := file.RealPath
// copy file to a temporary location if needed
if strings.HasPrefix(file.Type, "text") && !strings.HasSuffix(file.RealPath, ".txt") {
@@ -77,29 +72,21 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
return nil, fmt.Errorf("text snippet: failed to close temporary file '%s': %w", tempFilePath, err)
}
docPath = tempFilePath // Update docPath to point to the new temporary text snippet file
docPath = tempFilePath // Update docPath to point to the new temporary text snippet file
}
}
}
// Check timeout before opening document
if timeoutCtx.Err() != nil {
return nil, fmt.Errorf("document preview generation timed out after 2 seconds for '%s'", file.Name)
}
doc, err := fitz.New(docPath) // This calls the CGo version
if err != nil {
// The error message you received: "failed to open PDF: fitz: cannot open memory"
return nil, fmt.Errorf("failed to open PDF from memory for file '%s': %w", docPath, err)
}
defer doc.Close()
// Check timeout after opening document
if timeoutCtx.Err() != nil {
return nil, fmt.Errorf("document preview generation timed out after 2 seconds for '%s'", file.Name)
}
// Get the image from the doc page
// Ensure pageNumber is valid (e.g., >= 0 for 0-indexed or >= 1 for 1-indexed, check go-fitz docs)
// And pageNumber < doc.NumPage()
numPages := doc.NumPage()
if pageNumber < 0 || pageNumber >= numPages {
if pageNumber < 0 || pageNumber >= numPages { // Assuming 0-indexed for Image()
return nil, fmt.Errorf("invalid page number %d for PDF with %d pages ('%s')", pageNumber, numPages, docPath)
}
@@ -108,11 +95,6 @@ func (s *Service) GenerateImageFromDoc(ctx context.Context, file iteminfo.Extend
return nil, fmt.Errorf("failed to get image from page %d of '%s': %w", pageNumber, docPath, err)
}
// Check timeout after rendering image
if timeoutCtx.Err() != nil {
return nil, fmt.Errorf("document preview generation timed out after 2 seconds for '%s'", file.Name)
}
// Create a new buffer to hold the image bytes
var buf bytes.Buffer
+7 -471
View File
@@ -1074,69 +1074,6 @@ const docTemplate = `{
}
}
},
"/api/media/metadata": {
"get": {
"description": "Same ExtendedFileInfo as resources GET with metadata=true (typically used for directories).",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Resources"
],
"summary": "Directory with media metadata",
"parameters": [
{
"type": "string",
"description": "Path to the directory or file",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name",
"name": "source",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "When true, include embedded album art bytes in audio metadata",
"name": "albumArt",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/iteminfo.ExtendedFileInfo"
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/media/subtitles": {
"get": {
"description": "Returns raw subtitle content from external files or embedded streams",
@@ -1447,7 +1384,7 @@ const docTemplate = `{
},
{
"type": "string",
"description": "When true, omit index-level extended fields (e.g. hasPreview); does not disable ffmpeg/media extraction",
"description": "Skip extended attributes for faster retrieval, no hasPreview",
"name": "skipExtendedAttrs",
"in": "query"
},
@@ -1466,7 +1403,7 @@ const docTemplate = `{
},
{
"type": "string",
"description": "When true, run audio/video metadata extraction, subtitles, and directory media batch processing",
"description": "Extract audio/video metadata if true",
"name": "metadata",
"in": "query"
},
@@ -1791,7 +1728,7 @@ const docTemplate = `{
},
"/api/resources/archive": {
"post": {
"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.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the paths to archive live. Example: ` + "`" + `\"default\"` + "`" + `\n- **toSource** (string, optional): Source name where the archive file will be written. Defaults to fromSource if omitted. Example: ` + "`" + `\"backups\"` + "`" + `\n- **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\"]` + "`" + `\n- **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\"` + "`" + `\n- **format** (string, optional): Archive format. One of: ` + "`" + `\"zip\"` + "`" + `, ` + "`" + `\"tar.gz\"` + "`" + `. Default inferred from destination extension. Example: ` + "`" + `\"zip\"` + "`" + `\n- **compression** (integer, optional): Gzip compression level for tar.gz only (0–9). 0 = default. Ignored for zip. Example: ` + "`" + `6` + "`" + `\n- **deleteAfter** (boolean, optional): If true, delete source files/directories after successful creation. Requires delete permission. Example: ` + "`" + `true` + "`" + `",
"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.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the paths to archive live. Example: ` + "`" + `\"default\"` + "`" + `\n- **toSource** (string, optional): Source name where the archive file will be written. Defaults to fromSource if omitted. Example: ` + "`" + `\"backups\"` + "`" + `\n- **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\"]` + "`" + `\n- **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\"` + "`" + `\n- **format** (string, optional): Archive format. One of: ` + "`" + `\"zip\"` + "`" + `, ` + "`" + `\"tar.gz\"` + "`" + `. Default inferred from destination extension. Example: ` + "`" + `\"zip\"` + "`" + `\n- **compression** (integer, optional): Gzip compression level for tar.gz only (0–9). 0 = default. Ignored for zip. Example: ` + "`" + `6` + "`" + `\n- **deleteAfter** (boolean, optional): If true, delete source files/directories after successful creation. Requires delete permission. Example: ` + "`" + `true` + "`" + `",
"consumes": [
"application/json"
],
@@ -1850,15 +1787,6 @@ const docTemplate = `{
}
}
},
"413": {
"description": "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
@@ -2092,69 +2020,6 @@ const docTemplate = `{
}
}
},
"/api/resources/pause": {
"post": {
"description": "Sets a short-lived server flag for the given source and path. When the active chunk POST then fails (e.g. client abort), the server trims the incomplete chunk but keeps the temp partial file for resume.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Resources"
],
"summary": "Register graceful chunked upload pause",
"parameters": [
{
"type": "string",
"description": "Destination path (same as upload)",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name (same as upload)",
"name": "source",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "Pause registered"
},
"400": {
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Source not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/resources/preview": {
"get": {
"description": "Returns a preview image based on the requested path and size.",
@@ -2249,7 +2114,7 @@ const docTemplate = `{
},
"/api/resources/unarchive": {
"post": {
"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.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the archive file lives. Example: ` + "`" + `\"default\"` + "`" + `\n- **toSource** (string, optional): Source name where contents will be extracted. Defaults to fromSource if omitted. Example: ` + "`" + `\"restored\"` + "`" + `\n- **path** (string, required): Path to the archive file (on fromSource). Must be .zip, .tar.gz, or .tgz. Example: ` + "`" + `\"/downloads/data.zip\"` + "`" + `\n- **destination** (string, required): Directory path (on toSource) to extract into. Example: ` + "`" + `\"/projects/imported\"` + "`" + `\n- **deleteAfter** (boolean, optional): If true, delete the archive file after successful extraction. Default: false. Example: ` + "`" + `true` + "`" + `",
"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.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the archive file lives. Example: ` + "`" + `\"default\"` + "`" + `\n- **toSource** (string, optional): Source name where contents will be extracted. Defaults to fromSource if omitted. Example: ` + "`" + `\"restored\"` + "`" + `\n- **path** (string, required): Path to the archive file (on fromSource). Must be .zip, .tar.gz, or .tgz. Example: ` + "`" + `\"/downloads/data.zip\"` + "`" + `\n- **destination** (string, required): Directory path (on toSource) to extract into. Example: ` + "`" + `\"/projects/imported\"` + "`" + `\n- **deleteAfter** (boolean, optional): If true, delete the archive file after successful extraction. Default: false. Example: ` + "`" + `true` + "`" + `",
"consumes": [
"application/json"
],
@@ -2308,15 +2173,6 @@ const docTemplate = `{
}
}
},
"413": {
"description": "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
@@ -2977,18 +2833,6 @@ const docTemplate = `{
"name": "scope",
"in": "query"
},
{
"type": "integer",
"description": "Unix seconds; only results modified strictly before this time",
"name": "olderThan",
"in": "query"
},
{
"type": "integer",
"description": "Unix seconds; only results modified on or after this time",
"name": "newerThan",
"in": "query"
},
{
"type": "string",
"description": "User session ID, add unique value to prevent collisions",
@@ -3076,7 +2920,7 @@ const docTemplate = `{
}
},
"put": {
"description": "Updates the details of a user identified by ID. When updating the target user's password the actor must send their current password in the X-Password header",
"description": "Updates the details of a user identified by ID.",
"consumes": [
"application/json"
],
@@ -3097,12 +2941,6 @@ const docTemplate = `{
"name": "id",
"in": "query"
},
{
"type": "string",
"description": "Actor's current password (URL-encoded); required when changing a password user's password",
"name": "X-Password",
"in": "header"
},
{
"description": "User data to update",
"name": "data",
@@ -3126,15 +2964,6 @@ const docTemplate = `{
}
}
},
"401": {
"description": "Unauthorized - invalid or missing actor password for password change",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden",
"schema": {
@@ -3271,46 +3100,6 @@ const docTemplate = `{
}
}
},
"/public/api/media/metadata": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Shares"
],
"summary": "Directory with media metadata (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share",
"name": "path",
"in": "query"
},
{
"type": "boolean",
"description": "When true, include embedded album art bytes in audio metadata (heavier)",
"name": "albumArt",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/iteminfo.ExtendedFileInfo"
}
}
}
}
},
"/public/api/resources": {
"get": {
"description": "Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.",
@@ -3921,60 +3710,6 @@ const docTemplate = `{
}
}
},
"/public/api/resources/pause": {
"post": {
"description": "Same as authenticated pause, for upload shares. Uses hash and path query parameters like other public resource APIs.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Shares"
],
"summary": "Register graceful chunked upload pause (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share (same as upload)",
"name": "path",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "Pause registered"
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Source not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/public/api/resources/preview": {
"get": {
"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.",
@@ -4367,9 +4102,6 @@ const docTemplate = `{
"bannerUrl": {
"type": "string"
},
"canEditShare": {
"type": "boolean"
},
"description": {
"type": "string"
},
@@ -4481,9 +4213,6 @@ const docTemplate = `{
"description": "Override embedded field to show source name",
"type": "string"
},
"sourceURL": {
"type": "string"
},
"themeColor": {
"type": "string"
},
@@ -4676,99 +4405,6 @@ const docTemplate = `{
}
}
},
"iteminfo.ExtendedFileInfo": {
"type": "object",
"properties": {
"checksums": {
"description": "checksums for the file",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"content": {
"description": "text content of a file, if requested",
"type": "string"
},
"files": {
"description": "files in the directory with optional metadata",
"type": "array",
"items": {
"$ref": "#/definitions/iteminfo.ExtendedItemInfo"
}
},
"folders": {
"description": "folders in the directory",
"type": "array",
"items": {
"$ref": "#/definitions/iteminfo.ItemInfo"
}
},
"hasPreview": {
"description": "whether the file has a thumbnail preview",
"type": "boolean"
},
"hash": {
"description": "hash for the file -- used for sharing",
"type": "string"
},
"hidden": {
"description": "whether the file is hidden",
"type": "boolean"
},
"isShared": {
"description": "whether the file or folder is shared",
"type": "boolean"
},
"metadata": {
"description": "media metadata for audio/video files (includes duration)",
"allOf": [
{
"$ref": "#/definitions/iteminfo.MediaMetadata"
}
]
},
"modified": {
"description": "modification time",
"type": "string"
},
"name": {
"description": "name of the file",
"type": "string"
},
"onlyOfficeId": {
"description": "id for onlyoffice files",
"type": "string"
},
"path": {
"description": "path scoped to the associated index",
"type": "string"
},
"size": {
"description": "length in bytes for regular files",
"type": "integer"
},
"source": {
"description": "associated index source for the file",
"type": "string"
},
"subtitles": {
"description": "subtitles for video files",
"type": "array",
"items": {
"$ref": "#/definitions/utils.SubtitleTrack"
}
},
"token": {
"description": "token for the file -- used for sharing",
"type": "string"
},
"type": {
"description": "type of the file, either \"directory\" or a file mimetype",
"type": "string"
}
}
},
"iteminfo.ExtendedItemInfo": {
"type": "object",
"properties": {
@@ -5190,15 +4826,6 @@ const docTemplate = `{
"description": "enable to always create a new indexing database on startup.",
"type": "boolean"
},
"startupIntegrityCheck": {
"description": "the method used to check the integrity of the index database on startup (default: quickCheck)",
"type": "string",
"enum": [
"quickCheck",
"probe",
"off"
]
},
"walMode": {
"description": "enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.",
"type": "boolean"
@@ -5659,7 +5286,7 @@ const docTemplate = `{
}
},
"maxArchiveSize": {
"description": "maximum archive/unarchive size in GB. 0 means no limit. (default: 20)",
"description": "max pre-archive combined size of files/folder that are allowed to be archived (in GB)",
"type": "integer"
},
"minSearchLength": {
@@ -5745,7 +5372,7 @@ const docTemplate = `{
]
},
"createUserDir": {
"description": "deprecated: create a user directory for each user under defaultUserScope + username (always true now)",
"description": "create a user directory for each user under defaultUserScope + username",
"type": "boolean"
},
"defaultEnabled": {
@@ -5836,10 +5463,6 @@ const docTemplate = `{
"description": "debug onlyoffice editor",
"type": "boolean"
},
"deleteAfterArchive": {
"description": "delete source files after successful creation/extraction of archives",
"type": "boolean"
},
"deleteWithoutConfirming": {
"description": "delete files without confirmation",
"type": "boolean"
@@ -5892,10 +5515,6 @@ const docTemplate = `{
"description": "0-9 - the size of the gallery thumbnails",
"type": "integer"
},
"hideFilesInTree": {
"description": "hide files in the sidebar tree navigation, when true, will show only directories.",
"type": "boolean"
},
"hideSidebarFileActions": {
"description": "hide the file actions in the sidebar",
"type": "boolean"
@@ -5915,10 +5534,6 @@ const docTemplate = `{
"permissions": {
"$ref": "#/definitions/settings.UserDefaultsPermissions"
},
"preferEditorForMarkdown": {
"description": "prefer editor first for markdown files instead of the Markdown Viewer.",
"type": "boolean"
},
"preview": {
"$ref": "#/definitions/settings.UserDefaultsPreview"
},
@@ -5992,10 +5607,6 @@ const docTemplate = `{
"settings.UserDefaultsPreview": {
"type": "object",
"properties": {
"audio": {
"description": "show thumbnails for audio files",
"type": "boolean"
},
"autoplayMedia": {
"description": "autoplay media files in preview",
"type": "boolean"
@@ -6020,10 +5631,6 @@ const docTemplate = `{
"description": "show thumbnails for image files",
"type": "boolean"
},
"models": {
"description": "show live thumbnails for 3D models files",
"type": "boolean"
},
"motionVideoPreview": {
"description": "show multiple frames for videos in thumbnail preview when hovering",
"type": "boolean"
@@ -6072,9 +5679,6 @@ const docTemplate = `{
"bannerUrl": {
"type": "string"
},
"canEditShare": {
"type": "boolean"
},
"description": {
"type": "string"
},
@@ -6171,9 +5775,6 @@ const docTemplate = `{
"description": "backend source is path to maintain between name changes",
"type": "string"
},
"sourceURL": {
"type": "string"
},
"themeColor": {
"type": "string"
},
@@ -6216,9 +5817,6 @@ const docTemplate = `{
"bannerUrl": {
"type": "string"
},
"canEditShare": {
"type": "boolean"
},
"description": {
"type": "string"
},
@@ -6324,9 +5922,6 @@ const docTemplate = `{
"description": "backend source is path to maintain between name changes",
"type": "string"
},
"sourceURL": {
"type": "string"
},
"themeColor": {
"type": "string"
},
@@ -6372,9 +5967,6 @@ const docTemplate = `{
"bannerUrl": {
"type": "string"
},
"canEditShare": {
"type": "boolean"
},
"description": {
"type": "string"
},
@@ -6483,9 +6075,6 @@ const docTemplate = `{
"description": "backend source is path to maintain between name changes",
"type": "string"
},
"sourceURL": {
"type": "string"
},
"themeColor": {
"type": "string"
},
@@ -6616,10 +6205,6 @@ const docTemplate = `{
"users.Preview": {
"type": "object",
"properties": {
"audio": {
"description": "show thumbnail preview image for audio files",
"type": "boolean"
},
"autoplayMedia": {
"description": "autoplay media files in preview",
"type": "boolean"
@@ -6640,10 +6225,6 @@ const docTemplate = `{
"description": "show thumbnail preview image for image files",
"type": "boolean"
},
"models": {
"description": "show live thumbnails for 3D models files",
"type": "boolean"
},
"motionVideoPreview": {
"description": "show multiple frames for videos in thumbnail preview when hovering",
"type": "boolean"
@@ -6735,10 +6316,6 @@ const docTemplate = `{
"description": "debug onlyoffice editor",
"type": "boolean"
},
"deleteAfterArchive": {
"description": "delete source files after successful creation/extraction of archives",
"type": "boolean"
},
"deleteWithoutConfirming": {
"description": "delete files without confirmation",
"type": "boolean"
@@ -6790,10 +6367,6 @@ const docTemplate = `{
"description": "0-9 - the size of the gallery thumbnails",
"type": "integer"
},
"hideFilesInTree": {
"description": "hide files in the sidebar tree navigation, when true, will show only directories.",
"type": "boolean"
},
"hideSidebarFileActions": {
"description": "hide the file actions in the sidebar",
"type": "boolean"
@@ -6829,10 +6402,6 @@ const docTemplate = `{
"permissions": {
"$ref": "#/definitions/users.Permissions"
},
"preferEditorForMarkdown": {
"description": "prefer editor first for markdown files instead of the Markdown Viewer",
"type": "boolean"
},
"preview": {
"$ref": "#/definitions/users.Preview"
},
@@ -6849,10 +6418,6 @@ const docTemplate = `{
"$ref": "#/definitions/users.SourceScope"
}
},
"showCopyPath": {
"description": "show copy path action in the context menu",
"type": "boolean"
},
"showFirstLogin": {
"type": "boolean"
},
@@ -6909,35 +6474,6 @@ const docTemplate = `{
"type": "string"
}
}
},
"utils.SubtitleTrack": {
"type": "object",
"properties": {
"codec": {
"description": "codec name for embedded subtitles",
"type": "string"
},
"embedded": {
"description": "true for embedded subtitles, false for external files",
"type": "boolean"
},
"index": {
"description": "stream index for embedded subtitles (nil for external)",
"type": "integer"
},
"language": {
"description": "language code",
"type": "string"
},
"name": {
"description": "filename for external, or descriptive name for embedded",
"type": "string"
},
"title": {
"description": "title/description",
"type": "string"
}
}
}
}
}`
+7 -471
View File
@@ -1063,69 +1063,6 @@
}
}
},
"/api/media/metadata": {
"get": {
"description": "Same ExtendedFileInfo as resources GET with metadata=true (typically used for directories).",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Resources"
],
"summary": "Directory with media metadata",
"parameters": [
{
"type": "string",
"description": "Path to the directory or file",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name",
"name": "source",
"in": "query",
"required": true
},
{
"type": "boolean",
"description": "When true, include embedded album art bytes in audio metadata",
"name": "albumArt",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/iteminfo.ExtendedFileInfo"
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/media/subtitles": {
"get": {
"description": "Returns raw subtitle content from external files or embedded streams",
@@ -1436,7 +1373,7 @@
},
{
"type": "string",
"description": "When true, omit index-level extended fields (e.g. hasPreview); does not disable ffmpeg/media extraction",
"description": "Skip extended attributes for faster retrieval, no hasPreview",
"name": "skipExtendedAttrs",
"in": "query"
},
@@ -1455,7 +1392,7 @@
},
{
"type": "string",
"description": "When true, run audio/video metadata extraction, subtitles, and directory media batch processing",
"description": "Extract audio/video metadata if true",
"name": "metadata",
"in": "query"
},
@@ -1780,7 +1717,7 @@
},
"/api/resources/archive": {
"post": {
"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.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the paths to archive live. Example: `\"default\"`\n- **toSource** (string, optional): Source name where the archive file will be written. Defaults to fromSource if omitted. Example: `\"backups\"`\n- **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\"]`\n- **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\"`\n- **format** (string, optional): Archive format. One of: `\"zip\"`, `\"tar.gz\"`. Default inferred from destination extension. Example: `\"zip\"`\n- **compression** (integer, optional): Gzip compression level for tar.gz only (0–9). 0 = default. Ignored for zip. Example: `6`\n- **deleteAfter** (boolean, optional): If true, delete source files/directories after successful creation. Requires delete permission. Example: `true`",
"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.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the paths to archive live. Example: `\"default\"`\n- **toSource** (string, optional): Source name where the archive file will be written. Defaults to fromSource if omitted. Example: `\"backups\"`\n- **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\"]`\n- **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\"`\n- **format** (string, optional): Archive format. One of: `\"zip\"`, `\"tar.gz\"`. Default inferred from destination extension. Example: `\"zip\"`\n- **compression** (integer, optional): Gzip compression level for tar.gz only (0–9). 0 = default. Ignored for zip. Example: `6`\n- **deleteAfter** (boolean, optional): If true, delete source files/directories after successful creation. Requires delete permission. Example: `true`",
"consumes": [
"application/json"
],
@@ -1839,15 +1776,6 @@
}
}
},
"413": {
"description": "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
@@ -2081,69 +2009,6 @@
}
}
},
"/api/resources/pause": {
"post": {
"description": "Sets a short-lived server flag for the given source and path. When the active chunk POST then fails (e.g. client abort), the server trims the incomplete chunk but keeps the temp partial file for resume.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Resources"
],
"summary": "Register graceful chunked upload pause",
"parameters": [
{
"type": "string",
"description": "Destination path (same as upload)",
"name": "path",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Source name (same as upload)",
"name": "source",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "Pause registered"
},
"400": {
"description": "Bad request",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Source not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/api/resources/preview": {
"get": {
"description": "Returns a preview image based on the requested path and size.",
@@ -2238,7 +2103,7 @@
},
"/api/resources/unarchive": {
"post": {
"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.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the archive file lives. Example: `\"default\"`\n- **toSource** (string, optional): Source name where contents will be extracted. Defaults to fromSource if omitted. Example: `\"restored\"`\n- **path** (string, required): Path to the archive file (on fromSource). Must be .zip, .tar.gz, or .tgz. Example: `\"/downloads/data.zip\"`\n- **destination** (string, required): Directory path (on toSource) to extract into. Example: `\"/projects/imported\"`\n- **deleteAfter** (boolean, optional): If true, delete the archive file after successful extraction. Default: false. Example: `true`",
"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.\n\n**Request body parameters:**\n- **fromSource** (string, required): Source name where the archive file lives. Example: `\"default\"`\n- **toSource** (string, optional): Source name where contents will be extracted. Defaults to fromSource if omitted. Example: `\"restored\"`\n- **path** (string, required): Path to the archive file (on fromSource). Must be .zip, .tar.gz, or .tgz. Example: `\"/downloads/data.zip\"`\n- **destination** (string, required): Directory path (on toSource) to extract into. Example: `\"/projects/imported\"`\n- **deleteAfter** (boolean, optional): If true, delete the archive file after successful extraction. Default: false. Example: `true`",
"consumes": [
"application/json"
],
@@ -2297,15 +2162,6 @@
}
}
},
"413": {
"description": "Request Entity Too Large (archive size exceeds maxArchiveSizeGB limit)",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"500": {
"description": "Internal server error",
"schema": {
@@ -2966,18 +2822,6 @@
"name": "scope",
"in": "query"
},
{
"type": "integer",
"description": "Unix seconds; only results modified strictly before this time",
"name": "olderThan",
"in": "query"
},
{
"type": "integer",
"description": "Unix seconds; only results modified on or after this time",
"name": "newerThan",
"in": "query"
},
{
"type": "string",
"description": "User session ID, add unique value to prevent collisions",
@@ -3065,7 +2909,7 @@
}
},
"put": {
"description": "Updates the details of a user identified by ID. When updating the target user's password the actor must send their current password in the X-Password header",
"description": "Updates the details of a user identified by ID.",
"consumes": [
"application/json"
],
@@ -3086,12 +2930,6 @@
"name": "id",
"in": "query"
},
{
"type": "string",
"description": "Actor's current password (URL-encoded); required when changing a password user's password",
"name": "X-Password",
"in": "header"
},
{
"description": "User data to update",
"name": "data",
@@ -3115,15 +2953,6 @@
}
}
},
"401": {
"description": "Unauthorized - invalid or missing actor password for password change",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"403": {
"description": "Forbidden",
"schema": {
@@ -3260,46 +3089,6 @@
}
}
},
"/public/api/media/metadata": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Shares"
],
"summary": "Directory with media metadata (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share",
"name": "path",
"in": "query"
},
{
"type": "boolean",
"description": "When true, include embedded album art bytes in audio metadata (heavier)",
"name": "albumArt",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/iteminfo.ExtendedFileInfo"
}
}
}
}
},
"/public/api/resources": {
"get": {
"description": "Returns metadata for files or directories accessible via a public share link. Browsing is disabled for upload-only shares.",
@@ -3910,60 +3699,6 @@
}
}
},
"/public/api/resources/pause": {
"post": {
"description": "Same as authenticated pause, for upload shares. Uses hash and path query parameters like other public resource APIs.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Shares"
],
"summary": "Register graceful chunked upload pause (public share)",
"parameters": [
{
"type": "string",
"description": "Share hash",
"name": "hash",
"in": "query",
"required": true
},
{
"type": "string",
"description": "Path within the share (same as upload)",
"name": "path",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "Pause registered"
},
"403": {
"description": "Forbidden",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "Source not found",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
}
}
},
"/public/api/resources/preview": {
"get": {
"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.",
@@ -4356,9 +4091,6 @@
"bannerUrl": {
"type": "string"
},
"canEditShare": {
"type": "boolean"
},
"description": {
"type": "string"
},
@@ -4470,9 +4202,6 @@
"description": "Override embedded field to show source name",
"type": "string"
},
"sourceURL": {
"type": "string"
},
"themeColor": {
"type": "string"
},
@@ -4665,99 +4394,6 @@
}
}
},
"iteminfo.ExtendedFileInfo": {
"type": "object",
"properties": {
"checksums": {
"description": "checksums for the file",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"content": {
"description": "text content of a file, if requested",
"type": "string"
},
"files": {
"description": "files in the directory with optional metadata",
"type": "array",
"items": {
"$ref": "#/definitions/iteminfo.ExtendedItemInfo"
}
},
"folders": {
"description": "folders in the directory",
"type": "array",
"items": {
"$ref": "#/definitions/iteminfo.ItemInfo"
}
},
"hasPreview": {
"description": "whether the file has a thumbnail preview",
"type": "boolean"
},
"hash": {
"description": "hash for the file -- used for sharing",
"type": "string"
},
"hidden": {
"description": "whether the file is hidden",
"type": "boolean"
},
"isShared": {
"description": "whether the file or folder is shared",
"type": "boolean"
},
"metadata": {
"description": "media metadata for audio/video files (includes duration)",
"allOf": [
{
"$ref": "#/definitions/iteminfo.MediaMetadata"
}
]
},
"modified": {
"description": "modification time",
"type": "string"
},
"name": {
"description": "name of the file",
"type": "string"
},
"onlyOfficeId": {
"description": "id for onlyoffice files",
"type": "string"
},
"path": {
"description": "path scoped to the associated index",
"type": "string"
},
"size": {
"description": "length in bytes for regular files",
"type": "integer"
},
"source": {
"description": "associated index source for the file",
"type": "string"
},
"subtitles": {
"description": "subtitles for video files",
"type": "array",
"items": {
"$ref": "#/definitions/utils.SubtitleTrack"
}
},
"token": {
"description": "token for the file -- used for sharing",
"type": "string"
},
"type": {
"description": "type of the file, either \"directory\" or a file mimetype",
"type": "string"
}
}
},
"iteminfo.ExtendedItemInfo": {
"type": "object",
"properties": {
@@ -5179,15 +4815,6 @@
"description": "enable to always create a new indexing database on startup.",
"type": "boolean"
},
"startupIntegrityCheck": {
"description": "the method used to check the integrity of the index database on startup (default: quickCheck)",
"type": "string",
"enum": [
"quickCheck",
"probe",
"off"
]
},
"walMode": {
"description": "enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.",
"type": "boolean"
@@ -5648,7 +5275,7 @@
}
},
"maxArchiveSize": {
"description": "maximum archive/unarchive size in GB. 0 means no limit. (default: 20)",
"description": "max pre-archive combined size of files/folder that are allowed to be archived (in GB)",
"type": "integer"
},
"minSearchLength": {
@@ -5734,7 +5361,7 @@
]
},
"createUserDir": {
"description": "deprecated: create a user directory for each user under defaultUserScope + username (always true now)",
"description": "create a user directory for each user under defaultUserScope + username",
"type": "boolean"
},
"defaultEnabled": {
@@ -5825,10 +5452,6 @@
"description": "debug onlyoffice editor",
"type": "boolean"
},
"deleteAfterArchive": {
"description": "delete source files after successful creation/extraction of archives",
"type": "boolean"
},
"deleteWithoutConfirming": {
"description": "delete files without confirmation",
"type": "boolean"
@@ -5881,10 +5504,6 @@
"description": "0-9 - the size of the gallery thumbnails",
"type": "integer"
},
"hideFilesInTree": {
"description": "hide files in the sidebar tree navigation, when true, will show only directories.",
"type": "boolean"
},
"hideSidebarFileActions": {
"description": "hide the file actions in the sidebar",
"type": "boolean"
@@ -5904,10 +5523,6 @@
"permissions": {
"$ref": "#/definitions/settings.UserDefaultsPermissions"
},
"preferEditorForMarkdown": {
"description": "prefer editor first for markdown files instead of the Markdown Viewer.",
"type": "boolean"
},
"preview": {
"$ref": "#/definitions/settings.UserDefaultsPreview"
},
@@ -5981,10 +5596,6 @@
"settings.UserDefaultsPreview": {
"type": "object",
"properties": {
"audio": {
"description": "show thumbnails for audio files",
"type": "boolean"
},
"autoplayMedia": {
"description": "autoplay media files in preview",
"type": "boolean"
@@ -6009,10 +5620,6 @@
"description": "show thumbnails for image files",
"type": "boolean"
},
"models": {
"description": "show live thumbnails for 3D models files",
"type": "boolean"
},
"motionVideoPreview": {
"description": "show multiple frames for videos in thumbnail preview when hovering",
"type": "boolean"
@@ -6061,9 +5668,6 @@
"bannerUrl": {
"type": "string"
},
"canEditShare": {
"type": "boolean"
},
"description": {
"type": "string"
},
@@ -6160,9 +5764,6 @@
"description": "backend source is path to maintain between name changes",
"type": "string"
},
"sourceURL": {
"type": "string"
},
"themeColor": {
"type": "string"
},
@@ -6205,9 +5806,6 @@
"bannerUrl": {
"type": "string"
},
"canEditShare": {
"type": "boolean"
},
"description": {
"type": "string"
},
@@ -6313,9 +5911,6 @@
"description": "backend source is path to maintain between name changes",
"type": "string"
},
"sourceURL": {
"type": "string"
},
"themeColor": {
"type": "string"
},
@@ -6361,9 +5956,6 @@
"bannerUrl": {
"type": "string"
},
"canEditShare": {
"type": "boolean"
},
"description": {
"type": "string"
},
@@ -6472,9 +6064,6 @@
"description": "backend source is path to maintain between name changes",
"type": "string"
},
"sourceURL": {
"type": "string"
},
"themeColor": {
"type": "string"
},
@@ -6605,10 +6194,6 @@
"users.Preview": {
"type": "object",
"properties": {
"audio": {
"description": "show thumbnail preview image for audio files",
"type": "boolean"
},
"autoplayMedia": {
"description": "autoplay media files in preview",
"type": "boolean"
@@ -6629,10 +6214,6 @@
"description": "show thumbnail preview image for image files",
"type": "boolean"
},
"models": {
"description": "show live thumbnails for 3D models files",
"type": "boolean"
},
"motionVideoPreview": {
"description": "show multiple frames for videos in thumbnail preview when hovering",
"type": "boolean"
@@ -6724,10 +6305,6 @@
"description": "debug onlyoffice editor",
"type": "boolean"
},
"deleteAfterArchive": {
"description": "delete source files after successful creation/extraction of archives",
"type": "boolean"
},
"deleteWithoutConfirming": {
"description": "delete files without confirmation",
"type": "boolean"
@@ -6779,10 +6356,6 @@
"description": "0-9 - the size of the gallery thumbnails",
"type": "integer"
},
"hideFilesInTree": {
"description": "hide files in the sidebar tree navigation, when true, will show only directories.",
"type": "boolean"
},
"hideSidebarFileActions": {
"description": "hide the file actions in the sidebar",
"type": "boolean"
@@ -6818,10 +6391,6 @@
"permissions": {
"$ref": "#/definitions/users.Permissions"
},
"preferEditorForMarkdown": {
"description": "prefer editor first for markdown files instead of the Markdown Viewer",
"type": "boolean"
},
"preview": {
"$ref": "#/definitions/users.Preview"
},
@@ -6838,10 +6407,6 @@
"$ref": "#/definitions/users.SourceScope"
}
},
"showCopyPath": {
"description": "show copy path action in the context menu",
"type": "boolean"
},
"showFirstLogin": {
"type": "boolean"
},
@@ -6898,35 +6463,6 @@
"type": "string"
}
}
},
"utils.SubtitleTrack": {
"type": "object",
"properties": {
"codec": {
"description": "codec name for embedded subtitles",
"type": "string"
},
"embedded": {
"description": "true for embedded subtitles, false for external files",
"type": "boolean"
},
"index": {
"description": "stream index for embedded subtitles (nil for external)",
"type": "integer"
},
"language": {
"description": "language code",
"type": "string"
},
"name": {
"description": "filename for external, or descriptive name for embedded",
"type": "string"
},
"title": {
"description": "title/description",
"type": "string"
}
}
}
}
}
+9 -346
View File
@@ -131,8 +131,6 @@ definitions:
type: string
bannerUrl:
type: string
canEditShare:
type: boolean
description:
type: string
disableAnonymous:
@@ -210,8 +208,6 @@ definitions:
source:
description: Override embedded field to show source name
type: string
sourceURL:
type: string
themeColor:
type: string
title:
@@ -359,72 +355,6 @@ definitions:
type:
type: string
type: object
iteminfo.ExtendedFileInfo:
properties:
checksums:
additionalProperties:
type: string
description: checksums for the file
type: object
content:
description: text content of a file, if requested
type: string
files:
description: files in the directory with optional metadata
items:
$ref: '#/definitions/iteminfo.ExtendedItemInfo'
type: array
folders:
description: folders in the directory
items:
$ref: '#/definitions/iteminfo.ItemInfo'
type: array
hasPreview:
description: whether the file has a thumbnail preview
type: boolean
hash:
description: hash for the file -- used for sharing
type: string
hidden:
description: whether the file is hidden
type: boolean
isShared:
description: whether the file or folder is shared
type: boolean
metadata:
allOf:
- $ref: '#/definitions/iteminfo.MediaMetadata'
description: media metadata for audio/video files (includes duration)
modified:
description: modification time
type: string
name:
description: name of the file
type: string
onlyOfficeId:
description: id for onlyoffice files
type: string
path:
description: path scoped to the associated index
type: string
size:
description: length in bytes for regular files
type: integer
source:
description: associated index source for the file
type: string
subtitles:
description: subtitles for video files
items:
$ref: '#/definitions/utils.SubtitleTrack'
type: array
token:
description: token for the file -- used for sharing
type: string
type:
description: type of the file, either "directory" or a file mimetype
type: string
type: object
iteminfo.ExtendedItemInfo:
properties:
hasPreview:
@@ -751,14 +681,6 @@ definitions:
disableReuse:
description: enable to always create a new indexing database on startup.
type: boolean
startupIntegrityCheck:
description: 'the method used to check the integrity of the index database
on startup (default: quickCheck)'
enum:
- quickCheck
- probe
- "off"
type: string
walMode:
description: enable the more complex WAL journaling mode. Slower, more memory
usage, but better for deployments with constant user activity.
@@ -1114,8 +1036,8 @@ definitions:
$ref: '#/definitions/settings.LogConfig'
type: array
maxArchiveSize:
description: 'maximum archive/unarchive size in GB. 0 means no limit. (default:
20)'
description: max pre-archive combined size of files/folder that are allowed
to be archived (in GB)
type: integer
minSearchLength:
description: 'minimum length of search query to begin searching (default:
@@ -1177,8 +1099,8 @@ definitions:
- $ref: '#/definitions/settings.ConditionalFilter'
description: 'deprecated: use source.rules instead'
createUserDir:
description: 'deprecated: create a user directory for each user under defaultUserScope
+ username (always true now)'
description: create a user directory for each user under defaultUserScope
+ username
type: boolean
defaultEnabled:
description: should be added as a default source for new users?
@@ -1255,9 +1177,6 @@ definitions:
debugOffice:
description: debug onlyoffice editor
type: boolean
deleteAfterArchive:
description: delete source files after successful creation/extraction of archives
type: boolean
deleteWithoutConfirming:
description: delete files without confirmation
type: boolean
@@ -1295,10 +1214,6 @@ definitions:
gallerySize:
description: 0-9 - the size of the gallery thumbnails
type: integer
hideFilesInTree:
description: hide files in the sidebar tree navigation, when true, will show
only directories.
type: boolean
hideSidebarFileActions:
description: hide the file actions in the sidebar
type: boolean
@@ -1313,10 +1228,6 @@ definitions:
type: string
permissions:
$ref: '#/definitions/settings.UserDefaultsPermissions'
preferEditorForMarkdown:
description: prefer editor first for markdown files instead of the Markdown
Viewer.
type: boolean
preview:
$ref: '#/definitions/settings.UserDefaultsPreview'
quickDownload:
@@ -1373,9 +1284,6 @@ definitions:
type: object
settings.UserDefaultsPreview:
properties:
audio:
description: show thumbnails for audio files
type: boolean
autoplayMedia:
description: autoplay media files in preview
type: boolean
@@ -1394,9 +1302,6 @@ definitions:
image:
description: show thumbnails for image files
type: boolean
models:
description: show live thumbnails for 3D models files
type: boolean
motionVideoPreview:
description: show multiple frames for videos in thumbnail preview when hovering
type: boolean
@@ -1431,8 +1336,6 @@ definitions:
type: string
bannerUrl:
type: string
canEditShare:
type: boolean
description:
type: string
disableAnonymous:
@@ -1500,8 +1403,6 @@ definitions:
source:
description: backend source is path to maintain between name changes
type: string
sourceURL:
type: string
themeColor:
type: string
title:
@@ -1532,8 +1433,6 @@ definitions:
type: string
bannerUrl:
type: string
canEditShare:
type: boolean
description:
type: string
disableAnonymous:
@@ -1607,8 +1506,6 @@ definitions:
source:
description: backend source is path to maintain between name changes
type: string
sourceURL:
type: string
themeColor:
type: string
title:
@@ -1641,8 +1538,6 @@ definitions:
type: string
bannerUrl:
type: string
canEditShare:
type: boolean
description:
type: string
disableAnonymous:
@@ -1718,8 +1613,6 @@ definitions:
source:
description: backend source is path to maintain between name changes
type: string
sourceURL:
type: string
themeColor:
type: string
title:
@@ -1816,9 +1709,6 @@ definitions:
type: object
users.Preview:
properties:
audio:
description: show thumbnail preview image for audio files
type: boolean
autoplayMedia:
description: autoplay media files in preview
type: boolean
@@ -1834,9 +1724,6 @@ definitions:
image:
description: show thumbnail preview image for image files
type: boolean
models:
description: show live thumbnails for 3D models files
type: boolean
motionVideoPreview:
description: show multiple frames for videos in thumbnail preview when hovering
type: boolean
@@ -1903,9 +1790,6 @@ definitions:
debugOffice:
description: debug onlyoffice editor
type: boolean
deleteAfterArchive:
description: delete source files after successful creation/extraction of archives
type: boolean
deleteWithoutConfirming:
description: delete files without confirmation
type: boolean
@@ -1942,10 +1826,6 @@ definitions:
gallerySize:
description: 0-9 - the size of the gallery thumbnails
type: integer
hideFilesInTree:
description: hide files in the sidebar tree navigation, when true, will show
only directories.
type: boolean
hideSidebarFileActions:
description: hide the file actions in the sidebar
type: boolean
@@ -1969,10 +1849,6 @@ definitions:
description: legacy for migration purposes... og filebrowser has perm attribute
permissions:
$ref: '#/definitions/users.Permissions'
preferEditorForMarkdown:
description: prefer editor first for markdown files instead of the Markdown
Viewer
type: boolean
preview:
$ref: '#/definitions/users.Preview'
quickDownload:
@@ -1984,9 +1860,6 @@ definitions:
items:
$ref: '#/definitions/users.SourceScope'
type: array
showCopyPath:
description: show copy path action in the context menu
type: boolean
showFirstLogin:
type: boolean
showHidden:
@@ -2030,27 +1903,6 @@ definitions:
description: 'view mode to use: eg. normal, list, grid, or compact'
type: string
type: object
utils.SubtitleTrack:
properties:
codec:
description: codec name for embedded subtitles
type: string
embedded:
description: true for embedded subtitles, false for external files
type: boolean
index:
description: stream index for embedded subtitles (nil for external)
type: integer
language:
description: language code
type: string
name:
description: filename for external, or descriptive name for embedded
type: string
title:
description: title/description
type: string
type: object
info:
contact: {}
paths:
@@ -2768,49 +2620,6 @@ paths:
summary: List API tokens
tags:
- Auth
/api/media/metadata:
get:
consumes:
- application/json
description: Same ExtendedFileInfo as resources GET with metadata=true (typically
used for directories).
parameters:
- description: Path to the directory or file
in: query
name: path
required: true
type: string
- description: Source name
in: query
name: source
required: true
type: string
- description: When true, include embedded album art bytes in audio metadata
in: query
name: albumArt
type: boolean
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/iteminfo.ExtendedFileInfo'
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"404":
description: Not found
schema:
additionalProperties:
type: string
type: object
summary: Directory with media metadata
tags:
- Resources
/api/media/subtitles:
get:
consumes:
@@ -3056,8 +2865,7 @@ paths:
name: path
required: true
type: string
- description: When true, omit index-level extended fields (e.g. hasPreview);
does not disable ffmpeg/media extraction
- description: Skip extended attributes for faster retrieval, no hasPreview
in: query
name: skipExtendedAttrs
type: string
@@ -3070,8 +2878,7 @@ paths:
in: query
name: content
type: string
- description: When true, run audio/video metadata extraction, subtitles, and
directory media batch processing
- description: Extract audio/video metadata if true
in: query
name: metadata
type: string
@@ -3260,7 +3067,7 @@ paths:
consumes:
- application/json
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.
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.
**Request body parameters:**
- **fromSource** (string, required): Source name where the paths to archive live. Example: `"default"`
@@ -3305,13 +3112,6 @@ paths:
additionalProperties:
type: string
type: object
"413":
description: Request Entity Too Large (archive size exceeds maxArchiveSizeGB
limit)
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
@@ -3482,50 +3282,6 @@ paths:
summary: Get directory items
tags:
- Resources
/api/resources/pause:
post:
consumes:
- application/json
description: Sets a short-lived server flag for the given source and path. When
the active chunk POST then fails (e.g. client abort), the server trims the
incomplete chunk but keeps the temp partial file for resume.
parameters:
- description: Destination path (same as upload)
in: query
name: path
required: true
type: string
- description: Source name (same as upload)
in: query
name: source
required: true
type: string
produces:
- application/json
responses:
"200":
description: Pause registered
"400":
description: Bad request
schema:
additionalProperties:
type: string
type: object
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"404":
description: Source not found
schema:
additionalProperties:
type: string
type: object
summary: Register graceful chunked upload pause
tags:
- Resources
/api/resources/preview:
get:
consumes:
@@ -3593,7 +3349,7 @@ paths:
consumes:
- application/json
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.
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.
**Request body parameters:**
- **fromSource** (string, required): Source name where the archive file lives. Example: `"default"`
@@ -3637,13 +3393,6 @@ paths:
additionalProperties:
type: string
type: object
"413":
description: Request Entity Too Large (archive size exceeds maxArchiveSizeGB
limit)
schema:
additionalProperties:
type: string
type: object
"500":
description: Internal server error
schema:
@@ -4102,14 +3851,6 @@ paths:
in: query
name: scope
type: string
- description: Unix seconds; only results modified strictly before this time
in: query
name: olderThan
type: integer
- description: Unix seconds; only results modified on or after this time
in: query
name: newerThan
type: integer
- description: User session ID, add unique value to prevent collisions
in: header
name: SessionId
@@ -4237,9 +3978,7 @@ paths:
put:
consumes:
- application/json
description: Updates the details of a user identified by ID. When updating the
target user's password the actor must send their current password in the X-Password
header
description: Updates the details of a user identified by ID.
parameters:
- description: user ID to update
in: query
@@ -4249,11 +3988,6 @@ paths:
in: query
name: id
type: string
- description: Actor's current password (URL-encoded); required when changing
a password user's password
in: header
name: X-Password
type: string
- description: User data to update
in: body
name: data
@@ -4269,13 +4003,6 @@ paths:
additionalProperties:
type: string
type: object
"401":
description: Unauthorized - invalid or missing actor password for password
change
schema:
additionalProperties:
type: string
type: object
"403":
description: Forbidden
schema:
@@ -4304,33 +4031,6 @@ paths:
schema:
$ref: '#/definitions/http.HttpResponse'
summary: Health Check
/public/api/media/metadata:
get:
parameters:
- description: Share hash
in: query
name: hash
required: true
type: string
- description: Path within the share
in: query
name: path
type: string
- description: When true, include embedded album art bytes in audio metadata
(heavier)
in: query
name: albumArt
type: boolean
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/iteminfo.ExtendedFileInfo'
summary: Directory with media metadata (public share)
tags:
- Shares
/public/api/resources:
get:
consumes:
@@ -4756,43 +4456,6 @@ paths:
summary: Get directory items (public share)
tags:
- Shares
/public/api/resources/pause:
post:
consumes:
- application/json
description: Same as authenticated pause, for upload shares. Uses hash and path
query parameters like other public resource APIs.
parameters:
- description: Share hash
in: query
name: hash
required: true
type: string
- description: Path within the share (same as upload)
in: query
name: path
required: true
type: string
produces:
- application/json
responses:
"200":
description: Pause registered
"403":
description: Forbidden
schema:
additionalProperties:
type: string
type: object
"404":
description: Source not found
schema:
additionalProperties:
type: string
type: object
summary: Register graceful chunked upload pause (public share)
tags:
- Shares
/public/api/resources/preview:
get:
consumes:
+1265 -306
View File
File diff suppressed because it is too large. Load diff
+2 -5
View File
@@ -16,8 +16,6 @@
"lint:fix": "eslint --fix --ext .js,.vue,.ts src/",
"i18n:sync": "node ./scripts/sync-translations.js",
"i18n:check": "node ./scripts/sync-translations.js --check",
"i18n:enforce-order": "node scripts/sync-translations.js --enforce-order",
"i18n:cleanup": "node scripts/sync-translations.js --cleanup",
"format": "prettier --write .",
"test": "vitest run "
},
@@ -29,6 +27,7 @@
"css-vars-ponyfill": "^2.4.3",
"dompurify": "^3.2.4",
"epubjs": "^0.3.93",
"file-loader": "^6.2.0",
"glob": "^9.3.5",
"highlight.js": "^11.11.1",
"mammoth": "^1.9.1",
@@ -44,8 +43,7 @@
"vue-router": "^4.3.0"
},
"overrides": {
"minimatch": "^10.2.1",
"@xmldom/xmldom": "^0.8.12"
"minimatch": "^10.2.1"
},
"devDependencies": {
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
@@ -60,7 +58,6 @@
"eslint-plugin-vue": "^9.24.0",
"fs-extra": "^11.3.0",
"jsdom": "^25.0.1",
"typescript": "^6.0.0",
"vite": "^6.2.0",
"vite-plugin-checker": "^0.10.3",
"vite-plugin-compression2": "^1.0.0",
+2 -7
View File
@@ -43,12 +43,13 @@ server:
ignoreSymlinks: false # Excludes symbolic links
defaultUserScope: "/" # defaults to root of index "/" should match folders under path
defaultEnabled: true # should be added as a default source for new users?
createUserDir: false # create a user directory for each user under defaultUserScope + username
useLogicalSize: false # calculate sizes based on logical size instead of disk utilization (du -sh), folders will be 0 bytes when empty.
externalUrl: "" # used by share links if set (eg. http://mydomain.com)
internalUrl: "" # used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
cacheDir: "tmp" # path to the cache directory, used for thumbnails and other cached files
cacheDirCleanup: false # whether to automatically cleanup the cache directory. Note: docker must also mount a persistent volume to persist the cache (default: false)
maxArchiveSize: 20 # maximum archive/unarchive size in GB. 0 means no limit. (default: 20)
maxArchiveSize: 50 # max pre-archive combined size of files/folder that are allowed to be archived (in GB)
filesystem: # filesystem settings
createFilePermission: "644" # Unix permissions like 644, 755, 2755 (default: 644) validate:required,file_permission
createDirectoryPermission: "755" # Unix permissions like 755, 2755, 1777 (default: 755) validate:required,file_permission
@@ -57,7 +58,6 @@ server:
cacheSizeMB: 32 # size of the SQLite cache in MB
walMode: false # enable the more complex WAL journaling mode. Slower, more memory usage, but better for deployments with constant user activity.
disableReuse: false # enable to always create a new indexing database on startup.
startupIntegrityCheck: "quickCheck" # the method used to check the integrity of the index database on startup (default: quickCheck) validate:omitempty,oneof=quickCheck probe off
disableWebDAV: false # disable webdav support (default: false)
auth:
tokenExpirationHours: 2 # time in hours each web UI session token is valid for. Default is 2 hours.
@@ -156,7 +156,6 @@ 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
locale: "en" # language to use: eg. de, en, or fr
viewMode: "normal" # view mode to use: eg. normal, list, grid, or compact
@@ -174,14 +173,12 @@ userDefaults:
disableHideSidebar: false # keep sidebar open when previewing files
image: true # show thumbnails for image files
video: true # show thumbnails for video files
audio: true # show thumbnails for audio files
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering
office: true # show thumbnails for office files
popup: true # show larger popup preview when hovering over thumbnail
autoplayMedia: true # autoplay media files in preview
defaultMediaPlayer: false # disable the styled feature-rich media player for browser default
folder: true # show thumbnails for folders that have previewable contents
models: true # show live thumbnails for 3D models files
permissions:
api: false # allow api access
admin: false # allow admin access
@@ -194,7 +191,6 @@ userDefaults:
loginMethod: "password" # login method to use: eg. password, proxy, oidc
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
fileLoading: # upload and download settings
maxConcurrentUpload: 10
uploadChunkSizeMb: 10
@@ -204,7 +200,6 @@ userDefaults:
customTheme: "" # Name of theme to use chosen from custom themes config.
showSelectMultiple: false # show select multiple files on desktop
debugOffice: false # debug onlyoffice editor
preferEditorForMarkdown: false # prefer editor first for markdown files instead of the Markdown Viewer.
integrations:
office: # validate:omitempty
url: "" # The URL to the OnlyOffice Document Server, needs to be accessible to the user. validate:required
+37 -317
View File
@@ -1,89 +1,6 @@
/*
* LoadingSpinner.vue + initial index.html shell.
* Embedded in index.html via the backend; also @import'd from src/css/styles.css for the Vue app.
*
* Spinner glyph: filled SVG path (data URI mask) so ring thickness tapers along the arc — not alpha-only.
* Regenerate: polygon from outer/inner circular arcs (~120° sweep) in viewBox 0 0 100 100; mirror X if spin/orientation needs flipping.
*/
/* -------------------------------------------------------------------------- */
/* Core box */
/* -------------------------------------------------------------------------- */
.loader {
box-sizing: border-box;
margin: 0 auto;
border: none;
overflow: visible;
position: relative;
display: block;
border-radius: 50%;
}
/* -------------------------------------------------------------------------- */
/* Tapered ring: solid fill + SVG mask (true stroke-width taper, not alpha) */
/* Path is mirrored so the thick head leads when rotating clockwise (W3C +angle = CW). */
/* -------------------------------------------------------------------------- */
.loader__comet {
pointer-events: none;
position: absolute;
inset: 0;
display: block;
direction: ltr;
background-color: var(--spinner-color, var(--primaryColor));
/* Mirrored annulus-sector polygon, 120° sweep, viewBox 0 0 100 100 */
--fb-loader-taper-mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20100%20100%22%3E%3Cpath%20fill%3D%22black%22%20d%3D%22M57.47%2C7.65%20L54.99%2C7.29%20L52.50%2C7.07%20L50.00%2C7.00%20L47.50%2C7.07%20L45.01%2C7.29%20L42.53%2C7.65%20L40.08%2C8.16%20L37.67%2C8.81%20L35.29%2C9.59%20L32.97%2C10.52%20L30.70%2C11.57%20L28.50%2C12.76%20L26.37%2C14.07%20L24.32%2C15.51%20L22.36%2C17.06%20L20.49%2C18.72%20L18.72%2C20.49%20L17.06%2C22.36%20L15.51%2C24.32%20L14.07%2C26.37%20L12.76%2C28.50%20L11.57%2C30.70%20L10.52%2C32.97%20L9.59%2C35.29%20L8.81%2C37.67%20L8.16%2C40.08%20L7.65%2C42.53%20L7.29%2C45.01%20L7.07%2C47.50%20L7.00%2C50.00%20L7.07%2C52.50%20L7.29%2C54.99%20L7.65%2C57.47%20L8.16%2C59.92%20L8.81%2C62.33%20L9.59%2C64.71%20L10.16%2C64.50%20L9.56%2C62.11%20L9.12%2C59.69%20L8.81%2C57.26%20L8.65%2C54.83%20L8.63%2C52.41%20L8.75%2C50.00%20L9.01%2C47.61%20L9.41%2C45.26%20L9.94%2C42.94%20L10.61%2C40.66%20L11.40%2C38.44%20L12.32%2C36.28%20L13.36%2C34.19%20L14.51%2C32.18%20L15.77%2C30.24%20L17.14%2C28.39%20L18.60%2C26.63%20L20.16%2C24.96%20L21.81%2C23.40%20L23.53%2C21.95%20L25.33%2C20.60%20L27.20%2C19.37%20L29.12%2C18.26%20L31.10%2C17.26%20L33.12%2C16.39%20L35.18%2C15.64%20L37.27%2C15.02%20L39.38%2C14.52%20L41.50%2C14.15%20L43.64%2C13.91%20L45.77%2C13.79%20L47.89%2C13.79%20L50.00%2C13.92%20L52.09%2C14.18%20L54.14%2C14.55%20L56.16%2C15.04Z%22%2F%3E%3C%2Fsvg%3E");
-webkit-mask-image: var(--fb-loader-taper-mask);
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-image: var(--fb-loader-taper-mask);
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
transform-origin: 50% 50%;
transform-box: fill-box;
animation: fb-spinner-rotation var(--animation-duration, 1s) linear infinite;
}
@keyframes fb-spinner-rotation {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* -------------------------------------------------------------------------- */
/* Size variants */
/* -------------------------------------------------------------------------- */
.loader--xsmall {
width: 12px;
height: 12px;
}
.loader--small {
width: 24px;
height: 24px;
}
.loader--medium {
width: 48px;
height: 48px;
}
.loader--large {
width: 96px;
height: 96px;
}
/* -------------------------------------------------------------------------- */
/* Initial full-page shell (index.html) */
/* -------------------------------------------------------------------------- */
/* NOTE: This file only contains styles needed by index.html.
All other loader styles (base .loader, size variants, modes, etc.)
are defined in LoadingSpinner.vue as scoped styles.*/
body > .loader {
position: fixed;
top: 0;
@@ -94,256 +11,59 @@ body > .loader {
align-items: center;
justify-content: center;
z-index: 9999;
transition: 0.1s ease opacity;
transition: .1s ease opacity;
pointer-events: none;
/* Reset loader styles - spinner will be in pseudo-element */
border: none;
animation: none;
border-radius: 0;
margin: 0;
}
body > .loader.loader--xsmall,
body > .loader.loader--small,
body > .loader.loader--medium,
body > .loader.loader--large {
width: 100%;
height: 100%;
}
body > .loader .loader__comet {
position: relative;
inset: auto;
flex-shrink: 0;
}
body > .loader.loader--xsmall .loader__comet {
width: 12px;
height: 12px;
}
body > .loader.loader--small .loader__comet {
width: 24px;
height: 24px;
}
body > .loader.loader--medium .loader__comet {
body > .loader::before {
content: '';
width: 48px;
height: 48px;
border-radius: 50%;
border-top: 3px solid var(--spinner-color, var(--primaryColor));
border-right: 3px solid transparent;
border-bottom: 3px solid transparent;
box-sizing: border-box;
animation: spinner-rotation var(--animation-duration, 1s) linear infinite;
}
body > .loader.loader--large .loader__comet {
/* Size variants for body > .loader */
body > .loader.loader--xsmall::before {
width: 12px;
height: 12px;
border-width: 1.5px;
}
body > .loader.loader--small::before {
width: 24px;
height: 24px;
border-width: 2px;
}
body > .loader.loader--medium::before {
width: 48px;
height: 48px;
border-width: 3px;
}
body > .loader.loader--large::before {
width: 96px;
height: 96px;
border-width: 4px;
}
body > .loader.done {
opacity: 0;
}
/* -------------------------------------------------------------------------- */
/* Pulse mode */
/* -------------------------------------------------------------------------- */
.loader--pulse {
border: none;
background: var(--spinner-color, var(--primaryColor));
animation: fb-pulse-scale var(--animation-duration, 0.75s) ease infinite;
}
@keyframes fb-pulse-scale {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(0.95);
opacity: 0.8;
}
}
/* -------------------------------------------------------------------------- */
/* Bounce mode */
/* -------------------------------------------------------------------------- */
.loader--bounce {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3em;
border: none;
background: transparent;
animation: none;
}
.loader--bounce::before,
.loader--bounce::after {
content: "";
width: 0.6em;
height: 0.6em;
background-color: var(--spinner-color, var(--primaryColor));
border-radius: 50%;
display: inline-block;
animation: fb-bounce-scale var(--animation-duration, 0.75s) infinite ease-in-out both;
}
.loader--bounce::before {
animation-delay: -0.32s;
}
.loader--bounce::after {
animation-delay: -0.16s;
}
.loader--bounce .bounce-dot {
width: 0.6em;
height: 0.6em;
background-color: var(--spinner-color, var(--primaryColor));
border-radius: 50%;
display: inline-block;
animation: fb-bounce-scale var(--animation-duration, 0.75s) infinite ease-in-out both;
}
@keyframes fb-bounce-scale {
0%,
80%,
100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
.loader--bounce.loader--xsmall::before,
.loader--bounce.loader--xsmall::after,
.loader--bounce.loader--xsmall .bounce-dot {
width: 0.3em;
height: 0.3em;
}
.loader--bounce.loader--small::before,
.loader--bounce.loader--small::after,
.loader--bounce.loader--small .bounce-dot {
width: 0.45em;
height: 0.45em;
}
.loader--bounce.loader--medium::before,
.loader--bounce.loader--medium::after,
.loader--bounce.loader--medium .bounce-dot {
width: 0.6em;
height: 0.6em;
}
.loader--bounce.loader--large::before,
.loader--bounce.loader--large::after,
.loader--bounce.loader--large .bounce-dot {
width: 0.9em;
height: 0.9em;
}
/* -------------------------------------------------------------------------- */
/* Placeholder mode */
/* -------------------------------------------------------------------------- */
.loader--placeholder {
display: flex;
flex-direction: column;
gap: 0.5em;
border: none;
animation: none;
width: auto;
height: auto;
border-radius: 0;
}
.placeholder-item {
background-color: rgba(128, 128, 128, 0.1);
animation: fb-placeholder-ripple 1.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite,
fb-placeholder-bg-fade 2s ease-in-out infinite;
display: block;
border-radius: 0.25em;
margin: 0;
padding: 0;
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
box-shadow 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.placeholder-item-1 {
animation-delay: 0s;
}
.placeholder-item-2 {
animation-delay: 0.36s;
}
.placeholder-item-3 {
animation-delay: 0.72s;
}
@keyframes fb-placeholder-ripple {
@keyframes spinner-rotation {
0% {
transform: scale(1);
box-shadow: none;
opacity: 0.3;
}
25% {
transform: scale(1.07);
box-shadow: inset 0 -3em 3em rgba(172, 172, 172, 0.211),
0 0 0 2px var(--alt-background), 0 4px 12px rgba(0, 0, 0, 0.15);
opacity: 0.6;
}
50% {
transform: scale(0.97);
box-shadow: none;
opacity: 0.3;
transform: rotate(0deg);
}
100% {
transform: scale(1);
box-shadow: none;
opacity: 0.3;
transform: rotate(360deg);
}
}
@keyframes fb-placeholder-bg-fade {
0% {
background-color: rgba(128, 128, 128, 0.1);
}
50% {
background-color: rgba(128, 128, 128, 0.4);
}
100% {
background-color: rgba(128, 128, 128, 0.1);
}
}
.loader--placeholder.loader--xsmall .placeholder-item {
width: 30px;
height: 7.5px;
min-width: 30px;
min-height: 7.5px;
}
.loader--placeholder.loader--small .placeholder-item {
width: 150px;
height: 12.5px;
min-width: 150px;
min-height: 12.5px;
}
.loader--placeholder.loader--medium .placeholder-item {
width: 200px;
height: 25px;
min-width: 200px;
min-height: 25px;
}
.loader--placeholder.loader--large .placeholder-item {
width: 400px;
height: 50px;
min-width: 400px;
min-height: 50px;
}
Binary file not shown.
Binary file not shown.
+4 -3
View File
@@ -5,7 +5,7 @@
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>"{{ .htmlVars.title }}"</title>
<title>{{ .htmlVars.title }}</title>
<link rel="icon" type="image/png" sizes="256x256" href="{{ .htmlVars.favicon }}">
@@ -13,7 +13,8 @@
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/roboto-normal-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/roboto-medium-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/roboto-bold-latin.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/material-symbols.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/material-icons.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ .htmlVars.staticURL }}/fonts/material-symbols-outlined.woff2" as="font" type="font/woff2" crossorigin>
<!-- Add to home screen for Android and modern mobile browsers -->
<link rel="manifest" href="{{ .htmlVars.manifestURL }}" crossorigin="use-credentials">
@@ -83,7 +84,7 @@
<body>
<div id="app"></div>
<span class="loader loader--medium" aria-busy="true"><span class="loader__comet" aria-hidden="true"></span></span>
<span class="loader loader--medium"></span>
<script type="module" src="/src/main.ts"></script>
+53 -120
View File
@@ -8,8 +8,6 @@ import * as deepl from 'deepl-node';
// Parse command line arguments
const args = process.argv.slice(2);
const checkOnly = args.includes('--check') || args.includes('-c');
const enforceOrder = args.includes('--enforce-order'); // When using this will force the order of eng, will not perform new translations. If we move keys to a different block will count as new translations and will delete it from all the languages
const cleanupOnly = args.includes('--cleanup'); // Will only delete keys that not longer exist in eng, no translation or reorder, just deletion.
// --- Configuration ---
const __filename = fileURLToPath(import.meta.url);
@@ -21,14 +19,13 @@ const targetLocaleFiles = glob.sync(path.join(localesDir, '*.json'))
.filter(file => path.basename(file) !== `${masterLanguageCode}.json`)
.filter(file => path.basename(file) !== 'is.json'); // Exclude Icelandic - DeepL doesn't support it
const requireApiKey = !checkOnly && !enforceOrder && !cleanupOnly;
const DEEPL_API_KEY = process.env.DEEPL_API_KEY;
if (requireApiKey && !DEEPL_API_KEY) {
if (!checkOnly && !DEEPL_API_KEY) {
console.error("❌ Missing DEEPL_API_KEY in environment.");
process.exit(1);
}
const translator = requireApiKey ? new deepl.Translator(DEEPL_API_KEY) : null;
const translator = checkOnly ? null : new deepl.Translator(DEEPL_API_KEY);
const deeplLangMap = {
'zh-cn': 'ZH-HANS',
@@ -98,23 +95,8 @@ async function translateText(text, targetLanguage, keyPath = '') {
}
}
// Reorder objects to match eng (master lang) key order
function reorderObject(obj, masterObj) {
// If obj is not an object, return as is
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return obj;
const newObj = {};
for (const key in masterObj) {
if (Object.prototype.hasOwnProperty.call(masterObj, key)) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
newObj[key] = reorderObject(obj[key], masterObj[key]);
}
}
}
return newObj;
}
// --- Recursive key processor ---
async function processKeys(masterObj, targetObj, targetLangCode, currentPathParts = [], doEnforceOrder, doCleanupOnly = false) {
async function processKeys(masterObj, targetObj, targetLangCode, currentPathParts = []) {
let changesMade = false;
let meaningfulChanges = 0; // Only count meaningful changes
@@ -127,17 +109,19 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
const masterValue = masterObj[key];
// Special handling for "languages" key - always copy the entire object from master
if (key === 'languages' && currentPathParts.length === 0 && !doCleanupOnly) {
if (!checkOnly && !doEnforceOrder) {
if (key === 'languages' && currentPathParts.length === 0) {
if (checkOnly) {
// Don't print or count languages object copying - it's routine
} else {
console.log(`Copying entire "languages" object from master to ${targetLangCode}.json`);
targetObj[key] = JSON.parse(JSON.stringify(masterValue)); // Deep copy
}
targetObj[key] = JSON.parse(JSON.stringify(masterValue)); // Deep copy
changesMade = true;
continue;
}
if (typeof masterValue === 'object' && masterValue !== null && !Array.isArray(masterValue)) {
if (!doCleanupOnly && (!targetObj[key] || typeof targetObj[key] !== 'object')) {
if (!targetObj[key] || typeof targetObj[key] !== 'object') {
if (checkOnly) {
console.log(`Would create missing object structure for "${currentKeyPath}" in ${targetLangCode}.json`);
meaningfulChanges++; // Creating new object structures is meaningful
@@ -149,55 +133,41 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
}
changesMade = true;
}
if (targetObj[key] && typeof targetObj[key] === 'object') {
const result = await processKeys( masterValue, targetObj[key], targetLangCode, currentPathPartsNext,
doEnforceOrder, doCleanupOnly);
if (result == "UNSUPPORTED") {
const result = await processKeys(masterValue, targetObj[key], targetLangCode, currentPathPartsNext);
if (result == "UNSUPPORTED") {
console.log(`Skipping translation for "${targetLangCode}" due to unsupported structure.`);
return "UNSUPPORTED";
}
if (typeof result === 'number') {
meaningfulChanges += result;
changesMade = true;
} else if (result) {
changesMade = true;
}
}
} else if (!doCleanupOnly) {
// Only handle non‑object values
if (typeof masterValue === 'string') {
const existingTargetValue = targetObj[key];
const isMissing = !Object.prototype.hasOwnProperty.call(targetObj, key) || existingTargetValue === '' || existingTargetValue === null;
if (isMissing) {
if (checkOnly) {
console.log(`Would translate "${currentKeyPath}" for ${targetLangCode}.json`);
meaningfulChanges++; // Translation is meaningful
} else if (doEnforceOrder) {
// Skip translations for new keys
console.log(`Skipping new key "${currentKeyPath}" for ${targetLangCode}.json`);
} else {
// Sync translation
const result = await translateText(masterValue, targetLangCode, currentKeyPath);
if (result == "") {
return "UNSUPPORTED";
}
targetObj[key] = result;
changesMade = true;
if (typeof result === 'number') {
meaningfulChanges += result;
changesMade = true;
} else if (result) {
changesMade = true;
}
} else if (typeof masterValue === 'string') {
if (!targetObj.hasOwnProperty(key) || targetObj[key] === '' || targetObj[key] === null) {
if (checkOnly) {
console.log(`Would translate "${currentKeyPath}" for ${targetLangCode}.json`);
meaningfulChanges++; // Translation is meaningful
} else {
const result = await translateText(masterValue, targetLangCode, currentKeyPath);
if (result == "") {
return "UNSUPPORTED";
}
targetObj[key] = result;
}
} else {
if (!Object.prototype.hasOwnProperty.call(targetObj, key)) {
if (checkOnly) {
console.log(`Would copy key "${currentKeyPath}" (non-string) from English to ${targetLangCode}.json`);
meaningfulChanges++; // Copying non-string values is meaningful
} else {
console.log(`Key "${currentKeyPath}" (non-string) missing in ${targetLangCode}.json. Copying from English.`);
targetObj[key] = masterValue;
}
changesMade = true;
changesMade = true;
}
} else {
if (!targetObj.hasOwnProperty(key)) {
if (checkOnly) {
console.log(`Would copy key "${currentKeyPath}" (non-string) from English to ${targetLangCode}.json`);
meaningfulChanges++; // Copying non-string values is meaningful
} else {
console.log(`Key "${currentKeyPath}" (non-string) missing in ${targetLangCode}.json. Copying from English.`);
targetObj[key] = masterValue;
}
changesMade = true;
}
}
}
@@ -207,7 +177,7 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
const keysToRemove = [];
for (const key in targetObj) {
if (Object.prototype.hasOwnProperty.call(targetObj, key)) {
if (!Object.prototype.hasOwnProperty.call(masterObj, key)) {
if (!masterObj.hasOwnProperty(key)) {
keysToRemove.push(key);
}
}
@@ -230,17 +200,9 @@ async function processKeys(masterObj, targetObj, targetLangCode, currentPathPart
// --- Main synchronization ---
async function syncAllTranslations() {
const isSync = !checkOnly && !enforceOrder && !cleanupOnly;
const isEnforceOrder = enforceOrder && !cleanupOnly;
const isCleanup = cleanupOnly;
if (checkOnly) {
console.log("--- Checking for translation changes (no translations will be performed) ---");
} else if (isCleanup) {
console.warn("--- Only obsolete keys will be removed - No translations, or reordering will happen ---");
} else if (isEnforceOrder) {
console.warn("--- Enforcing order of all files to match master language (no translations will be performed) ---");
} else if (isSync) {
} else {
console.warn("--- Using DeepL API for translation ---");
}
@@ -258,61 +220,38 @@ async function syncAllTranslations() {
for (const targetFile of targetLocaleFiles) {
const targetLangCode = path.basename(targetFile, '.json');
let targetContent = {};
let originalTargetContent = null;
let fileExisted = await fs.pathExists(targetFile);
if (fileExisted) {
try {
originalTargetContent = await fs.readJson(targetFile);
targetContent = JSON.parse(JSON.stringify(originalTargetContent)); // start from original
targetContent = await fs.readJson(targetFile);
} catch (e) {
console.warn(`Warning: Could not parse ${targetFile}. Starting fresh. Error: ${e.message}`);
targetContent = {};
originalTargetContent = null;
}
} else {
if (isCleanup) continue;
console.log(`\nTarget file ${targetFile} not found. Will create for language: ${targetLangCode}.`);
}
const result = await processKeys(masterContent, targetContent, targetLangCode, [], isEnforceOrder, isCleanup);
let reorderedContent = targetContent;
let orderChanged = false;
if (isEnforceOrder && originalTargetContent && !checkOnly) {
reorderedContent = reorderObject(targetContent, masterContent);
// Compare the reordered content with the original to see if order changed
const origStr = JSON.stringify(originalTargetContent);
const reorderedStr = JSON.stringify(reorderedContent);
if (origStr !== reorderedStr) {
orderChanged = true;
console.log(`- Reordered keys in ${targetLangCode}.json`);
}
}
const result = await processKeys(masterContent, targetContent, targetLangCode);
if (checkOnly) {
let wouldChange = false;
if (typeof result === 'number' && result > 0) {
meaningfulChanges += result;
wouldChange = true;
console.log(`Found ${result} meaningful changes needed for ${targetLangCode}.json`);
}
if (!fileExisted && !isCleanup) {
wouldChange = true;
console.log(`Would create new file for ${targetLangCode}.json`);
}
if (wouldChange) {
hasMeaningfulChanges = true;
console.log(`Found ${result} meaningful changes needed for ${targetLangCode}.json`);
} else if (!fileExisted) {
meaningfulChanges++;
hasMeaningfulChanges = true;
console.log(`Would create new file for ${targetLangCode}.json`);
} else {
// Only print if there are meaningful changes
}
} else {
const needsWrite = result || orderChanged || (!isCleanup && !fileExisted);
if (needsWrite) {
const finalContent = (isEnforceOrder && orderChanged) ? reorderedContent : targetContent;
if (result || !fileExisted) {
try {
await fs.writeJson(targetFile, finalContent, { spaces: 2 });
if (!isCleanup && !isEnforceOrder) {
console.log(`Successfully ${result ? 'updated' : 'created'} ${targetFile}`);
}
await fs.writeJson(targetFile, targetContent, { spaces: 2 });
console.log(`Successfully ${result ? 'updated' : 'created'} ${targetFile}`);
} catch (error) {
console.error(`Error writing to ${targetFile}:`, error);
}
@@ -331,13 +270,7 @@ async function syncAllTranslations() {
return 0; // Exit code 0 for no meaningful changes
}
} else {
if (isCleanup) {
console.log('\n✅ Cleanup complete (obsolete keys removed).');
} else if (isEnforceOrder) {
console.log('\n✅ Enforce order complete.');
} else {
console.log('\n✅ Translation synchronization complete (via DeepL).');
}
console.log('\n✅ Translation synchronization complete (via DeepL).');
return 0;
}
}
+1 -5
View File
@@ -132,11 +132,7 @@ export async function getApiKeys() {
const apiPath = getApiPath('auth/token/list')
return await fetchJSON(apiPath)
} catch (err) {
// ignore 404 errors
if (err.status !== 404) {
notify.showError(err.message || 'Failed to get API tokens')
throw err
}
notify.showError(err.message || 'Failed to get API tokens')
throw err
}
}
+2 -49
View File
@@ -1,7 +1,6 @@
import { fetchURL, adjustedData } from "./utils";
import { fetchURL } from "./utils";
import { notify } from "@/notify";
import { getApiPath, getPublicApiPath } from "@/utils/url.js";
import { state } from "@/store";
import { getApiPath } from "@/utils/url.js";
// GET /api/media/subtitles
export async function getSubtitleContent(source, path, subtitleName, embedded = false) {
@@ -19,50 +18,4 @@ export async function getSubtitleContent(source, path, subtitleName, embedded =
notify.showError(err.message || `Error fetching subtitle ${subtitleName}`)
throw err
}
}
// GET /api/media/metadata — directory or file with metadata; optional albumArt for embedded cover extraction.
/** @param {boolean} albumArt when true, request embedded album art in audio metadata */
/** @returns {Promise<object>} resource (adjustedData) */
export async function fetchDirectoryMediaMetadata(source, path, albumArt = false) {
const apiPath = getApiPath("media/metadata", {
source,
path,
...(albumArt ? { albumArt: "true" } : {}),
});
const res = await fetchURL(apiPath);
const data = await res.json();
return adjustedData(data);
}
// GET /public/api/media/metadata
/** @param {boolean} albumArt when true, request embedded album art in audio metadata */
/** @returns {Promise<object>} resource (adjustedData) */
export async function fetchDirectoryMediaMetadataPublic(path, hash, password = "", albumArt = false) {
const params = {
path,
hash,
...(albumArt ? { albumArt: "true" } : {}),
...(state.shareInfo.token && { token: state.shareInfo.token }),
};
const apiPath = getPublicApiPath("media/metadata", params);
const response = await fetch(apiPath, {
headers: { "X-SHARE-PASSWORD": password || "" },
});
if (!response.ok) {
const error = new Error(response.statusText);
let data = null;
try {
data = await response.json();
} catch (e) {
// ignore
}
if (data?.message) {
error.message = data.message;
}
/** @type {any} */ (error).status = response.status;
throw error;
}
const data = await response.json();
return adjustedData(data);
}
+5 -59
View File
@@ -47,31 +47,6 @@ export async function getItems(source, path, only = "") {
}
}
/**
* Registers a graceful pause for the active chunked upload at this path.
* Call immediately before aborting the chunk XHR so the server keeps partial data.
*/
export async function signalUploadPause(source, path, shareHash = null) {
if (shareHash) {
const apiPath = getPublicApiPath('resources/pause', {
hash: shareHash,
path: path,
})
const headers = {}
const sharePassword = localStorage.getItem('sharepass:' + shareHash)
if (sharePassword) {
headers['X-SHARE-PASSWORD'] = sharePassword
}
await fetchURL(apiPath, { method: 'POST', headers })
return
}
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
}
const apiPath = getApiPath('resources/pause', { path, source })
await fetchURL(apiPath, { method: 'POST' })
}
async function resourceAction(source, path, method, content) {
if (!source || source === undefined || source === null) {
throw new Error('no source provided')
@@ -219,19 +194,6 @@ export async function download(format, files, shareHash = "") {
}, 100)
}
/** Best-effort text from a failed download response (consumes body). */
async function readDownloadErrorBody(response) {
try {
const text = await response.text()
if (text && text.length > 0) {
return text.length > 300 ? `${text.slice(0, 300)}…` : text
}
} catch {
// ignore
}
return ''
}
async function downloadChunked(file, shareHash = "") {
const chunkSizeMb = state.user?.fileLoading?.downloadChunkSizeMb || 0
@@ -250,10 +212,10 @@ async function downloadChunked(file, shareHash = "") {
downloadManager.setStatus(downloadId, "downloading")
// Show download prompt if not already shown (it should already be shown by downloadFiles, but check to be safe)
const hasDownloadPrompt = state.prompts && state.prompts.some(h => h.name === 'download');
const hasDownloadPrompt = state.hovers && state.hovers.some(h => h.name === 'download');
if (!hasDownloadPrompt) {
mutations.showPrompt({ name: 'download' })
mutations.showHover({ name: 'download' })
}
const params = {
@@ -297,18 +259,7 @@ async function downloadChunked(file, shareHash = "") {
})
if (!response.ok && response.status !== 206) {
const body = (await readDownloadErrorBody(response)).trim()
const fromHttp =
body ||
[response.statusText, `HTTP ${response.status}`].filter(Boolean).join(" ").trim()
throw new Error(fromHttp || "Request failed")
}
if (!response.body) {
throw new Error(
[response.statusText, `HTTP ${response.status}`].filter(Boolean).join(" ").trim() ||
"No response body"
)
throw new Error(`Failed to download chunk: ${response.statusText}`)
}
// Track progress within the chunk using ReadableStream
@@ -352,10 +303,6 @@ async function downloadChunked(file, shareHash = "") {
throw readError; // Re-throw other errors
}
if (chunkLoaded < expectedChunkSize) {
throw new Error("Connection closed before the full chunk was received")
}
// Combine chunk parts into single ArrayBuffer
const chunk = new Uint8Array(chunkLoaded);
let position = 0;
@@ -408,9 +355,8 @@ async function downloadChunked(file, shareHash = "") {
// Don't throw error or show notification for user-initiated cancellation
return;
}
const message =
error instanceof Error ? error.message || "Unknown error" : String(error)
downloadManager.setError(downloadId, message)
downloadManager.setError(downloadId, error.message || 'Download failed')
notify.showError(`Chunked download failed: ${error.message}`)
throw error
}
}
+1 -8
View File
@@ -2,7 +2,7 @@ import { fetchURL } from "./utils";
import { notify } from "@/notify"; // Import notify for error handling
import { getApiPath } from "@/utils/url.js";
export default async function search(base, sources, query, largest = false, extraParams = {}) {
export default async function search(base, sources, query, largest = false) {
try {
// Ensure sources is an array
const sourcesArray = Array.isArray(sources) ? sources : [sources];
@@ -25,13 +25,6 @@ export default async function search(base, sources, query, largest = false, extr
params.largest = "true";
}
if (extraParams.olderThan !== undefined && extraParams.olderThan !== "") {
params.olderThan = String(extraParams.olderThan);
}
if (extraParams.newerThan !== undefined && extraParams.newerThan !== "") {
params.newerThan = String(extraParams.newerThan);
}
const apiPath = getApiPath("tools/search", params);
const res = await fetchURL(apiPath);
let data = await res.json();
+1 -9
View File
@@ -3,8 +3,7 @@ import { notify } from "@/notify";
import { getApiPath } from "@/utils/url.js";
// GET /api/tools/search
// extraParams: optional { olderThan, newerThan } — Unix timestamps in seconds (number or numeric string); modified time must be strictly before olderThan / on or after newerThan
export async function search(base, sources, query, largest = false, extraParams = {}) {
export async function search(base, sources, query, largest = false) {
try {
const sourcesArray = Array.isArray(sources) ? sources : [sources];
@@ -25,13 +24,6 @@ export async function search(base, sources, query, largest = false, extraParams
params.largest = "true";
}
if (extraParams.olderThan !== undefined && extraParams.olderThan !== "") {
params.olderThan = String(extraParams.olderThan);
}
if (extraParams.newerThan !== undefined && extraParams.newerThan !== "") {
params.newerThan = String(extraParams.newerThan);
}
const apiPath = getApiPath("tools/search", params);
const res = await fetchURL(apiPath);
let data = await res.json();
+2 -5
View File
@@ -48,8 +48,7 @@ export async function create(user) {
}
// PUT /api/users (update user)
// Optional `options.headers` (e.g. X-Password when changing a password user's password).
export async function update(user, which = ['all'], options = {}) {
export async function update(user, which = ['all']) {
const excludeKeys = ['id', 'name']
which = which.filter(item => !excludeKeys.includes(item))
if (user.username === 'anonymous') {
@@ -67,14 +66,12 @@ export async function update(user, which = ['all'], options = {}) {
}
const apiPath = getApiPath('users', { id: user.id })
const extraHeaders = options.headers || {}
await fetchURL(apiPath, {
method: 'PUT',
body: JSON.stringify({
which: which,
data: userData
}),
headers: extraHeaders,
})
})
}
+6 -6
View File
@@ -30,8 +30,8 @@
:title="label"
class="action no-select"
>
<i v-if="icon == 'table_rows_narrow' || icon == 'content_copy' || icon == 'file_copy' || icon == 'copy_all'" class="material-symbols-outlined">{{ icon }}</i>
<i v-else class="material-symbols">{{ icon }}</i>
<i v-if="icon == 'table_rows_narrow'" class="material-symbols-outlined">{{ icon }}</i>
<i v-else class="material-icons">{{ icon }}</i>
<span>{{ label }}</span>
<span v-if="counter > 0" class="counter">{{ counter }}</span>
@@ -81,15 +81,15 @@ export default {
},
actionMultiIcon() {
if (this.show) {
mutations.closeHovers();
mutations.showPrompt(this.show);
mutations.closeContextMenus();
mutations.showHover(this.show);
}
this.$emit("action");
},
action() {
if (this.show) {
mutations.closeHovers();
mutations.showPrompt(this.show);
mutations.closeContextMenus();
mutations.showHover(this.show);
}
this.$emit("action");
},
+111 -169
View File
@@ -10,25 +10,24 @@
ref="contextMenu"
v-if="showContext"
:style="centered ? {} : { top: posY + 'px', left: posX + 'px' }"
class="no-select floating-window"
class="button no-select floating-window"
:class="{ 'dark-mode': isDarkMode, 'centered': centered }"
:key="showCreate ? 'create-mode' : 'normal-mode'"
>
<div v-if="!showLimitedOptions" class="context-menu-header">
<div class="context-menu-header">
<div
class="action button clickable"
v-if="showCreateButton"
@click="toggleShowCreate"
>
<i v-if="!showCreate" class="material-symbols">add</i>
<i v-else class="material-symbols">arrow_back</i>
<i v-if="!showCreate" class="material-icons">add</i>
<i v-else class="material-icons">arrow_back</i>
</div>
<div
v-if="selectedCount > 0"
@mouseleave="hideTooltip"
@mouseenter="showTooltip($event, $t('buttons.selectedCount'))"
class="button selected-count-header"
:class="{ 'selected-count-header--circle': selectedCount <= 99 }"
>
<span>{{ selectedCount }}</span>
</div>
@@ -38,25 +37,25 @@
v-if="showCreateActions"
icon="create_new_folder"
:label="$t('files.newFolder')"
@action="showNewDirPrompt"
@action="showNewDirHover"
/>
<action
v-if="showCreateActions"
icon="note_add"
:label="$t('files.newFile')"
@action="showPrompt('newFile')"
@action="showHover('newFile')"
/>
<action
v-if="showCreateActions"
icon="file_upload"
:label="$t('general.upload')"
@action="showUploadPrompt"
@action="showUploadHover"
/>
<action
v-if="showInfo"
icon="info"
:label="$t('general.info')"
@action="showInfoPrompt"
@action="showInfoHover"
/>
<action
@@ -69,37 +68,31 @@
v-if="showArchive"
icon="archive"
:label="$t('prompts.archive')"
@action="showArchivePrompt"
@action="showArchiveHover"
/>
<action
v-if="showUnarchive"
icon="unarchive"
:label="$t('prompts.unarchive')"
@action="showUnarchivePrompt"
@action="showUnarchiveHover"
/>
<action
v-if="showShareAction"
icon="share"
:label="$t('general.share')"
@action="showSharePrompt"
@action="showShareHover"
/>
<action
v-if="showRename"
icon="edit"
icon="mode_edit"
:label="$t('general.rename')"
@action="showRenamePrompt"
@action="showRenameHover"
/>
<action
v-if="showCopy"
icon="file_copy"
icon="content_copy"
:label="$t('buttons.copyFile')"
@action="showCopyPrompt"
/>
<action
v-if="showCopyPath"
icon="copy_all"
:label="$t('buttons.copyPath')"
@action="copyPathToClipboard"
@action="showCopyHover"
/>
<action
v-if="showOpenParentFolder"
@@ -117,7 +110,7 @@
v-if="showMove"
icon="forward"
:label="$t('buttons.moveFile')"
@action="showMovePrompt"
@action="showMoveHover"
/>
<action
v-if="showSelectAll"
@@ -129,13 +122,13 @@
v-if="showDelete"
icon="delete"
:label="$t('general.delete')"
@action="showDeletePrompt"
@action="showDeleteHover"
/>
<action
v-if="showAccess"
icon="lock"
:label="$t('access.rules')"
@action="showAccessPrompt"
@action="showAccessHover"
/>
<action
v-if="showSelectMultiple"
@@ -159,20 +152,19 @@
top: '3em',
right: '1em',
}"
class="no-select floating-window"
class="button no-select floating-window"
:class="{ 'dark-mode': isDarkMode }"
>
<action icon="info" :label="$t('general.info')" @action="showInfoPrompt"/>
<action icon="info" :label="$t('general.info')" @action="showInfoHover"/>
<action v-if="showGoToRaw" icon="open_in_new" :label="$t('general.openFile')" @action="goToRaw()" />
<action v-if="shouldShowParentFolder()" icon="folder" :label="$t('buttons.openParentFolder')" @action="openParentFolder" />
<action v-if="isPreview && permissions.modify" icon="edit" :label="$t('general.rename')" @action="showRenamePromptForPreview" />
<action v-if="isPreview && permissions.modify" icon="mode_edit" :label="$t('general.rename')" @action="showRenameHoverForPreview" />
<action v-if="showWatch" icon="visibility" :label="$t('buttons.watchFile')" @action="watchFile()" />
<action v-if="hasDownload" icon="file_download" :label="$t('general.download')" @action="startDownload" />
<action v-if="showUnarchiveInOverflow" icon="folder_open" :label="$t('prompts.unarchive')" @action="showUnarchivePromptFromPreview" />
<action v-if="showUnarchiveInOverflow" icon="folder_open" :label="$t('prompts.unarchive')" @action="showUnarchiveHoverFromPreview" />
<action v-if="showEdit" icon="edit" :label="$t('general.edit')" @action="edit()" />
<action v-if="markdownPreview" icon="visibility" :label="$t('general.preview')" @action="switchToMarkdown" />
<action v-if="showSave" icon="save" :label="$t('general.save')" @action="save()" />
<action v-if="showDelete" icon="delete" :label="$t('general.delete')" @action="showDeletePrompt" />
<action v-if="showDelete" icon="delete" :label="$t('general.delete')" show="delete" />
</div>
</transition>
</template>
@@ -216,10 +208,6 @@ export default {
type: Boolean,
default: false,
},
showLimitedOptions: {
type: Boolean,
default: false,
},
items: {
type: Array,
default: null, // Array of item objects { name, path, source, isDir, type, ... }
@@ -247,7 +235,10 @@ export default {
return this.hasDownload && !this.req.isDir && !this.isShare;
},
showGoToItem() {
return this.showLimitedOptions && this.selectedCount == 1;
return this.isDuplicateFinder && this.selectedCount == 1;
},
isDuplicateFinder() {
return getters.currentView() === "duplicateFinder";
},
permissions() {
return getters.permissions();
@@ -259,69 +250,65 @@ export default {
return getters.isShare();
},
showCreateActions() {
if (this.showLimitedOptions) return false;
if (this.isDuplicateFinder) return false;
return this.showCreate && !this.isSearchActive;
},
showInfo() {
if (this.showLimitedOptions) return this.selectedCount == 1;
if (this.isDuplicateFinder) return this.selectedCount == 1;
return !this.showCreate && this.selectedCount == 1;
},
showDownload() {
if (this.showLimitedOptions) return false;
if (this.isDuplicateFinder) return false;
return !this.showCreate && this.permissions.download && this.selectedCount > 0;
},
showArchive() {
if (this.showLimitedOptions || getters.isShare()) return false;
if (this.isDuplicateFinder || getters.isShare()) return false;
if (!this.permissions.create) return false;
return !this.showCreate && this.selectedCount > 0 && !this.showUnarchive;
},
showUnarchive() {
if (this.showLimitedOptions || getters.isShare()) return false;
if (this.isDuplicateFinder || getters.isShare()) return false;
if (!this.permissions.create) return false;
if (this.selectedCount !== 1) return false;
const item = this.firstSelected;
return item && isArchivePath(item.path || item.from || item.name);
},
showShareAction() {
if (this.showLimitedOptions) return false;
if (this.isDuplicateFinder) return false;
return (this.showCreate || this.selectedCount == 1) && this.showShare;
},
showRename() {
if (this.showLimitedOptions) return false;
if (this.isDuplicateFinder) return false;
return !this.showCreate && this.selectedCount == 1 && this.permissions.modify && !this.isSearchActive;
},
showCopy() {
if (this.showLimitedOptions) return false;
if (this.isDuplicateFinder) return false;
return !this.showCreate && this.selectedCount > 0 && this.permissions.modify;
},
showCopyPath() {
if (this.showLimitedOptions) return false;
return !this.showCreate && this.selectedCount == 1 && !!state.user?.showCopyPath;
},
showOpenParentFolder() {
return !this.showCreate && this.selectedCount == 1 && (this.isSearchActive || this.showLimitedOptions);
return !this.showCreate && this.selectedCount == 1 && (this.isSearchActive || this.isDuplicateFinder);
},
showMove() {
if (this.showLimitedOptions) return false;
if (this.isDuplicateFinder) return false;
return !this.showCreate && this.selectedCount > 0 && this.permissions.modify;
},
showSelectAll() {
if (this.showLimitedOptions) return false;
if (this.isDuplicateFinder) return false;
return !this.showCreate && !this.isSearchActive && this.req?.items?.length > 0;
},
showCreateButton() {
if (this.showLimitedOptions || this.createOnly) return false;
if (this.isDuplicateFinder || this.createOnly) return false;
return !this.isSearchActive && this.permissions.create && !this.isShare;
},
showDivider() {
if (this.showLimitedOptions || this.createOnly) return false;
if (this.isDuplicateFinder || this.createOnly) return false;
if (getters.isShare()) {
return state.shareInfo?.allowCreate
}
return state.user?.permissions?.create || state.user?.permissions?.share || state.user?.permissions?.admin;
},
showSelectMultiple() {
if (this.showLimitedOptions) return false;
if (this.isDuplicateFinder) return false;
if (this.isMultiple || this.isSearchActive) {
return false;
}
@@ -337,7 +324,7 @@ export default {
return this.showEdit || this.showDelete || this.showSave || this.showGoToRaw || this.hasDownload || this.showUnarchiveInOverflow;
},
showUnarchiveInOverflow() {
if (!this.permissions.create || getters.isShare()) return false;
if (!this.permissions.archive || getters.isShare()) return false;
const req = state.req;
return req && !req.isDir && isArchivePath(req.path || req.name);
},
@@ -353,7 +340,7 @@ export default {
return cv == "markdownViewer" && this.permissions.modify;
},
showDelete() {
if (this.showLimitedOptions) return false;
if (this.isDuplicateFinder) return false;
if (this.selectedCount == 0) {
return false;
}
@@ -365,7 +352,15 @@ export default {
return this.selectedCount > 0 && this.permissions.download;
},
isPreview() {
return getters.isPreviewView();
const cv = getters.currentView();
return (
cv == "preview" ||
cv == "onlyOfficeEditor" ||
cv == "markdownViewer" ||
cv == "epubViewer" ||
cv == "docViewer" ||
cv == "editor"
);
},
showSave() {
const allowEdit = this.permissions.modify || (getters.isShare() && state.shareInfo.allowEdit);
@@ -375,7 +370,7 @@ export default {
return getters.currentPromptName() == "OverflowMenu";
},
showAccess() {
if (this.showLimitedOptions) return false;
if (this.isDuplicateFinder) return false;
if (getters.isShare()) {
return false;
}
@@ -414,10 +409,6 @@ export default {
currentPrompt() {
return getters.currentPrompt();
},
markdownPreview() {
if (getters.currentView() !== 'editor') return false;
return state.req.type === 'text/markdown';
},
},
watch: {
hasOverflowItems: {
@@ -447,13 +438,9 @@ export default {
}
},
methods: {
showInfoPrompt() {
if (!this.isPreview) {
mutations.closeHovers();
} else {
mutations.closeTopPrompt();
}
mutations.showPrompt({
showInfoHover() {
mutations.closeContextMenus();
mutations.showHover({
name: "info",
props: {
item: this.firstSelected,
@@ -463,7 +450,6 @@ export default {
goToItem() {
const item = this.firstSelected;
url.goToItem(item.source, item.path, {}, true);
mutations.closeHovers();
},
hideTooltip() {
mutations.hideTooltip();
@@ -485,14 +471,14 @@ export default {
shouldShowParentFolder() {
return this.isPreview && state.req.path != "/";
},
showAccessPrompt() {
mutations.closeHovers();
showAccessHover() {
mutations.closeContextMenus();
let sourceName = this.firstSelected?.source || state.req.source;
let path = this.firstSelected?.path || state.req.path;
if (this.firstSelected && !this.firstSelected.isDir) {
path = url.removeLastDir(path) + '/';
}
mutations.showPrompt({
mutations.showHover({
name: "access",
props: {
sourceName: sourceName,
@@ -568,21 +554,21 @@ export default {
}
this.showCreate = true;
},
showPrompt(value) {
return mutations.showPrompt(value);
showHover(value) {
return mutations.showHover(value);
},
showSharePrompt() {
mutations.closeHovers();
mutations.showPrompt({
showShareHover() {
mutations.closeContextMenus();
mutations.showHover({
name: "share",
props: {
item: this.selectedCount == 1 ? this.firstSelected : state.req
},
});
},
showRenamePrompt() {
mutations.closeHovers();
mutations.showPrompt({
showRenameHover() {
mutations.closeContextMenus();
mutations.showHover({
name: "rename",
props: {
item: this.selectedCount == 1 ? this.firstSelected : state.req,
@@ -590,11 +576,11 @@ export default {
},
});
},
showRenamePromptForPreview() {
mutations.closeTopPrompt(); // Close the ContextMenu (if it was open from preview)
showRenameHoverForPreview() {
mutations.closeTopHover(); // Close the ContextMenu (if it was open from preview)
// Get parent items from the listing
const parentItems = state.navigation.listing || [];
mutations.showPrompt({
mutations.showHover({
name: "rename",
props: {
item: state.req,
@@ -621,26 +607,26 @@ export default {
}
},
toggleMultipleSelection() {
mutations.setMultiple(true);
mutations.setMultiple(!state.multiple);
mutations.closeHovers();
},
startDownload() {
mutations.closeTopPrompt();
mutations.closeTopHover();
const items = this.providedItems;
downloadFiles(items);
},
showDeletePrompt() {
mutations.closeTopPrompt();
mutations.showPrompt({
showDeleteHover() {
mutations.closeContextMenus();
mutations.showHover({
name: 'delete',
props: {
items: this.providedItems,
},
});
},
showMovePrompt() {
mutations.closeHovers();
mutations.showPrompt({
showMoveHover() {
mutations.closeContextMenus();
mutations.showHover({
name: 'move',
props: {
items: this.providedItems,
@@ -648,9 +634,9 @@ export default {
},
});
},
showCopyPrompt() {
mutations.closeHovers();
mutations.showPrompt({
showCopyHover() {
mutations.closeContextMenus();
mutations.showHover({
name: 'copy',
props: {
items: this.providedItems,
@@ -658,41 +644,8 @@ export default {
},
});
},
async copyPathToClipboard() {
const item = this.firstSelected;
const path = item?.path || "";
if (!path) {
notify.showErrorToast(this.$t("prompts.copyToClipboardFailed"));
mutations.closeHovers();
return;
}
try {
await navigator.clipboard.writeText(path);
notify.showSuccessToast(this.$t("buttons.copySuccess"));
} catch (err) {
const textArea = document.createElement("textarea");
textArea.value = path;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
notify.showSuccessToast(this.$t("buttons.copySuccess"));
} catch (e) {
notify.showErrorToast(this.$t("prompts.copyToClipboardFailed"));
}
document.body.removeChild(textArea);
}
mutations.closeHovers();
},
showNewDirPrompt() {
mutations.closeHovers();
showNewDirHover() {
mutations.closeContextMenus();
// If the context menu was triggered on a directory, pass its path as base
const selectedItem = this.firstSelected;
let base = null;
@@ -703,22 +656,22 @@ export default {
source: selectedItem.source,
};
}
mutations.showPrompt({
mutations.showHover({
name: "newDir",
props: {
base: base,
},
});
},
showArchivePrompt() {
mutations.closeTopPrompt();
showArchiveHover() {
mutations.closeTopHover();
const items = this.providedItems.map(item => ({
path: item.path,
name: item.name,
source: item.source || state.req.source,
}));
if (items.length === 0) return;
mutations.showPrompt({
mutations.showHover({
name: "archive",
props: {
items,
@@ -727,14 +680,14 @@ export default {
},
});
},
showUnarchivePrompt() {
mutations.closeTopPrompt();
showUnarchiveHover() {
mutations.closeTopHover();
const item = this.firstSelected;
if (!item) return;
this.openUnarchivePrompt(item);
},
showUnarchivePromptFromPreview() {
mutations.closeTopPrompt();
showUnarchiveHoverFromPreview() {
mutations.closeTopHover();
const req = state.req;
if (!req) return;
this.openUnarchivePrompt({ path: req.path, source: req.source, name: req.name });
@@ -742,7 +695,7 @@ export default {
openUnarchivePrompt(item) {
const path = item.path || item.from;
const source = item.source || state.req.source;
mutations.showPrompt({
mutations.showHover({
name: "unarchive",
props: {
item: { path, source, name: item.name },
@@ -762,10 +715,10 @@ export default {
false
);
window.open(downloadUrl, "_blank");
mutations.closeHovers();
mutations.closeContextMenus();
},
watchFile() {
mutations.closeHovers();
mutations.closeContextMenus();
const source = state.req?.source || state.sources.current || "";
const path = state.req?.path || "/";
this.$router.push({
@@ -777,10 +730,7 @@ export default {
});
},
async edit() {
this.$router.replace({ hash: '#edit' });
},
async switchToMarkdown() {
this.$router.replace({ hash: '#preview' });
window.location.hash = "#edit";
},
async save() {
const button = "save";
@@ -798,10 +748,10 @@ export default {
// Don't show error notification here - API layer already showed it
buttons.done(button);
}
mutations.closeHovers();
mutations.closeContextMenus();
},
showUploadPrompt() {
mutations.closeHovers();
showUploadHover() {
mutations.closeContextMenus();
let targetPath = state.req.path;
let targetSource = state.req.source;
const selectedItem = this.firstSelected;
@@ -809,7 +759,7 @@ export default {
targetPath = selectedItem.path;
targetSource = selectedItem.source;
}
mutations.showPrompt({
mutations.showHover({
name: "upload",
props: {
targetPath: targetPath,
@@ -820,11 +770,19 @@ export default {
openParentFolder() {
const item = this.firstSelected;
const parentPath = url.removeLastDir(item.path) || "/";
url.goToItem(item.source, parentPath, {}, this.showLimitedOptions);
mutations.closeHovers();
url.goToItem(item.source, parentPath, {}, this.isDuplicateFinder);
},
selectAllItems() {
mutations.selectAllItems()
if (state.req && state.req.items && state.req.items.length > 0) {
// Clear current selection first
mutations.resetSelected();
// Add all items from current directory to selection by their indices
state.req.items.forEach((item, index) => {
mutations.addSelected(index);
});
// Close the context menu
mutations.closeContextMenus();
}
},
},
};
@@ -836,14 +794,12 @@ export default {
z-index: 1000;
background-color: var(--background);
max-width: 20em;
min-width: 13em;
min-width: 15em;
min-height: 4em;
height: auto;
display: flex;
flex-direction: column;
justify-content: center;
border-radius: 1em;
padding: 0.5em;
}
#context-menu.centered {
@@ -858,18 +814,6 @@ export default {
cursor: unset;
}
/* Circle for up to 3 digits; 1000+ keeps pill shape from .button */
.selected-count-header--circle {
box-sizing: border-box;
flex-shrink: 0;
width: 2em;
height: 2em;
min-width: 2em;
min-height: 2em;
padding: 0;
border-radius: 50%;
}
.context-menu-header > .action i {
padding: 0.25em;
}
@@ -913,7 +857,5 @@ export default {
display: flex;
align-items: center;
justify-content: space-between;
padding-left: 0.5em;
padding-right: 0.5em;
}
</style>
+257 -5
View File
@@ -7,11 +7,6 @@
'--spinner-color': color
}"
>
<span
v-if="mode === 'spinner'"
class="loader__comet"
aria-hidden="true"
></span>
<span v-if="mode === 'bounce'" class="bounce-dot"></span>
<span v-if="mode === 'bounce'" class="bounce-dot"></span>
<div v-if="mode === 'placeholder'" class="placeholder-item placeholder-item-1"></div>
@@ -46,3 +41,260 @@ export default {
},
};
</script>
<style scoped>
.loader {
width: 48px;
height: 48px;
border-radius: 50%;
display: block;
border-top: 3px solid var(--spinner-color, var(--primaryColor));
border-right: 3px solid transparent;
border-bottom: 3px solid transparent;
box-sizing: border-box;
animation: spinner-rotation var(--animation-duration, 1s) linear infinite;
margin: 0 auto;
}
@keyframes spinner-rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* ============================================
SIZE VARIANTS
============================================ */
.loader--xsmall {
width: 12px;
height: 12px;
border-width: 1.5px;
}
.loader--small {
width: 24px;
height: 24px;
border-width: 2px;
}
.loader--medium {
width: 48px;
height: 48px;
border-width: 3px;
}
.loader--large {
width: 96px;
height: 96px;
border-width: 4px;
}
/* ============================================
PULSE MODE
============================================ */
.loader--pulse {
border: none;
background: var(--spinner-color, var(--primaryColor));
animation: pulse-scale var(--animation-duration, 0.75s) ease infinite;
}
@keyframes pulse-scale {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(0.95);
opacity: 0.8;
}
}
/* ============================================
BOUNCE MODE
============================================ */
.loader--bounce {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3em;
border: none;
background: transparent;
animation: none;
}
.loader--bounce::before,
.loader--bounce::after {
content: '';
width: 0.6em;
height: 0.6em;
background-color: var(--spinner-color, var(--primaryColor));
border-radius: 50%;
display: inline-block;
animation: bounce-scale var(--animation-duration, 0.75s) infinite ease-in-out both;
}
.loader--bounce::before {
animation-delay: -0.32s;
}
.loader--bounce::after {
animation-delay: -0.16s;
}
.loader--bounce .bounce-dot {
width: 0.6em;
height: 0.6em;
background-color: var(--spinner-color, var(--primaryColor));
border-radius: 50%;
display: inline-block;
animation: bounce-scale var(--animation-duration, 0.75s) infinite ease-in-out both;
}
@keyframes bounce-scale {
0%, 80%, 100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
/* Size variants for bounce mode */
.loader--bounce.loader--xsmall::before,
.loader--bounce.loader--xsmall::after,
.loader--bounce.loader--xsmall .bounce-dot {
width: 0.3em;
height: 0.3em;
}
.loader--bounce.loader--small::before,
.loader--bounce.loader--small::after,
.loader--bounce.loader--small .bounce-dot {
width: 0.45em;
height: 0.45em;
}
.loader--bounce.loader--medium::before,
.loader--bounce.loader--medium::after,
.loader--bounce.loader--medium .bounce-dot {
width: 0.6em;
height: 0.6em;
}
.loader--bounce.loader--large::before,
.loader--bounce.loader--large::after,
.loader--bounce.loader--large .bounce-dot {
width: 0.9em;
height: 0.9em;
}
/* ============================================
PLACEHOLDER MODE
============================================ */
.loader--placeholder {
display: flex;
flex-direction: column;
gap: 0.5em;
border: none;
animation: none;
width: auto;
height: auto;
border-radius: 0;
}
.placeholder-item {
background-color: rgba(128, 128, 128, 0.1);
animation: placeholder-ripple 1.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite,
placeholder-bg-fade 2s ease-in-out infinite;
display: block;
border-radius: 0.25em;
margin: 0;
padding: 0;
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
box-shadow 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.placeholder-item-1 {
animation-delay: 0s;
}
.placeholder-item-2 {
animation-delay: 0.36s;
}
.placeholder-item-3 {
animation-delay: 0.72s;
}
@keyframes placeholder-ripple {
0% {
transform: scale(1);
box-shadow: none;
opacity: 0.3;
}
25% {
transform: scale(1.07);
box-shadow: inset 0 -3em 3em rgba(172, 172, 172, 0.211),
0 0 0 2px var(--alt-background),
0 4px 12px rgba(0, 0, 0, 0.15);
opacity: 0.6;
}
50% {
transform: scale(0.97);
box-shadow: none;
opacity: 0.3;
}
100% {
transform: scale(1);
box-shadow: none;
opacity: 0.3;
}
}
@keyframes placeholder-bg-fade {
0% {
background-color: rgba(128, 128, 128, 0.1);
}
50% {
background-color: rgba(128, 128, 128, 0.4);
}
100% {
background-color: rgba(128, 128, 128, 0.1);
}
}
/* Size variants for placeholder mode */
.loader--placeholder.loader--xsmall .placeholder-item {
width: 30px;
height: 7.5px;
min-width: 30px;
min-height: 7.5px;
}
.loader--placeholder.loader--small .placeholder-item {
width: 150px;
height: 12.5px;
min-width: 150px;
min-height: 12.5px;
}
.loader--placeholder.loader--medium .placeholder-item {
width: 200px;
height: 25px;
min-width: 200px;
min-height: 25px;
}
.loader--placeholder.loader--large .placeholder-item {
width: 400px;
height: 50px;
min-width: 400px;
min-height: 50px;
}
</style>
+4 -4
View File
@@ -19,10 +19,10 @@
}"
>
<!-- Close button - always present on every notification, separate from optional buttons array -->
<i class="material-symbols" @click="closeNotification(notification.id)">close</i>
<i class="material-icons" @click="closeNotification(notification.id)">close</i>
<div class="notification-content-wrapper">
<div class="notification-header">
<i v-if="notification.icon" class="material-symbols notification-icon">
<i v-if="notification.icon" class="material-icons notification-icon">
{{ notification.icon }}
</i>
<div class="notification-message">{{ notification.message }}</div>
@@ -255,13 +255,13 @@ export default {
border-color: rgba(255, 255, 255, 0.5);
}
.notification-item > .material-symbols:first-child {
.notification-item > .material-icons:first-child {
cursor: pointer;
font-size: 1.75em;
opacity: 0.8;
}
.notification-item > .material-symbols:first-child:hover {
.notification-item > .material-icons:first-child:hover {
opacity: 1;
}
+2 -4
View File
@@ -21,7 +21,7 @@ https://raw.githubusercontent.com/dzwillia/vue-simple-progress/master/src/compon
{{ displayed_text }}
<i
v-if="helpText && status === 'error'"
class="material-symbols-outlined tooltip-info-icon"
class="no-select material-symbols-outlined tooltip-info-icon"
@mouseenter="showTooltip"
@mouseleave="hideTooltip"
>
@@ -354,9 +354,7 @@ export default {
opacity: 0.7;
}
.tooltip-info-icon:hover,
.tooltip-info-icon:hover .material-symbols-outlined {
font-variation-settings: 'FILL' 1;
.tooltip-info-icon:hover {
opacity: 1;
}
+39 -79
View File
@@ -5,10 +5,10 @@
<!-- Close button visible when search is active -->
<button v-if="active" class="action" @click="close" :aria-label="$t('general.close')"
:title="$t('general.close')">
<i class="material-symbols">close</i>
<i class="material-icons">close</i>
</button>
<!-- Search icon when search is not active -->
<i v-else class="material-symbols">search</i>
<i v-else class="material-icons">search</i>
<!-- Input field for search -->
<input id="search-input" type="text"
@keyup.exact="keyup" @input="submit" ref="input" :autofocus="active" v-model.trim="value"
@@ -44,19 +44,17 @@
<ButtonGroup :buttons="typeSelect" @button-clicked="addToTypes" @remove-button-clicked="removeFromTypes"
:isDisabled="isTypeSelectDisabled" />
<!-- Inputs for filtering by file size -->
<div class="constraints">
<div class="sizeConstraints">
<div class="sizeInputWrapper">
<p>{{ $t("search.smallerThan") }}</p>
<input class="sizeInput" v-model="smallerThan" type="number" min="0"
placeholder="MB" />
<p>{{ $t("search.largerThan") }}</p>
<input class="sizeInput" v-model="largerThan" type="number" placeholder="MB" />
:placeholder="$t('general.number')" />
<p>MB</p> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
<div class="sizeInputWrapper">
<p>{{ $t("search.olderThanDate") }}</p>
<input class="sizeInput" v-model="modifiedOlderThan" type="date" />
<p>{{ $t("search.newerThanDate") }}</p>
<input class="sizeInput" v-model="modifiedNewerThan" type="date" />
<p>{{ $t("search.largerThan") }}</p>
<input class="sizeInput" v-model="largerThan" type="number" :placeholder="$t('general.number')" />
<p>MB</p> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
</div>
<!-- Toggle for showing preview images -->
@@ -69,11 +67,11 @@
</div>
</div>
<!-- Loading icon when search is ongoing -->
<LoadingSpinner v-if="isRunning" size="medium" />
<LoadingSpinner v-if="ongoing" size="medium" />
<!-- Message when no results are found -->
<div class="searchPrompt" v-show="isEmpty && !isRunning">
<p>{{ noneMessage }}</p>
<i class="material-symbols-outlined tooltip-info-icon" @mouseenter="showHelpTooltip"
<i class="no-select material-symbols-outlined tooltip-info-icon" @mouseenter="showHelpTooltip"
@mouseleave="hideTooltip">
help
</i>
@@ -133,8 +131,6 @@ export default {
return {
largerThan: "",
smallerThan: "",
modifiedOlderThan: "",
modifiedNewerThan: "",
noneMessage: this.$t("search.typeToSearch", { minSearchLength: globalVars.minSearchLength }),
searchTypes: "",
isTypeSelectDisabled: false,
@@ -152,7 +148,7 @@ export default {
],
toggleOptionButton: [{ label: this.$t("search.showOptions") }],
value: "",
ongoing: 0,
ongoing: false,
results: [],
reload: false,
scrollable: null,
@@ -161,27 +157,18 @@ export default {
};
},
watch: {
currentSourceState(){
this.selectedSource = state.sources.current;
},
largerThan() {
this.submit();
},
smallerThan() {
this.submit();
},
modifiedOlderThan() {
this.submit();
},
modifiedNewerThan() {
this.submit();
},
searchTypes() {
this.submit();
},
value() {
if (this.results.length) {
this.ongoing = 0;
this.ongoing = false;
this.results = [];
}
},
@@ -233,9 +220,6 @@ export default {
}
},
computed: {
currentSourceState() {
return state.sources.current;
},
eventTheme() {
return getters.eventTheme();
},
@@ -267,16 +251,17 @@ export default {
return this.results.length === 0;
},
text() {
if (this.ongoing > 0) {
if (this.ongoing) {
return "";
}
return this.$t("search.typeToSearch", { minSearchLength: globalVars.minSearchLength })
},
isRunning() {
return this.ongoing > 0;
return this.ongoing;
},
activeStates() {
const selectedItems = state.selected ? [].concat(state.selected) : [];
if (selectedItems.length === 0) {
// Return an array of all false if nothing is selected
return new Array(this.results.length).fill(false);
@@ -308,7 +293,7 @@ export default {
let result = url.extractSourceFromPath(decodeURIComponent(state.route.path));
if (this.selectedSource === "" || result.source === this.selectedSource) {
return result.path;
} else {
} else {
return "/"; // if searching on non-current source, search the whole thing
}
},
@@ -317,12 +302,11 @@ export default {
openContext(event) {
event.preventDefault();
event.stopPropagation();
mutations.showPrompt({
mutations.showHover({
name: "ContextMenu",
props: {
posX: event.clientX,
posY: event.clientY,
showLimitedOptions: true,
},
});
},
@@ -380,6 +364,9 @@ export default {
const fullPath = context + '/' + path;
return url.buildItemUrl(source, fullPath, true);
},
getIcon(mimetype) {
return getMaterialIconForType(mimetype);
},
enableOptions() {
this.hiddenOptions = false;
this.toggleOptionButton = [{ label: "Hide Options" }];
@@ -391,23 +378,6 @@ export default {
humanSize(size) {
return getHumanReadableFilesize(size);
},
/** YYYY-MM-DD from a date input → Unix seconds at 00:00:00 UTC for that calendar day. */
dateToUnixStartOfDayUTC(isoDate) {
if (isoDate === "" || typeof isoDate !== "string") {
return null;
}
const parts = isoDate.split("-");
if (parts.length !== 3) {
return null;
}
const y = Number(parts[0]);
const m = Number(parts[1]);
const d = Number(parts[2]);
if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) {
return null;
}
return Math.floor(Date.UTC(y, m - 1, d) / 1000);
},
basePath(str, isDir) {
let result = url.removeLastDir(str);
if (!isDir) {
@@ -420,6 +390,15 @@ export default {
let part = parts.pop();
return part;
},
open() {
if (!state.isSearchActive) {
mutations.closeHovers();
mutations.closeSidebar();
mutations.resetSelected();
this.resetSearchFilters;
mutations.setSearch(true);
}
},
close(event) {
this.value = "";
event.stopPropagation();
@@ -463,11 +442,12 @@ export default {
},
async submit(event) {
this.results = [];
if (event != undefined) {
event.preventDefault();
}
if (this.value === "" || this.value.length < globalVars.minSearchLength) {
this.noneMessage = this.$t("search.notEnoughCharacters", { minSearchLength: globalVars.minSearchLength });
this.ongoing = false; this.noneMessage = this.$t("search.notEnoughCharacters", { minSearchLength: globalVars.minSearchLength });
return;
}
let searchTypesFull = this.searchTypes;
@@ -477,16 +457,7 @@ export default {
if (this.smallerThan != "") {
searchTypesFull = searchTypesFull + "type:smallerThan=" + this.smallerThan + " ";
}
const dateParams = {};
const olderUnix = this.dateToUnixStartOfDayUTC(this.modifiedOlderThan);
if (olderUnix !== null) {
dateParams.olderThan = olderUnix;
}
const newerUnix = this.dateToUnixStartOfDayUTC(this.modifiedNewerThan);
if (newerUnix !== null) {
dateParams.newerThan = newerUnix;
}
this.ongoing++;
this.ongoing = true;
// Determine which sources to search
let sourcesToSearch = [];
@@ -501,21 +472,15 @@ export default {
// Only pass scope if searching a single source
const scope = sourcesToSearch.length === 1 ? this.getContext : null;
this.results = await toolsApi.search(
scope,
sourcesToSearch,
searchTypesFull + this.value,
false,
dateParams
);
this.results = await toolsApi.search(scope, sourcesToSearch, searchTypesFull + this.value);
this.ongoing--;
if (this.results.length == 0 && this.ongoing == 0) {
this.ongoing = false;
if (this.results.length == 0) {
this.noneMessage = this.$t("search.noResults");
}
},
showHelpTooltip(event) {
const helpText = `${this.$t("search.helpText1", { minSearchLength: globalVars.minSearchLength })}\n\n${this.$t("search.helpText2")}`;
const helpText = `${this.$t("search.helpText1")}\n\n${this.$t("search.helpText2")}`;
mutations.showTooltip({
content: helpText,
x: event.clientX,
@@ -689,12 +654,12 @@ export default {
width: 100%;
}
.search-input-container .material-symbols {
.search-input-container .material-icons {
font-size: 1.25em;
color: rgba(255, 255, 255, 0.7);
}
#search.active .search-input-container .material-symbols {
#search.active .search-input-container .material-icons {
color: inherit;
}
@@ -706,11 +671,6 @@ export default {
font-size: 0.95em;
}
#search-input {
width: 100%;
padding-left: 0.5em;
}
#search.active input {
color: inherit;
}
@@ -909,7 +869,7 @@ body.rtl #search .boxes h3 {
/* IE and Edge */
}
.constraints {
.sizeConstraints {
display: flex;
flex-wrap: wrap;
flex-direction: row;
+11 -76
View File
@@ -1,20 +1,15 @@
<template>
<div id="status-bar" :style="moveWithSidebar" :class="{ 'dark-mode-header': isDarkMode, 'active': showStatusBar }" @contextmenu.prevent.stop @touchstart.stop @touchend.stop>
<div class="status-content" @contextmenu.prevent.stop @touchstart.stop @touchend.stop>
<!-- Left side: selection/directory info and stats for the editor and markdown viewer -->
<div class="status-info">
<template v-if="isEditorOrMarkdownView">
<span>{{ editorStatsText }}</span>
</template>
<template v-else>
<span v-if="selectedCount > 0" class="button">{{ selectedCount }}</span>
<span v-if="selectedCount > 0">{{ selectedItemsText }}</span>
<span v-else>{{ directoryInfoText }}</span>
</template>
<div v-if="selectedCount > 0" class="status-info">
<span class="button">{{ selectedCount }}</span>
<span>{{ selectedItemsText }}</span>
</div>
<div v-else class="status-info">
<span class="directory-info">{{ directoryInfoText }}</span>
</div>
<!-- Right side: Slider to control the listing size or the font size in editor -->
<div class="status-controls">
<div v-if="currentView === 'listingView'" class="gallery-size-control">
<div v-if="showGallerySize" class="gallery-size-control">
<span class="size-label">{{ $t("general.size") }}</span>
<input
v-model="gallerySize"
@@ -27,17 +22,6 @@
@change="commitGallerySize"
/>
</div>
<div v-if="currentView === 'editor'" class="gallery-size-control">
<span class="size-label">{{ $t("general.size") }}</span>
<input
v-model="editorFontSize"
type="range"
min="10"
max="24"
step="1"
/>
<span class="size-label">{{ editorFontSize }}px</span> <!-- eslint-disable-line @intlify/vue-i18n/no-raw-text -->
</div>
</div>
</div>
</div>
@@ -55,17 +39,11 @@ export default {
};
},
computed: {
currentView() {
return getters.currentView();
},
isEditorOrMarkdownView() {
return this.currentView === 'editor' || this.currentView === 'markdownViewer';
},
showStatusBar() {
if (getters.isShare() && state.shareInfo.shareType === "upload") {
return false;
}
return this.currentView === "listingView" || this.isEditorOrMarkdownView;
return getters.currentView() === "listingView";
},
isDarkMode() {
return getters.isDarkMode();
@@ -79,6 +57,9 @@ export default {
numFiles() {
return getters.reqNumFiles();
},
showGallerySize() {
return getters.isCardView() && state.req?.items?.length > 0;
},
totalDirectorySize() {
if (!Array.isArray(state.req?.items)) return 0;
return state.req.items.reduce((total, item) => total + (item.size || 0), 0);
@@ -136,30 +117,6 @@ export default {
}
return {};
},
editorStatsText() {
const { lines, words, chars } = state.editorStats;
return [
[words, chars]
.map((v, i) => v != null && this.$t(i === 0 ? 'editor.words' : 'editor.chars', { count: v }))
.filter(Boolean)
.join(', '),
lines != null && this.$t('editor.lines', { count: lines })
].filter(Boolean).join(' | ');
},
editorFontSize: {
get() {
return state.editorFontSize;
},
set(value) {
mutations.setEditorFontSize(value);
}
},
},
mounted() {
window.addEventListener('wheel', this.handleWheel, { passive: false });
},
beforeUnmount() {
window.removeEventListener('wheel', this.handleWheel);
},
methods: {
updateGallerySize(event) {
@@ -200,28 +157,6 @@ export default {
mutations.updateCurrentUser({ viewMode: newMode });
}
},
// Ctrl + Mouse Wheel to adjust the slider sizes
handleWheel(event) {
if (!(event.ctrlKey || event.metaKey)) return;
const delta = event.deltaY > 0 ? 1 : -1; // Scroll down increases, up decreases
if (this.currentView === 'listingView') {
event.preventDefault();
let newSize = Math.min(9, Math.max(1, this.gallerySize - delta));
if (newSize !== this.gallerySize) {
this.gallerySize = newSize;
mutations.setGallerySize(newSize);
this.adjustViewMode();
}
} else if (this.currentView === 'editor') {
event.preventDefault();
let newSize = Math.min(24, Math.max(10, this.editorFontSize - delta));
if (newSize !== this.editorFontSize) {
this.editorFontSize = newSize;
}
}
}
},
};
</script>
+1 -1
View File
@@ -12,7 +12,7 @@
</template>
<script>
import { getIconClass } from "@/utils/material-symbols";
import { getIconClass } from "@/utils/material-icons";
export default {
name: "Toast",
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff. Show more