Compare commits

..
Author SHA1 Message Date
Graham Steffaniak a3b0fc0038 Upgrade ffmpeg 8.0.1 (#1634) 2025-11-20 13:50:57 -06:00
dependabot[bot]andGraham Steffaniak 695364c2df Bump golang.org/x/crypto from 0.44.0 to 0.45.0 in /backend in the go_modules group across 1 directory (#1627)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Graham Steffaniak <42989099+gtsteffaniak@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-20 13:36:46 -06:00
Graham Steffaniak 3298111560 Add exif tool (#1625) 2025-11-19 17:54:48 -06:00
Graham Steffaniak 3ca9f54de0 updated with compile safe i18n checks (#1623) 2025-11-19 16:48:44 -06:00
440 changed files with 34835 additions and 63145 deletions

No files matched your search

-39
View File
@@ -1,39 +0,0 @@
{
"name": "filebrowser",
"image": "mcr.microsoft.com/devcontainers/go:1.25-bookworm",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
},
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"containerEnv": {
"CGO_ENABLED": "1",
"FILEBROWSER_DEVMODE": "true"
},
"forwardPorts": [80],
"portsAttributes": {
"80": {
"label": "filebrowser app",
"onAutoForward": "notify"
}
},
"postCreateCommand": "make setup",
"customizations": {
"vscode": {
"extensions": [
"golang.go",
"Vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker"
]
}
}
}
-2
View File
@@ -1,6 +1,4 @@
backend/**.db
**.crt
**.key
backend/tmp
backend/dist
backend/backend*
-6
View File
@@ -80,12 +80,6 @@ jobs:
dockerfile: ./_docker/Dockerfile.playwright-no-config
- name: settings
dockerfile: ./_docker/Dockerfile.playwright-settings
- name: screenshots
dockerfile: ./_docker/Dockerfile.playwright-screenshots
- name: previews
dockerfile: ./_docker/Dockerfile.playwright-previews
- name: jwt
dockerfile: ./_docker/Dockerfile.playwright-jwt
steps:
- name: Checkout
uses: actions/checkout@v4
+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
+8 -42
View File
@@ -59,7 +59,7 @@ jobs:
images: |
gtstef/filebrowser
ghcr.io/gtsteffaniak/filebrowser
- name: Modify tags (strip 'v' and add version tags)
- name: Modify tags (strip 'v' and add 'beta'/'stable' if needed)
id: modify-json
run: |
JSON='${{ steps.meta.outputs.json }}'
@@ -68,31 +68,15 @@ jobs:
sub(":v"; ":") # Strip the 'v'
| select(test(":latest") | not)
) |
# Find all version tags with suffix (X.Y.Z-suffix) and add major.minor and major tags
.tags += [
.tags[]
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
| split(":")
| .[-1] as $version
| ($version | split("-")) as $version_split
| ($version_split[0] | split(".")) as $version_parts
| ($version_split[1:] | join("-")) as $suffix
| ($version_parts | "\(.[0]).\(.[1])") as $major_minor
| ($version_parts[0]) as $major
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
] |
# Add suffix-only tags for beta (only if tag ends with -beta)
if any(.tags[]; split(":")[-1] | endswith("-beta")) then
if any(.tags[]; test("beta")) then
.tags += [
"gtstef/filebrowser:beta",
"ghcr.io/gtsteffaniak/filebrowser:beta"
"ghcr.io/gtsteffaniak/filebrowser:beta",
"gtstef/filebrowser:latest",
"ghcr.io/gtsteffaniak/filebrowser:latest"
]
else . end |
# Add suffix-only tags for stable (and latest) (only if tag ends with -stable)
if any(.tags[]; split(":")[-1] | endswith("-stable")) then
if any(.tags[]; test("stable")) then
.tags += [
"gtstef/filebrowser:stable",
"ghcr.io/gtsteffaniak/filebrowser:stable",
@@ -154,31 +138,13 @@ jobs:
| select(test(":latest") | not) # Remove the "latest" tag
| . + "-slim" # Append "-slim" to the versioned tag
) |
# Find all version tags with suffix (X.Y.Z-suffix-slim) and add major.minor and major tags
.tags += [
.tags[]
| select(test(":[0-9]+\\.[0-9]+\\.[0-9]+-"))
| split(":")
| .[-1] as $version
| ($version | split("-")) as $version_split
| ($version_split[0] | split(".")) as $version_parts
| ($version_split[1:] | join("-")) as $suffix
| ($version_parts | "\(.[0]).\(.[1])") as $major_minor
| ($version_parts[0]) as $major
| "gtstef/filebrowser:\($major_minor)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major_minor)-\($suffix)",
"gtstef/filebrowser:\($major)-\($suffix)",
"ghcr.io/gtsteffaniak/filebrowser:\($major)-\($suffix)"
] |
# Add suffix-only tags for beta-slim (only if tag ends with -beta-slim)
if any(.tags[]; split(":")[-1] | endswith("-beta-slim")) then
if any(.tags[]; test("beta")) then
.tags += [
"gtstef/filebrowser:beta-slim",
"ghcr.io/gtsteffaniak/filebrowser:beta-slim"
]
else . end |
# Add suffix-only tags for stable-slim (only if tag ends with -stable-slim)
if any(.tags[]; split(":")[-1] | endswith("-stable-slim")) then
if any(.tags[]; test("stable")) then
.tags += [
"gtstef/filebrowser:stable-slim",
"ghcr.io/gtsteffaniak/filebrowser:stable-slim"
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ghcr.io/gtsteffaniak/playwright-base
tags: chromium
tags: latest
- name: Build and push playwright-base
uses: docker/build-push-action@v6
with:
-4
View File
@@ -1,6 +1,4 @@
*.db
*.db.old
*.sqlite
*.bak
*.log
*.mjs
@@ -20,7 +18,6 @@ frontend/tsconfig.tsbuildinfo
/frontend/test-results
/frontend/playwright-files
/frontend/screenshots
/frontend/generated
/frontend/tests/playwright-files/demo*
/frontend/loginAuth.json
/backend/vendor
@@ -30,7 +27,6 @@ frontend/tsconfig.tsbuildinfo
/backend/srv
/backend/http/dist
/backend/http/embed
/backend/debug
.DS_Store
node_modules
-485
View File
@@ -2,499 +2,14 @@
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.9
**Security**:
- [Critical] Unauthenticated Path Traversal in Public Share Delete Allows Arbitrary File Deletion GHSA-fwj3-42wh-8673
- [Moderate] Stored XSS via SVG File in Public Share (Missing CSP Header) GHSA-mmpx-jh39-wrv6
**BugFixes**:
- Fix context menu items and adjusted when items show to more accurately reflect permissions.
## v1.3.8
**BugFixes**:
- Quick download icon style after icon change.
- Missing error popup for resource creatoin actions (upload/create)
- EnforcedOtp login failure until restart (#2330)
- Thumbnails for Folders only display sporadically (#2353)
- Unwanted user scope change for users with non-default scopes (#2347)
- Fix sidebar source info totals (#2321) (#2322) (#982)
- Error uploading a large number of photos -- only 100 items get uploaded (#2348)
## v1.3.7
**Notes**:
- creating/deleting password-based user requires reauthentication (#2112)
**BugFixes**:
- TOTP works for admin but fails for standard users on re-login until Docker is restarted (#2330)
- No Loginfields shown if password authentification is set to false (#2331)
## v1.3.6
**BugFixes**:
- file watcher not working for files and not respecting line count (#2314)
- "source" type link shows up multiple times when adding sidebar links if user has multiple sources.
- JWT ES256 results in error (#2188)
- single file shares not loading editor text content (#2122)
## v1.3.5
**Notes**:
- any password change or admin user property change for a password user requires reauthentication (#2112)
**BugFixes**:
- PWA icon fixes (#2292)
- deny-rule'd folders visible in directory listings (regression from v1.2.4-stable) (#2295)
- OTP and password requirement fixes (#2112) (#2263)
- CLI not picking up config properly
- Storage usage numbers on first load adjusted.
- Some items in userDefaults are not setting defaults for new users properly #2278
- Fix copy to clipboard and simplify code (#2281) (#2274)
- Fix search shortcut `/` and pdf previews (#2307)
- Special permissions bit not set when declared in createDirectoryPermission (#2283)
- Execute permission altered after editing sh script with FB Quantum (#2309)
- Source is red but working (#2289)
## 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)
- Copy file path to clipborad through right click (#2204)
**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.
**New Features**:
- New Sidebar Features
- Sidebar tree navigation (#2006) (#350)
- Source usage to be customized to show os-reported values rather than calculated. This can be changed per source by editing the source link in the sidebar. (#1266) (#982)
- Archive/Unarchive actions in UI (#1252) (#335) (#1569)
- new api to archive/unarchive files on the server
- requires `create` user permissions
- archiving actions respect `server.maxArchiveSize`
- Added share icon to items that are shared (#1420)
- Authentication enhancements
- LDAP login support with OIDC feature parity. (#591)
- userGroup for OIDC and LDAP, only users in a group will get access. (#1964)
- Add JWT token authentication support (#1364)
- Enhanced thumbnail and item previews
- Added ability to show "motion preview" for folders with multiple child items that have previews. cycles through the first 4 images.
- support for reading embedded images from raw image or heic/heif files (#215)
- reorganized and simplified thumbnail settings in profile settings (#1968)
- removed `highQuality` thumbnail option which only affected gallery view. Now its always enabled.
- improved caching for unsupported images, the same file won't be attempted again with the same modtime.
- supports 3d model previews.
- FileWatcher also supports watching directories
- Support previews for 3D model files (STL, OBJ, 3MF, etc.) (#1273)
- supported formats via threejs: GLTF, GLB, OBJ, STL, PLY, DAE (Collada), 3MF, 3DS, USDZ, USD, USDA, USDC, AMF, VRML, WRL, VTK, VTP, PCD, XYZ, VOX, KMZ
- supports animations (for formats that contain them)
- supports embedded textures, external neighboring file textures, or textures in `/textures` subdirectory
- Enhanced prompts
- All prompts have a taskbar with a close button
- Prompts can be freely moved by dragging taskbar
- Prompt styling has been updated
- Clicking outside of prompts no longer automatically closes them.
- add webdav support (#209) -- thanks to @reddac for (#1764)
- see docs on how to use
- requires api an un-customized api token as password
- respects access rules
- requires download permission to view and modify/create/delete permission to modify.
**Notes**:
- Docker images default to `filebrowser` user instead of root
- reorganized api routes
- consolidated tags for swagger to be more accurately grouped
- tools are all behind `/api/tools` routes
- `/api/raw` is deprecated (but functional). The `/api/resources/download` route will be used instead.
- `/api/preview` has been removed and replaced with `/api/resources/preview`
- `/api/onlyoffice` have been replaced with `/api/office`
- `/api/shares` has been moved to `/api/share/list`
- `/api/auth/tokens` has been moved to `/api/auth/token/list` and `/api/auth/token` added to get specific token info
- `PUT /api/token` has been moved to `POST /api/token`
- `/public/api/shareinfo` has been moved to `/public/api/share/info`
- `POST /resources/bulk/delete` api has been moved to `DELETE /resources/bulk` (#1984)
**BugFixes**:
- Long folder names get cut off at top navigation bar (#1934)
## v1.2.2-stable
**Security**:
- Patched Stored XSS in public share page via unsanitized share metadata (text/template misuse)
**New Features**:
- "Divider" option in sidebar links to add a text or divider between links ()
- shares offer a "go to source Location" sidebar link and button when editing a share.
**Notes**:
- Share edit/delete permissions are scoped to the user's shares rather than global (#2050)
**BugFixes**:
- fixed the requirement that the database path needed to be set in the config file, now it loads `FILEBROWSER_DATABASE` value by default, fallback to config file property.
- Error downloading zipped directory: no such file or directory (users with scope) #2015
## v1.2.1-stable
**BugFixes**:
- startup icon generation order issue (#2019)
- login button on shares not showing (#2028)
- removed file watcher link in context menu for shares (#2025)
- Direct download link for single-file shares generates incorrect URL (#2026)
- Deleting files causes error V1.2.0 #2039
## v1.2.7-beta
**BugFixes**:
- head > title > infinitely duplicating titles (#2016)
- Proxy auth not working with Nginx, stuck at logo (#2013)
## v1.2.6-beta
**Security**:
- resolves https://github.com/gtsteffaniak/filebrowser/security/advisories/GHSA-8vrh-3pm2-v4v6
## v1.2.5-beta
**Notes**:
- Dependency Updates for frontend and backend packages
- Upgrade to go 1.26
- file list prompt interaction behavior, single-click always selects, double-click navigates. (#1911)
**BugFixes**:
- .ssa and .ass subtitle files get their styling removed during conversion (#1933)
- custom icons issue
- new database message for noauth (#1935)
## v1.2.4-beta
**Notes**:
- Changed `/api/media/subtitles` api endpoint to better support subtitles.
- Correcting some errors in French language (#1947)
- add Dutch `nl.json` for frontend i18n -- thanks @Stephan-P (#1957)
- share banner and icon images automatically serve a scaled down 1024x1024
**BugFixes**:
- Can't access directories with space (and possibly other special characters) when browsing a public link share (#1956)
- External ASS subtitles are not usable in web preview (#1933)
- Filename blocking rename/move (#1950)
- enforcedOtp: true blocks all user settings updates with HTTP 400 (#1962)
- Share favicon URL not showing up and Share description causing endless loading (#1911)
- folder sizes not updated for changes to filesystem outside of filebrowser (#1974)
## v1.2.3-beta
**Notes**:
- Removed upload api behavior to assume paths ending in "/" are folders, strictly uses isDir query param
- renamed public upload api query param from `targetPath` to `path`. see swagger docs.
**BugFixes**:
- fix stuck motion preview popup window issue
- Chunked uploads ignore User Scope and are saved to Source Root (#1894)
- share delete with user scope issue
- fix favicon pwa icon, having the PWA respect the favicon/loginIcon (#1899)
- delete prompt icons show image previews
## v1.2.2-beta
**New Features**:
- Resizable sidebar (#1896)
- OIDC Authentication: Change Button Text via `frontend.oidcLoginButtonText` (#1708)
- improved favicon processing (#1899)
- supports more formats
- supports larger images
- automatcially generates multiple favicon sizes on startup for non-svg images. Custom svg favicons need a companion *.png to exist broad compatibilty
- Enhanced media playback: Ability to control the queue from your device's lock screen and notification panel - Will also show
metadata of the current playing media if available (#1917)
**Notes**:
- Better text file content detection (#1726)
- More url encoding changes for API which should make things more consistent. Open issues if you see path/source not found errors.
- adjustments to the startup behavior for sqlite index for reusing the previous database on startup
- CTRL + B disables sticky sidebar forever (#1869)
- added context menu back to duplicate finder
- Sharing a link for uploads - folder/file access and UX polishing (#1902)
- improved listing view and scrolling performance
- improved image viewer which will utilize recent thumbnails as a placeholder when loading the full image.
- Small reorganization of "Share" settings to make the popup clear (#1826)
**BugFixes**:
- Added docker default `FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"` back -- mistakenly removed. see https://filebrowserquantum.com/en/docs/getting-started/config/#3-default-locations. (#1891)
- Fix brand text in login screen (#1898)
- Sidebar links cointains urls with 2x repeated source name (#1847)
- 2 factor auth getting overwritten if you edit the auth from admin (#1819)
- New created user's files are listing error, Probably related with language setting. (#1565)
- thumbnail generation cpu/memory and concurrency bug.
- added 75MB filesize limit for image previews
- optimized concurrency for large vs small images
## v1.2.1-beta
**New Features**:
- Global disable onlyoffice editor via "*" file option to disable all files for a specific user. (#1533)
**Notes**:
- upgraded imaging package and improved thumbnail generation performance. (#1797) (#1850)
- updated download api to use repeated `file` query param for path instead of `files` with comma. See swagger for details (#1881)
**BugFixes**:
- OnlyOffice stopped working after 1.2.0 upgrade due to download api changes
- Search result link to wrong path when search context other than / (#1863)
- File actions doesn't work if using user scopes (#1879)
- Drop items into folders doesn't work (#1860)
- duplicate finder context menu (#1862)
- File list in previews doesn't show the parent items (#1861)
## v1.2.0-beta
This is a major version update with many code changes which could cause unexpected behavior. Upgrades should proceed with caution and report any undesriable behavior by opening an issue on github. The index is now fully in sqlite database - see [Indexing Overview](https://filebrowserquantum.com/en/docs/features/indexing/) for more information.
Sorry for the delay -- a lot of effort went into this release, specifically about fine-tuning sqlite indexing. It took me a long time to iron out the behavior, but it should be mostly there now. Enjoy and report any issues as always :)
**New Features**:
- SQLite-based indexing
- reduced memory usage, higher CPU and IO usage.
- index persistence between restarts (requires [persistent cacheDir](https://filebrowserquantum.com/en/docs/configuration/server/#cachedir))
- new tool - "realtime file watcher" low latency if user has realtime permissions (#917)
- Access control works with individual files too
- conditionally hide symbolic links as indexing rule config (#1540)
- External applications can connect to filebrowser over webDAV (#209) (#1764)
- make breadcrumbs act as a drop area (#1785)
- search multiple sources at once (#848)
- file uploads resume from bad internet connection #1599
- chunked downloads - fix for 524 errors on cloudflare #1502
- made size calculation consistent: defaults to "size on disk" style to mimic "du -sh", and allows config `source.config.useLogicalSize: true` for 0 size folders and actual size file sizes. (#1266)
- Feature: allow deleting selected duplicates from duplicate finder (#1659)
- share changes:
- opengraph support for shared links
- Allow adding "share settings" option to customizable sidebar links (#1825)
- file picker for share favicon and banner icon
- Allow disabling "Login button" for shares (#1673)
**Notes**:
- `server.cacheDirCleanup` defaults to `false` instead of `true`. For docker, you would still need to mount a cacheDir volume to persist cache between restarts.
- indexing rules have been streamlined, see [wiki]. The previous style is deprecated but still functional.
- improved listing load times for directories with metadata -- a two-pass approach. First a fast load to get the listing items, then a second api request to include metadata.
- Enhanced delete prompt
- bulkDelete API added and replaces the delete API for all UI actions. See swagger docs for usage.
- raw download and patch resource api simplified to a single source per request. See swagger docs for usage.
- UserScope function re-organization, if you notice any user scope issues, please open a GitHub issue.
- file upload resume from bad internet connection #1599
**BugFixes**:
- Source info not properly read from external storage NAS (#1727)
- ensure user requests scopes are strongly enforced (#1789)
## New Contributors
* @reddec made their first contribution in https://github.com/gtsteffaniak/filebrowser/pull/1764
* @maximeborges made their first contribution in https://github.com/gtsteffaniak/filebrowser/pull/1849
**Full Changelog**: https://github.com/gtsteffaniak/filebrowser/compare/v1.1.6-beta...v1.2.0-beta
## v1.1.6-beta
**Notes**:
- "showHidden" is now a backend attribute. Shares will NOT show hidden files by default unless configured to.
- improved listing view UI performance, especially for folders with many items. (#1773)
**BugFixes**:
- SortBy config is not respected when navigating next/previous (#1786)
- unauthenticated users unable to preview files (#1792)
- File permission not correctly applied( #1790)
- API Key loses "download" permission after creation despite being enabled (#1799)
- never exclude root folder (#1749)
- sources with "files" name not showing after 1.1.x update (#1772)
- Symbolic links can't be deleted from webui. (#1566)
- panic: close of closed channel during indexing (#1771)
- Public sharing returns 404 errors for album art (#1792)
- Directories with some symbols in their name "fail to open" (#1808)
## v1.1.5-beta
**Notes**:
- Major git container tag request #1756
**BugFixes**:
- Sharing broken if source `disableIndexing: true` (#1742)
- Password protected share permission issues (#1729) (#1606) (#1593)
- Sidebar Sliding left (#1737)
- file permissions not working (#1762)
- handle Close() errors in archive creation (#1745)
- Move custom CSS and user-selected theme styles to the end of the body (#1744)
## v1.1.4-beta
**New Features**:
- toggle to show searching results with thumbnails #1545
**Notes**:
- file list has loading spinner to help prevent double click issues and give better feedback
- improved cacheDir startup checks
- warning for slow read/write speeds below 50MB/s
- warning for low free space below 20GB
- warning if read/write latency is slow
- fatal for any errors reading or writing to the directory
- links to official docs for more info
- access rule changes:
- denied folders won't show up in parent directory listing view (#1684)
- tools will respect access rules
- shares will check access rules based on user that created the share
**BugFixes**:
- passwords with special characters not working properly (#1648)
- encoded filename navigation issue (#1711)
- Fix/Improve some behaviors in nextPrevious (#1707)
- Files recognized as folder if they have the same name as previously deleted folders #1697
- multi logger support issue #1701
## v1.1.3-beta
**Notes**:
- continue create user dir scope even if filesystem path creation fails #1509
- access control cache is cleared more aggresively to ensure no delay
- removed default .ico favicon in favor of .svg
**BugFixes**:
- indexing used size increases over time #1685
- download progress is not shown #1687
## v1.1.2-beta
**Notes**:
- changes to duplicate detector
- temp SQLite database each query to reduce memory pressure
- max limit is 500 groups total
- Downloads from UI requests utf-8 formatted names to support chinese and other characters. Updated swagger docs.
- beta releases no longer publish to `latest` docker tag #1675
**BugFixes**:
- better index status updates, fixing delays #1649
- fixed long load times for listings with media info due sequential processing of files.
- downloaded files always included `utf-8` in filename #1671
- custom sidebar links allow external links like `https://google.com`
- html title not populated correctly for links #1676
## v1.1.1-beta
**Notes**:
- [docker] upgraded ffmpeg 8.0 to 8.0.1
- stricter access control checks on file downloads.
- Wrong translation on Chinese "save" button #1650
- Incorrect Spanish Translations in Folder Creation and Rename Actions #1626
- duplicate file detector has stricter partial checksum match #1617
**BugFixes**:
- added missing exiftool to docker image for heic conversion orientation support
- v1.1.0-beta - Incorrect naming of 1 file in directory-info #1621
- disable only office viewing settings not applying
- OnlyOffice integration does not work behind proxy authentication #1422
- Newly created users "add on" to defined scope of previous user #1628 #1518
- disable chown on upload / file saving #1469 #1546
- Uploading a file will silently overwrite any existing file with the same name #1564
- share file url issue
- Fix drag and drop and rows in normal view #1651
- Some text based files are not able to edit. #1567
## v1.1.0-beta
+44 -34
View File
@@ -2,7 +2,7 @@
[![Go Report Card](https://goreportcard.com/badge/github.com/gtsteffaniak/filebrowser/backend)](https://goreportcard.com/report/github.com/gtsteffaniak/filebrowser/backend)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/1c48cfb7646d4009aa8c6f71287670b8)](https://www.codacy.com/gh/gtsteffaniak/filebrowser/dashboard)
[![latest version](https://img.shields.io/github/v/release/gtsteffaniak/filebrowser)](https://github.com/gtsteffaniak/filebrowser/releases)
[![latest version](https://img.shields.io/github/release/gtsteffaniak/filebrowser/all.svg)](https://github.com/gtsteffaniak/filebrowser/releases)
[![DockerHub Pulls](https://img.shields.io/docker/pulls/gtstef/filebrowser?label=latest%20Docker%20pulls)](https://hub.docker.com/r/gtstef/filebrowser)
[![Apache-2.0 License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)
@@ -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,45 @@ Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Self hostable | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
webdav support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
webdav support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple sources at once | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Docker image size | 180 MB (with ffmpeg) | 31 MB | 240 MB (main image) | 250 MB | ❌ | > 2 GB |
Min. Memory Requirements | 512 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
has standalone binary | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
Min. Memory Requirements | 256 MB | 128 MB | 128 MB (main image) | 512 MB | ❌ | 512 MB |
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: | ❌ | ❌ | ❌ | ❌ | ❌ |
+5 -3
View File
@@ -1,15 +1,17 @@
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
ENV GOEXPERIMENT=greenteagc
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
@@ -25,7 +27,6 @@ FROM alpine:latest
COPY --from=ffmpeg [ "/ffmpeg", "/ffprobe", "/usr/local/bin/" ]
ENV FILEBROWSER_FFMPEG_PATH="/usr/local/bin/"
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata curl exiftool
RUN adduser -D -s /bin/true -u 1000 filebrowser
@@ -42,6 +43,7 @@ RUN [ "filebrowser", "version" ]
RUN [ "ffmpeg", "-version" ]
RUN [ "ffprobe", "-version" ]
RUN [ "exiftool", "-ver" ]
USER root
# exposing default port for auto discovery.
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
+1 -2
View File
@@ -1,5 +1,4 @@
FROM node:22-slim
WORKDIR /app/frontend
RUN npm i @playwright/test oauth2-mock-server @types/node
RUN npx playwright install --with-deps chromium
RUN npx playwright install --with-deps firefox
-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
-14
View File
@@ -1,14 +0,0 @@
FROM ghcr.io/gtsteffaniak/playwright-base:latest
WORKDIR /app
COPY [ "./_docker/src/jwt/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
RUN apt update && apt install nginx -y
RUN mv default.conf /etc/nginx/conf.d/default.conf
RUN sed -i 's/filebrowser/localhost/g' /etc/nginx/conf.d/default.conf
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN nginx & ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
-14
View File
@@ -1,14 +0,0 @@
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
WORKDIR /app
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
COPY [ "./_docker/src/screenshots/backend/*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
ENTRYPOINT [ "./filebrowser" ]
-17
View File
@@ -1,17 +0,0 @@
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
WORKDIR /app
# Copy setup scripts first to leverage caching
COPY [ "./_docker/src/previews/", "./" ]
WORKDIR /app/frontend
COPY [ "./frontend/tests", "./tests" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
-15
View File
@@ -1,15 +0,0 @@
FROM ghcr.io/gtsteffaniak/playwright-base:chromium
WORKDIR /app
WORKDIR /app/frontend
COPY [ "./frontend/", "./" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
COPY [ "./_docker/src/screenshots/backend/*", "./"]
RUN mkdir -p ./http/dist
COPY [ "./backend/http/embed/", "./http/dist/"]
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots
-3
View File
@@ -7,7 +7,6 @@ COPY [ "./_docker/src/settings/", "./" ]
WORKDIR /app/frontend
COPY [ "./_docker/src/settings/frontend/", "./" ]
COPY [ "./frontend/tests/", "./tests" ]
COPY [ "./frontend/tests/playwright-files", "/tests/playwright-files" ]
WORKDIR /app/backend/
COPY [ "./backend/filebrowser*", "./"]
@@ -18,6 +17,4 @@ COPY [ "./backend/http/embed/", "./http/dist/"]
# Run tests
ENV FILEBROWSER_PLAYWRIGHT_TEST=true
RUN ./filebrowser set -u testuser1,testuser1
RUN ./filebrowser set rule -s access -p / -r user -v admin -allow -c config.yaml
RUN ./filebrowser set rule -s access -p /excluded -r user -v admin -c config.yaml
RUN ./filebrowser & sleep 2 && cd ../frontend && npx playwright test
+5 -3
View File
@@ -3,11 +3,13 @@ ARG VERSION
ARG REVISION
WORKDIR /app
COPY ./backend ./
RUN apk update && apk add
RUN apk update && apk add upx
ENV GOEXPERIMENT=greenteagc
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
@@ -15,13 +17,12 @@ COPY ./frontend/package.json ./
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \
&& git config --global url."https://github.com/".insteadOf "git@github.com:" \
&& git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
RUN npm i
RUN npm i --maxsockets 1
COPY ./frontend/ ./
RUN npm run build:docker
FROM alpine:latest
ENV FILEBROWSER_DATABASE="/home/filebrowser/data/database.db"
ENV FILEBROWSER_CONFIG="/home/filebrowser/data/config.yaml"
ENV PATH="$PATH:/home/filebrowser"
RUN apk --no-cache add ca-certificates mailcap tzdata curl
RUN adduser -D -s /bin/true -u 1000 filebrowser
@@ -34,6 +35,7 @@ COPY --from=base --chown=filebrowser:1000 [ "/app/reduce-rounded-corners.css", "
COPY --from=nbuild --chown=filebrowser:1000 [ "/app/dist/", "./http/dist/" ]
## sanity checks
RUN [ "filebrowser", "version" ]
USER root
# exposing default port for auto discovery.
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
+1 -35
View File
@@ -1,19 +1,4 @@
services:
local-playwright:
container_name: local-playwright
ports:
- "8080:8080"
build:
dockerfile: ./_docker/Dockerfile.playwright-local
context: ../
local-playwright-screenshots:
container_name: local-playwright
volumes:
- ../frontend/generated:/app/frontend/generated
entrypoint: ["/bin/sh", "-c", "./filebrowser & sleep 2 && cd ../frontend && npx playwright test --project dark-screenshots && npx playwright test --project light-screenshots"]
build:
dockerfile: ./_docker/Dockerfile.playwright-local
context: ../
nginx-proxy-auth:
image: nginx
container_name: nginx-proxy-auth
@@ -22,30 +7,11 @@ services:
volumes:
- ./src/proxy/backend/default.conf:/etc/nginx/conf.d/default.conf
- ./src/proxy/backend/.htpasswd:/etc/nginx/conf.d/.htpasswd
nginx-proxy-jwt:
image: nginx
container_name: nginx-proxy-jwt
ports:
- "8081:8081" # Admin user
- "8082:8082" # Regular user
- "8083:8083" # Wrong key
- "8084:8084" # No user
volumes:
- ./src/jwt/backend/default.conf:/etc/nginx/conf.d/default.conf
- ./src/jwt/backend/.htpasswd:/etc/nginx/conf.d/.htpasswd
filebrowser-jwt:
hostname: filebrowser
volumes:
- '../frontend:/home/frontend'
- "./src/jwt/backend/config.yaml:/home/filebrowser/data/config.yaml"
build:
context: ../
dockerfile: ./_docker/Dockerfile.slim
filebrowser:
hostname: filebrowser
volumes:
- '../frontend:/home/frontend'
- "./src/proxy/backend/config.yaml:/home/filebrowser/data/config.yaml"
- "./src/proxy/backend/config.yaml:/home/filebrowser/config.yaml"
build:
context: ../
dockerfile: ./_docker/Dockerfile.slim
+1 -4
View File
@@ -8,11 +8,8 @@ userDefaults:
image: true
popup: true
video: true
audio: true
models: true
office: true
highQuality: true
permissions:
download: true
create: true
share: true
create: true
-48
View File
@@ -1,48 +0,0 @@
server:
port: 8080
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
config:
defaultEnabled: true
createUserDir: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
styling:
disableEventThemes: true
auth:
methods:
password:
enabled: false
jwt:
enabled: true
secret: "test-jwt-secret-key-for-development-only-do-not-use-in-production"
algorithm: "HS256"
header: "X-JWT-Assertion"
userIdentifier: "sub"
groupsClaim: "mygroup"
adminGroup: "admin"
userDefaults:
preview:
image: true
popup: true
video: true
office: true
highQuality: true
darkMode: true
disableSettings: false
singleClick: false
permissions:
admin: false
modify: true
share: true
api: false
download: true
create: true
-66
View File
@@ -1,66 +0,0 @@
# Admin user (port 8081)
server {
listen 8081;
server_name localhost 127.0.0.1;
set $admin_token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RhZG1pbkBleGFtcGxlLmNvbSIsImV4cCI6MjA4NzU2NTc2NiwiZ3JvdXBzIjpbImFkbWluIl0sImlhdCI6MTc3MjIwNTc2NiwibmFtZSI6IlRlc3QgQWRtaW4iLCJuYmYiOjE3NzIyMDU3NjYsInN1YiI6InRlc3RhZG1pbiJ9.pxuKGyMmEe94m0xQ7y1QzcUZgU-lZSvqjoKUVoVd3F4";
location / {
proxy_set_header X-JWT-Assertion $admin_token;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://filebrowser:8080;
}
}
# Regular user (port 8082)
server {
listen 8082;
server_name localhost 127.0.0.1;
set $regular_token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJleHAiOjIwODc1NjU2OTIsImdyb3VwcyI6WyJteWdyb3VwIl0sImlhdCI6MTc3MjIwNTY5MiwibmFtZSI6IlRlc3QgVXNlciIsIm5iZiI6MTc3MjIwNTY5Miwic3ViIjoidGVzdHVzZXIifQ.X8fUYbhnZpgvP5CnGdms9eGo8yRLyNhRbj7xUSxEgl8";
location / {
proxy_set_header X-JWT-Assertion $regular_token;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://filebrowser:8080;
}
}
# Valid token but signed with different secret (port 8083)
server {
listen 8083;
server_name localhost 127.0.0.1;
# Valid JWT token structure, but signed with wrong secret - should fail signature verification
set $wrong_key "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RhZG1pbkBleGFtcGxlLmNvbSIsImV4cCI6MjA4NzU2NjAyMywiZ3JvdXBzIjpbImFkbWluIl0sImlhdCI6MTc3MjIwNjAyMywibmFtZSI6IlRlc3QgQWRtaW4iLCJuYmYiOjE3NzIyMDYwMjMsInN1YiI6InRlc3RhZG1pbiJ9.afBobbJ3VQ-fKnUebGr-gxFIqegyICi6E3EgkUSeFNw";
location / {
proxy_set_header X-JWT-Assertion $wrong_key;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://filebrowser:8080;
}
}
# No authentication (port 8084)
server {
listen 8084;
server_name localhost 127.0.0.1;
location / {
proxy_set_header X-JWT-Assertion "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://filebrowser:8080;
}
}
-101
View File
@@ -1,101 +0,0 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
// Simple JWT generator without external dependencies
func main() {
// Secret key - must match the one in your config file
secret := "wrong-key"
// Check if custom secret provided via command line
if len(os.Args) > 1 {
secret = os.Args[1]
}
// Create token claims with 10 year expiration
now := time.Now()
expiresAt := now.Add(10 * 365 * 24 * time.Hour) // 10 years
header := map[string]interface{}{
"alg": "HS256",
"typ": "JWT",
}
claims := map[string]interface{}{
"sub": "testadmin", // username (subject)
"email": "testadmin@example.com", // email claim
"name": "Test Admin", // full name
"groups": []string{"admin"}, // groups - includes admin for testing
"iat": now.Unix(), // issued at
"exp": expiresAt.Unix(), // expires at
"nbf": now.Unix(), // not before
}
// Encode header
headerJSON, err := json.Marshal(header)
if err != nil {
fmt.Fprintf(os.Stderr, "Error encoding header: %v\n", err)
os.Exit(1)
}
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
// Encode claims
claimsJSON, err := json.Marshal(claims)
if err != nil {
fmt.Fprintf(os.Stderr, "Error encoding claims: %v\n", err)
os.Exit(1)
}
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
// Create signature
message := headerB64 + "." + claimsB64
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(message))
signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
// Combine to form complete JWT
tokenString := message + "." + signature
// Output the token
fmt.Println("=== JWT Token Generated ===")
fmt.Println()
fmt.Println("Token:")
fmt.Println(tokenString)
fmt.Println()
// Verify token can be decoded
parts := strings.Split(tokenString, ".")
if len(parts) == 3 {
fmt.Println("✓ Token structure is valid (3 parts)")
} else {
fmt.Fprintf(os.Stderr, "✗ Invalid token structure\n")
os.Exit(1)
}
// Save to file for easy access
filename := "test-jwt-token.txt"
if err := os.WriteFile(filename, []byte(tokenString), 0644); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Could not write token to file: %v\n", err)
} else {
fmt.Printf("✓ Token saved to: %s\n", filename)
fmt.Println()
}
// Also save to shell script for easy sourcing
scriptContent := fmt.Sprintf("#!/bin/bash\n# Source this file to set JWT token as environment variable\n# Usage: source jwt-env.sh\nexport TEST_JWT_TOKEN=\"%s\"\necho \"JWT token loaded into TEST_JWT_TOKEN\"\n", tokenString)
if err := os.WriteFile("jwt-env.sh", []byte(scriptContent), 0755); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Could not write shell script: %v\n", err)
} else {
fmt.Println("✓ Shell script saved to: jwt-env.sh")
fmt.Println(" Usage: source jwt-env.sh")
fmt.Println()
}
}
-3
View File
@@ -1,3 +0,0 @@
module test
go 1.25.6
@@ -1,66 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/playwright/jwt-setup.ts",
timeout: 5000,
testDir: "./tests/playwright/jwt",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: false,
/* Retry on CI only */
retries: 2,
/* Opt out of parallel tests on CI. */
workers: 1, // required for now! todo parallel some tests
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
actionTimeout: 5000,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for different JWT test scenarios */
projects: [
{
name: "admin-user",
use: {
...devices["Desktop Firefox"],
baseURL: "http://127.0.0.1:8081", // Admin user JWT
},
},
{
name: "regular-user",
use: {
...devices["Desktop Firefox"],
baseURL: "http://127.0.0.1:8082", // Regular user JWT
},
},
{
name: "wrong-key",
use: {
...devices["Desktop Firefox"],
baseURL: "http://127.0.0.1:8083", // Wrong secret key
},
},
{
name: "no-user",
use: {
...devices["Desktop Firefox"],
baseURL: "http://127.0.0.1:8084", // No JWT token
},
},
],
});
+10 -15
View File
@@ -8,27 +8,22 @@ server:
name: "exclude"
config:
defaultEnabled: true
rules:
- folderPath: "excluded"
- folderPath: "excludedButVisible"
viewable: true
- fileStartsWith: "startsWith"
- fileName: "dontshow.txt"
conditionals:
rules:
- folderPath: "excluded"
- folderPath: "excludedButVisible"
viewable: true
- path: "../frontend/tests/playwright-files2"
name: "include"
config:
defaultEnabled: true
useLogicalSize: true
rules:
- includeRootItem: "myfolder"
- includeRootItem: "subfolderExclusions"
- includeRootItem: "folder#hash"
- includeRootItem: "files"
- filePath: "/subfolderExclusions/exclusionlist.sh"
- folderPath: "/subfolderExclusions/folderItem"
viewable: true
conditionals:
rules:
- includeRootItem: "myfolder"
- includeRootItem: "subfolderExclusions"
- filePath: "/subfolderExclusions/exclusionlist.sh"
- folderPath: "/subfolderExclusions/folderItem"
viewable: true
- fileStartsWith: "startsWith"
- folderEndsWith: "Folder"
- folderNames: 'folderNames'
-35
View File
@@ -1,35 +0,0 @@
server:
port: 80
baseURL: "/"
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
auth:
methods:
noauth: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
styling:
disableEventThemes: true
userDefaults:
showHidden: true
preview:
image: true
popup: true
video: true
audio: true
office: true
models: true
highQuality: true
permissions:
download: true
create: true
@@ -1,54 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/playwright/previews-setup",
timeout: 10000,
testDir: "./tests/playwright/previews",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: false,
/* Retry on CI only */
retries: 2,
workers: 10,
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
launchOptions: {
args: [
'--disable-gpu',
'--use-gl=swiftshader', // CPU-based WebGL renderer built into Chromium
'--enable-webgl',
'--disable-dev-shm-usage', // Overcome limited resource problems in Docker
'--no-sandbox', // Required for Docker
],
},
storageState: "previews.json",
actionTimeout: 5000,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://127.0.0.1",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
@@ -11,14 +11,14 @@ import { defineConfig, devices } from "@playwright/test";
*/
export default defineConfig({
globalSetup: "./tests/playwright/proxy-setup.ts",
timeout: 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 */
@@ -1,10 +0,0 @@
server:
port: 8080
baseURL: "/files/"
logging:
- levels: "info|error|debug"
sources:
- path: "../frontend/tests/playwright-files"
name: "playwright"
config:
defaultEnabled: true
@@ -1,65 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: "./tests/playwright/screenshots-setup.ts",
timeout: 5000,
testDir: "./tests/playwright/screenshots",
/* Run tests in files in parallel */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: false,
/* Retry on CI only */
retries: 1,
/* Opt out of parallel tests on CI. */
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "line",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
launchOptions: {
args: [
'--disable-gpu',
'--use-gl=swiftshader', // CPU-based WebGL renderer built into Chromium
'--enable-webgl',
'--disable-dev-shm-usage', // Overcome limited resource problems in Docker
'--no-sandbox', // Required for Docker
],
},
actionTimeout: 5000,
storageState: "loginAuth.json",
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:8080",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Set default locale to English (US) */
locale: "en-US",
},
/* Configure projects for major browsers */
projects: [
{
name: "dark-screenshots",
use: {
...devices["Desktop Chrome"],
theme: 'dark',
},
},
{
name: "light-screenshots",
use: {
...devices["Desktop Chrome"],
theme: 'light',
},
},
],
});
-5
View File
@@ -13,11 +13,6 @@ server:
config:
disableIndexing: true
defaultEnabled: true
- path: "/tests/playwright-files"
name: "access"
config:
defaultEnabled: true
denyByDefault: true
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
@@ -11,7 +11,7 @@ import { defineConfig, devices } from "@playwright/test";
*/
export default defineConfig({
globalSetup: "./tests/playwright/global-setup",
timeout: 5000,
timeout: 3000,
testDir: "./tests/playwright/settings",
/* Run tests in files in parallel */
fullyParallel: false,
+2 -2
View File
@@ -6,7 +6,7 @@ tmp_dir = "tmp"
bin = "tmp/filebrowser"
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -c test_config.yaml"
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "http/dist", "http/embed", "swagger/docs", "preview/thumbnails", "preview/heic"]
exclude_dir = ["tmp", "vendor", "http/dist", "http/embed", "swagger/docs"]
log = "air.log"
delay = 1000 # ms
@@ -14,4 +14,4 @@ tmp_dir = "tmp"
time = true
[misc]
clean_on_exit = false
clean_on_exit = true
+1 -1
View File
@@ -18,4 +18,4 @@ tmp_dir = "tmp"
time = true
[misc]
clean_on_exit = false
clean_on_exit = true
-1
View File
@@ -3,4 +3,3 @@ air.log
filebrowser
generated.yaml
test_config.yaml
/data
+4 -59
View File
@@ -10,9 +10,6 @@ import (
"os"
"path/filepath"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/go-logger/logger"
)
// Cache interface for caching operations
@@ -32,61 +29,9 @@ type FileCache struct {
}
}
// NewFileCache creates a new FileCache
func NewFileCache(dir string) (*FileCache, error) {
cacheDir := filepath.Join(dir, "diskcache")
// Migrate existing cache files from old structure (if any)
if err := migrateOldCacheStructure(dir, cacheDir); err != nil {
logger.Warningf("failed to migrate old cache structure: %v\n", err)
}
// Create the cache directory immediately with proper permissions
if err := os.MkdirAll(cacheDir, fileutils.PermDir); err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
return &FileCache{dir: cacheDir}, nil
}
// migrateOldCacheStructure moves cache files from old structure (dir/a/...) to new structure (dir/diskcache/a/...)
func migrateOldCacheStructure(oldDir, newDir string) error {
// Read the old cache directory
entries, err := os.ReadDir(oldDir)
if err != nil {
if os.IsNotExist(err) {
return nil // Directory doesn't exist yet, nothing to migrate
}
return err
}
migrated := 0
for _, entry := range entries {
// Only migrate single-character directories (cache hash directories like a, b, c, etc.)
if !entry.IsDir() || len(entry.Name()) != 1 {
continue
}
// Skip if it's already the diskcache directory
if entry.Name() == "d" && filepath.Join(oldDir, entry.Name()) == newDir {
continue
}
oldPath := filepath.Join(oldDir, entry.Name())
newPath := filepath.Join(newDir, entry.Name())
// Create parent directory in new location
if err := os.MkdirAll(newDir, fileutils.PermDir); err != nil {
return err
}
// Move the directory
if err := os.Rename(oldPath, newPath); err != nil {
// If rename fails (e.g., cross-device), skip this one
continue
}
migrated++
}
return nil
return &FileCache{dir: dir}, nil
}
func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
@@ -95,11 +40,11 @@ func (f *FileCache) Store(ctx context.Context, key string, value []byte) error {
defer mu.Unlock()
fileName := f.getFileName(key)
if err := os.MkdirAll(filepath.Dir(fileName), fileutils.PermDir); err != nil {
if err := os.MkdirAll(filepath.Dir(fileName), 0700); err != nil {
return err
}
if err := os.WriteFile(fileName, value, fileutils.PermFile); err != nil {
if err := os.WriteFile(fileName, value, 0600); err != nil {
return err
}
@@ -6,7 +6,6 @@ import (
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/stretchr/testify/require"
)
@@ -20,9 +19,6 @@ func TestFileCache(t *testing.T) {
cachedFilePath = "a/62/a62f2225bf70bfaccbc7f1ef2a397836717377de"
)
// Set up file permissions before creating cache
fileutils.SetFsPermissions(0644, 0755)
// Create temporary directory for the cache
cacheDir, err := os.MkdirTemp("", cacheRoot)
require.NoError(t, err)
@@ -32,20 +28,19 @@ func TestFileCache(t *testing.T) {
require.NoError(t, err)
// store new key
// Note: NewFileCache creates a "diskcache" subdirectory, so the actual path includes it
err = cache.Store(ctx, key, []byte(value))
require.NoError(t, err)
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, value)
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, value)
// update existing key
err = cache.Store(ctx, key, []byte(newValue))
require.NoError(t, err)
checkValue(t, ctx, cache, filepath.Join(cacheDir, "diskcache", cachedFilePath), key, newValue)
checkValue(t, ctx, cache, filepath.Join(cacheDir, cachedFilePath), key, newValue)
// delete key
err = cache.Delete(ctx, key)
require.NoError(t, err)
exists := fileExists(filepath.Join(cacheDir, "diskcache", cachedFilePath))
exists := fileExists(filepath.Join(cacheDir, cachedFilePath))
require.False(t, exists)
}
-138
View File
@@ -1,138 +0,0 @@
package files
import (
"os"
"path/filepath"
"testing"
"unicode/utf8"
"github.com/stretchr/testify/require"
)
func TestGetContent_UTF8Truncation(t *testing.T) {
// Get the path to the test file
// The test file is in frontend/tests/playwright-files/utf8-truncated.txt
// We need to find it relative to the test execution directory
cwd, err := os.Getwd()
require.NoError(t, err)
// Navigate from backend/adapters/fs/files to the project root
// backend/adapters/fs/files -> backend/adapters/fs -> backend/adapters -> backend -> root
testFilePath := filepath.Join(cwd, "..", "..", "..", "..", "frontend", "tests", "playwright-files", "utf8-truncated.txt")
// Check if file exists, if not try alternative path
if _, err = os.Stat(testFilePath); os.IsNotExist(err) {
// Try from project root
testFilePath = filepath.Join("frontend", "tests", "playwright-files", "utf8-truncated.txt")
if _, err = os.Stat(testFilePath); os.IsNotExist(err) {
t.Skipf("Test file not found at %s, skipping test", testFilePath)
return
}
}
// Get absolute path
var absPath string
absPath, err = filepath.Abs(testFilePath)
require.NoError(t, err)
t.Run("file with UTF-8 truncation at 4096 byte boundary", func(t *testing.T) {
// The test file is longer than 4096 bytes, with valid UTF-8 throughout.
// However, when reading exactly 4096 bytes as a header, it cuts off in the
// middle of a multi-byte UTF-8 sequence (e6 9c, missing the last byte 88 of '月').
// This should trigger the truncation handling in getContent's header validation.
// The fix should trim the incomplete sequence from the header, allowing the
// header check to pass, and then the full file (which is valid UTF-8) should
// pass the full file validation.
content, err := getContent(absPath)
// Should not return an error - the header truncation is handled, and the full file is valid
require.NoError(t, err)
// The content should be the full valid UTF-8 file
require.NotEmpty(t, content, "Content should not be empty - full file is valid UTF-8")
// Verify it contains the expected text
require.Contains(t, content, "文件已备份", "Content should contain Chinese characters")
require.Contains(t, content, "2024年", "Content should contain date information")
})
t.Run("regular UTF-8 text file", func(t *testing.T) {
// Test with a simple text file to ensure normal files still work
tmpFile, err := os.CreateTemp("", "test-utf8-*.txt")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
testText := "Hello, 世界! This is a test file with UTF-8 characters.\n"
_, err = tmpFile.WriteString(testText)
require.NoError(t, err)
tmpFile.Close()
content, err := getContent(tmpFile.Name())
require.NoError(t, err)
require.Equal(t, testText, content)
})
t.Run("file smaller than header size", func(t *testing.T) {
// Test with a file smaller than 4096 bytes
tmpFile, err := os.CreateTemp("", "test-small-*.txt")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
testText := "Small file content"
_, err = tmpFile.WriteString(testText)
require.NoError(t, err)
tmpFile.Close()
content, err := getContent(tmpFile.Name())
require.NoError(t, err)
require.Equal(t, testText, content)
})
t.Run("file with Chinese characters at boundary", func(t *testing.T) {
// Create a file that's exactly 4094 bytes, ending with a complete Chinese character
tmpFile, err := os.CreateTemp("", "test-chinese-*.txt")
require.NoError(t, err)
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
// Create content that's exactly 4094 bytes, ending with complete UTF-8
baseText := "2024年 06月 17日 星期一 04:05:58 CST 文件已备份\n"
content := ""
for len([]byte(content)) < 4094 {
content += baseText
}
// Trim to exactly 4094 bytes
encoded := []byte(content)
if len(encoded) > 4094 {
encoded = encoded[:4094]
}
// Ensure it ends with a complete character by finding the last complete rune
for len(encoded) > 0 {
lastRune, _ := decodeLastRune(encoded)
if lastRune != 0xFFFD { // RuneError
break
}
encoded = encoded[:len(encoded)-1]
}
_, err = tmpFile.Write(encoded)
require.NoError(t, err)
tmpFile.Close()
result, err := getContent(tmpFile.Name())
require.NoError(t, err)
require.NotEmpty(t, result)
require.Equal(t, string(encoded), result)
})
}
// Helper function to check if last rune is valid
func decodeLastRune(p []byte) (rune, int) {
if len(p) == 0 {
return 0, 0
}
r, size := utf8.DecodeLastRune(p)
return r, size
}
+32 -425
View File
@@ -1,20 +1,13 @@
package files
import (
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
liberrors "github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
dbsql "github.com/gtsteffaniak/filebrowser/backend/database/sql"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
@@ -90,111 +83,6 @@ func Test_GetRealPath(t *testing.T) {
}
}
// Regression: PUT / save must not os.Chmod to PermFile for existing files, or +x (and other mode bits) are lost (#2309).
func TestWriteFilePreservesExecutableBit(t *testing.T) {
cacheDir := t.TempDir()
originalCache := settings.Config.Server.CacheDir
settings.Config.Server.CacheDir = cacheDir
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
fileutils.SetFsPermissions(0o644, 0o755)
if indexing.GetIndexDB() == nil {
db, _, err := dbsql.NewIndexDB("test_writefile_exec", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
indexing.SetIndexDBForTesting(db)
}
root := t.TempDir()
scriptName := "backupscript.sh"
scriptAbs := filepath.Join(root, scriptName)
if err := os.WriteFile(scriptAbs, []byte("#!/bin/sh\necho hi\n"), 0o755); err != nil {
t.Fatal(err)
}
sourceName := "test_writefile_exec"
indexing.Initialize(&settings.Source{
Name: sourceName,
Path: root,
}, false, false)
idx := indexing.GetIndex(sourceName)
if idx == nil {
t.Fatal("Failed to get test index")
}
stopPoll := startScannerStatusPoll(t, idx, 1)
defer stopPoll()
waitForScannerReady(t, idx)
indexPath := "/" + scriptName
if err := WriteFile(sourceName, indexPath, strings.NewReader("#!/bin/sh\necho bye\n")); err != nil {
t.Fatalf("WriteFile: %v", err)
}
st, err := os.Stat(scriptAbs)
if err != nil {
t.Fatal(err)
}
if st.Mode()&0o111 == 0 {
t.Fatalf("executable bits cleared after save: mode=%v", st.Mode())
}
indexing.StopAllScanners()
time.Sleep(50 * time.Millisecond)
indexing.ClearTestIndices()
}
func TestWriteFileRejectsExistingDirectory(t *testing.T) {
cacheDir := t.TempDir()
originalCache := settings.Config.Server.CacheDir
settings.Config.Server.CacheDir = cacheDir
t.Cleanup(func() { settings.Config.Server.CacheDir = originalCache })
fileutils.SetFsPermissions(0o644, 0o755)
if indexing.GetIndexDB() == nil {
db, _, err := dbsql.NewIndexDB("test_writefile_isdir", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
indexing.SetIndexDBForTesting(db)
}
root := t.TempDir()
dirName := "folder"
if err := os.Mkdir(filepath.Join(root, dirName), 0o755); err != nil {
t.Fatal(err)
}
sourceName := "test_writefile_isdir"
indexing.Initialize(&settings.Source{
Name: sourceName,
Path: root,
}, false, false)
idx := indexing.GetIndex(sourceName)
if idx == nil {
t.Fatal("Failed to get test index")
}
stopPoll := startScannerStatusPoll(t, idx, 1)
defer stopPoll()
waitForScannerReady(t, idx)
err := WriteFile(sourceName, "/"+dirName, strings.NewReader("file content"))
if err == nil {
t.Fatal("expected error when path is an existing directory")
}
if !errors.Is(err, liberrors.ErrIsDirectory) {
t.Fatalf("expected ErrIsDirectory, got: %v", err)
}
indexing.StopAllScanners()
time.Sleep(50 * time.Millisecond)
indexing.ClearTestIndices()
}
func TestSortItems(t *testing.T) {
tests := []struct {
name string
@@ -302,33 +190,11 @@ func TestSortItems(t *testing.T) {
// per design decision - RealPathCache is auxiliary and doesn't need clearing
func TestOverrideDirectoryToFile(t *testing.T) {
// Use a temporary directory for cache to avoid creating directories in the source tree
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 (needed by NewTempDB)
if fileutils.PermDir == 0 {
fileutils.SetFsPermissions(0644, 0755)
}
// Initialize the database first (use test helper to avoid permission issues)
if indexing.GetIndexDB() == nil {
db, _, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
indexing.SetIndexDBForTesting(db)
}
// Initialize the index with scanning disabled to prevent background scanner interference
// Initialize the index in mock mode (no filesystem operations)
indexing.Initialize(&settings.Source{
Name: "test",
Path: "/mock/path",
}, true, false) // true for mock mode, false for isNewDb
}, true) // true for mock mode
// Get the index and set up mock data
idx := indexing.GetIndex("test")
@@ -336,18 +202,8 @@ func TestOverrideDirectoryToFile(t *testing.T) {
t.Fatal("Failed to get test index")
}
// Wait for initial scanner to complete (it will fail on /mock/path but needs to finish cleanup)
// This ensures batchItems is nil before we start testing
for i := 0; i < 50; i++ { // Wait up to 500ms
time.Sleep(10 * time.Millisecond)
status := idx.GetScannerStatus()
if status["status"] == "ready" || status["status"] == "unavailable" {
break
}
}
// Create mock directory structure using UpdateMetadata
rootInfo := &iteminfo.FileInfo{
// Create mock directory structure
idx.Directories["/"] = &iteminfo.FileInfo{ //nolint:staticcheck // t.Fatal terminates execution
Path: "/",
ItemInfo: iteminfo.ItemInfo{
Name: "/",
@@ -357,28 +213,27 @@ func TestOverrideDirectoryToFile(t *testing.T) {
{Name: "Test Object", Type: "directory"},
},
}
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
// Delete the old directory item from the database before replacing it with a file
idx.DeleteMetadata("/Test Object", true, false)
// Simulate the directory-to-file override by updating the mock data
// Remove the directory from the parent's Folders slice
rootInfo.Folders = []iteminfo.ItemInfo{} // Clear folders
rootInfo := idx.Directories["/"] //nolint:staticcheck // t.Fatal terminates execution
for i, folder := range rootInfo.Folders {
if folder.Name == "Test Object" {
rootInfo.Folders = append(rootInfo.Folders[:i], rootInfo.Folders[i+1:]...)
break
}
}
// Add the file to the parent's Files slice
rootInfo.Files = []iteminfo.ExtendedItemInfo{
{
ItemInfo: iteminfo.ItemInfo{
Name: "Test Object",
Size: 25, // Length of "This is test file content"
},
rootInfo.Files = append(rootInfo.Files, iteminfo.ExtendedItemInfo{
ItemInfo: iteminfo.ItemInfo{
Name: "Test Object",
Size: 25, // Length of "This is test file content"
},
}
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
})
// Verify the directory was replaced with a file in the mock data
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
rootInfo, exists := idx.GetMetadataInfo("/", true)
if !exists {
t.Fatal("Root metadata not found")
}
@@ -412,33 +267,11 @@ func TestOverrideDirectoryToFile(t *testing.T) {
}
func TestOverrideFileToDirectory(t *testing.T) {
// Use a temporary directory for cache to avoid creating directories in the source tree
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 (needed by NewTempDB)
if fileutils.PermDir == 0 {
fileutils.SetFsPermissions(0644, 0755)
}
// Initialize the database first (use test helper to avoid permission issues)
if indexing.GetIndexDB() == nil {
db, _, err := dbsql.NewIndexDB("test_file", "OFF", 1000, 32, false)
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
indexing.SetIndexDBForTesting(db)
}
// Initialize the index in mock mode (no filesystem operations)
indexing.Initialize(&settings.Source{
Name: "test",
Path: "/mock/path",
}, true, false) // true for mock mode, false for isNewDb
}, true) // true for mock mode
// Get the index and set up mock data
idx := indexing.GetIndex("test")
@@ -446,18 +279,8 @@ func TestOverrideFileToDirectory(t *testing.T) {
t.Fatal("Failed to get test index")
}
// Wait for initial scanner to complete (it will fail on /mock/path but needs to finish cleanup)
// This ensures batchItems is nil before we start testing
for i := 0; i < 50; i++ { // Wait up to 500ms
time.Sleep(10 * time.Millisecond)
status := idx.GetScannerStatus()
if status["status"] == "ready" || status["status"] == "unavailable" {
break
}
}
// Create mock directory structure with a file using UpdateMetadata
rootInfo := &iteminfo.FileInfo{
// Create mock directory structure with a file
idx.Directories["/"] = &iteminfo.FileInfo{ //nolint:staticcheck // t.Fatal terminates execution
Path: "/",
ItemInfo: iteminfo.ItemInfo{
Name: "/",
@@ -467,26 +290,25 @@ func TestOverrideFileToDirectory(t *testing.T) {
{ItemInfo: iteminfo.ItemInfo{Name: "Test Object", Size: 12}}, // Length of "test content"
},
}
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
// Delete the old file item from the database before replacing it with a directory
idx.DeleteMetadata("/Test Object", false, false)
// Simulate the file-to-directory override by updating the mock data
// Remove the file from the parent's Files slice
rootInfo.Files = []iteminfo.ExtendedItemInfo{} // Clear files
rootInfo := idx.Directories["/"] //nolint:staticcheck // t.Fatal terminates execution
for i, file := range rootInfo.Files {
if file.Name == "Test Object" {
rootInfo.Files = append(rootInfo.Files[:i], rootInfo.Files[i+1:]...)
break
}
}
// Add the directory to the parent's Folders slice
rootInfo.Folders = []iteminfo.ItemInfo{
{
Name: "Test Object",
Type: "directory",
},
}
idx.UpdateMetadata(rootInfo, nil) // nil scanner for test
rootInfo.Folders = append(rootInfo.Folders, iteminfo.ItemInfo{
Name: "Test Object",
Type: "directory",
})
// Verify the file was replaced with a directory in the mock data
rootInfo, exists := idx.GetMetadataInfo("/", true, false)
rootInfo, exists := idx.GetMetadataInfo("/", true)
if !exists {
t.Fatal("Root metadata not found")
}
@@ -518,218 +340,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)
})
}
}
+301 -406
View File
@@ -2,265 +2,138 @@ package files
import (
"context"
"encoding/base64"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"github.com/dhowden/tag"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
"github.com/gtsteffaniak/go-logger/logger"
)
// 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
for i := range response.Files {
fileItem := &response.Files[i]
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
if isItemAudio || isItemVideo {
metadataCount++
}
}
var wg sync.WaitGroup
wg.Go(func() {
indexPath := idx.MakeIndexPath(response.Path, true)
err := idx.RefreshDirectory(indexPath, false)
if err != nil {
logger.Debugf("Failed to refresh directory for folder size update: %v", err)
}
})
if opts.Metadata && metadataCount > 0 {
addMetadataToChildren(response, opts, &wg)
}
wg.Wait()
}
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping)
func finalizeResponse(response *iteminfo.ExtendedFileInfo, info *iteminfo.FileInfo, realPath string, user *users.User, userScope string) {
// Add OnlyOffice ID if applicable
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
response.OnlyOfficeId = generateOfficeId(realPath)
}
// Strip user scope from response path to return path relative to user's context
if user != nil && userScope != "" && userScope != "/" {
response.Path = strings.TrimPrefix(response.Path, userScope)
if response.Path == "" {
response.Path = "/"
}
}
}
// 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")
}
if user == nil {
return "", "", fmt.Errorf("user not provided")
}
if opts.Path == "" {
return "", "", fmt.Errorf("path not provided")
}
if opts.Source == "" {
return "", "", fmt.Errorf("source not provided")
}
// Get index
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return "", "", fmt.Errorf("could not get index: %v ", opts.Source)
}
// Resolve user scope
userScope, scopeErr := user.GetScopeForSourcePath(idx.Path)
if scopeErr != nil || userScope == "" {
return "", "", fmt.Errorf("user has no access to source: %v", opts.Source)
}
safePath, err := utils.SanitizeUserPath(opts.Path)
if err != nil {
return "", "", errors.ErrAccessDenied
}
// Combine scope + sanitized path
indexPath := utils.JoinPathAsUnix(userScope, safePath)
// Layer 1: USER ACCESS CONTROL
// Quick check: Does THIS user have permission?
if !access.Permitted(idx.Path, indexPath, user.Username) {
return "", "", errors.ErrAccessDenied
}
return indexPath, userScope, nil
}
type Items struct {
Files []string `json:"files,omitempty"`
Folders []string `json:"folders,omitempty"`
}
func GetDirItems(opts utils.FileOptions, access *access.Storage, user *users.User) (Items, error) {
items := Items{}
indexPath, _, err := CheckPermissions(opts, access, user)
if err != nil {
return items, err
}
// Get index
idx := indexing.GetIndex(opts.Source)
if idx == nil {
return items, fmt.Errorf("could not get index: %v ", opts.Source)
}
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
ShowHidden: opts.ShowHidden,
Expand: true,
SkipExtendedAttrs: true,
})
if err != nil {
return items, err // Path excluded by index rules OR doesn't exist
}
if info.Type != "directory" {
return items, fmt.Errorf("path is not a directory: %v ", indexPath)
}
if err := access.CheckChildItemAccess(info, idx, user.Username); err != nil {
return items, err
}
if opts.Only == "files" || opts.Only == "" {
for _, file := range info.Files {
items.Files = append(items.Files, file.Name)
}
}
if opts.Only == "folders" || opts.Only == "" {
for _, folder := range info.Folders {
items.Folders = append(items.Folders, folder.Name)
}
}
return items, nil
}
// FileInfoFasterFunc is a variable that can be mocked in tests
var FileInfoFasterFunc = fileInfoFasterImpl
func FileInfoFaster(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
return FileInfoFasterFunc(opts, access, user, share)
}
// fileInfoFasterImpl is the actual implementation of FileInfoFaster
func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *users.User, share *share.Storage) (*iteminfo.ExtendedFileInfo, error) {
func FileInfoFaster(opts utils.FileOptions, access *access.Storage) (*iteminfo.ExtendedFileInfo, error) {
response := &iteminfo.ExtendedFileInfo{}
indexPath, userScope, err := CheckPermissions(opts, access, user)
if err != nil {
return response, err
}
// Get index
idx := indexing.GetIndex(opts.Source)
if idx == nil {
index := indexing.GetIndex(opts.Source)
if index == nil {
return response, fmt.Errorf("could not get index: %v ", opts.Source)
}
// Layer 2: INDEX RULES (global)
// Get file info using unified entry point (applies IsViewable/ShouldSkip)
info, err := idx.GetFileInfo(indexing.FileInfoRequest{
IndexPath: indexPath,
FollowSymlinks: opts.FollowSymlinks,
ShowHidden: opts.ShowHidden,
Expand: opts.Expand,
SkipExtendedAttrs: opts.SkipExtendedAttrs,
})
if !strings.HasPrefix(opts.Path, "/") {
opts.Path = "/" + opts.Path
}
realPath, isDir, err := index.GetRealPath(opts.Path)
if err != nil {
return response, err // Path excluded by index rules OR doesn't exist
return response, fmt.Errorf("could not get real path for requested path: %v, error: %v", opts.Path, err)
}
if !strings.HasSuffix(opts.Path, "/") && isDir {
opts.Path = opts.Path + "/"
}
opts.IsDir = isDir
var info *iteminfo.FileInfo
// Check if path is viewable (allows filesystem access without indexing)
isViewable := index.IsViewable(isDir, opts.Path)
// For non-viewable paths, verify they are indexed
// Skip this check if indexing is disabled for the entire source
if !isViewable && !index.Config.DisableIndexing {
err = index.RefreshFileInfo(opts)
if err != nil {
return response, fmt.Errorf("path not accessible: %v", err)
}
}
// otherwise response keeps unfiltered Folders/Files while CheckChildItemAccess only mutates info.
if info.Type == "directory" {
if err := access.CheckChildItemAccess(info, idx, user.Username); err != nil {
if isDir {
info, err = index.GetFsDirInfo(opts.Path)
if err != nil {
return response, err
}
} else {
// For files, get info from parent directory to ensure HasPreview is set correctly
info, err = index.GetFsDirInfo(opts.Path)
if err != nil {
return response, err
}
}
// Build response
response.FileInfo = *info
response.RealPath = filepath.Join(idx.Path, indexPath)
response.RealPath = realPath
response.Source = opts.Source
if share != nil && user.Permissions.Share && opts.ShowSharedAttr {
if access != nil && !access.Permitted(index.Path, opts.Path, opts.Username) {
// User doesn't have access to the current folder, but check if they have access to any subitems
// This allows specific allow rules on subfolders/files to work even when parent is denied
err := access.CheckChildItemAccess(response, index, opts.Username)
if err != nil {
return response, err
}
}
// For directories, populate metadata for audio/video files ONLY if explicitly requested
// This avoids expensive ffprobe calls on every directory listing
if isDir && opts.Metadata {
startTime := time.Now()
metadataCount := 0
for i := range response.Files {
file := &response.Files[i]
file.IsShared = share.IsShared(response.Path+file.Name, idx.Path, user.ID)
fileItem := &response.Files[i]
isItemAudio := strings.HasPrefix(fileItem.Type, "audio")
isItemVideo := strings.HasPrefix(fileItem.Type, "video")
if isItemAudio || isItemVideo {
// Get the real path for this file
itemRealPath, _, _ := index.GetRealPath(opts.Path, fileItem.Name)
// Extract metadata for audio files (without album art for performance)
if isItemAudio {
err := extractAudioMetadata(context.Background(), fileItem, itemRealPath, opts.AlbumArt || opts.Content, opts.Metadata)
if err != nil {
logger.Debugf("failed to extract metadata for file: "+fileItem.Name, err)
} else {
metadataCount++
}
} else if isItemVideo {
// Extract duration for video files
err := extractVideoMetadata(context.Background(), fileItem, itemRealPath)
if err != nil {
logger.Debugf("failed to extract video metadata for file: "+fileItem.Name, err)
} else {
metadataCount++
}
}
}
}
for i := range response.Folders {
folder := &response.Folders[i]
folder.IsShared = share.IsShared(response.Path+folder.Name, idx.Path, user.ID)
if metadataCount > 0 {
elapsed := time.Since(startTime)
logger.Debugf("Extracted metadata for %d audio/video files in %v (avg: %v per file)",
metadataCount, elapsed, elapsed/time.Duration(metadataCount))
}
response.IsShared = share.IsShared(response.Path, idx.Path, user.ID)
}
defer finalizeResponse(response, info, response.RealPath, user, userScope)
if opts.SkipExtendedAttrs {
return response, nil
}
// Process directory metadata if requested
if info.Type == "directory" {
processDirectoryMetadata(response, idx, opts)
}
// Process single file content/metadata
// Extract content/metadata when explicitly requested OR for single file audio/video requests
isAudioVideo := strings.HasPrefix(info.Type, "audio") || strings.HasPrefix(info.Type, "video")
if opts.Content || opts.Metadata || isAudioVideo {
processContent(response, idx, opts)
if opts.Content || opts.Metadata || (!isDir && isAudioVideo) {
processContent(response, index, opts)
}
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
response.OnlyOfficeId = generateOfficeId(realPath)
}
return response, nil
}
@@ -271,47 +144,46 @@ 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,
}
err := extractVideoMetadata(context.Background(), extItem, info.RealPath, nil)
err := extractVideoMetadata(context.Background(), extItem, info.RealPath)
if err != nil {
logger.Debugf("failed to extract video metadata for file: "+info.RealPath, info.Name, err)
} else {
info.Metadata = extItem.Metadata
}
parentPath := filepath.Dir(info.Path)
parentInfo, exists := idx.GetMetadataInfo(parentPath, true, false)
if exists {
info.GetSubtitles(parentInfo)
}
// Handle subtitles if requested
if opts.ExtractEmbeddedSubtitles {
subtitles := ffmpeg.DetectEmbeddedSubtitles(info.RealPath, info.ModTime)
info.Subtitles = append(info.Subtitles, subtitles...)
parentPath := filepath.Dir(info.Path)
parentInfo, exists := idx.GetReducedMetadata(parentPath, true)
if exists {
info.DetectSubtitles(parentInfo)
err := info.LoadSubtitleContent()
if err != nil {
logger.Debug("failed to load subtitle content: " + err.Error())
}
}
}
return
}
// 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,
}
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, nil)
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content)
if err != nil {
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
} else {
// Copy metadata to ExtendedFileInfo
info.Metadata = extItem.Metadata
info.HasPreview = extItem.Metadata != nil && len(extItem.Metadata.AlbumArt) > 0
info.HasPreview = extItem.Metadata != nil && extItem.Metadata.AlbumArt != ""
}
return
}
@@ -342,8 +214,7 @@ func generateOfficeId(realPath string) string {
// extractAudioMetadata extracts metadata from an audio file using dhowden/tag
// and optionally extracts duration using the ffmpeg service with concurrency control
// If ffmpegService is nil, a new service will be created (for backward compatibility)
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, ffmpegService *ffmpeg.FFmpegService) error {
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool) error {
file, err := os.Open(realPath)
if err != nil {
return err
@@ -382,14 +253,15 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
// Extract duration ONLY if explicitly requested using the ffmpeg VideoService
// This respects concurrency limits and gracefully handles missing ffmpeg
if getDuration {
// Use provided service or create a new one for backward compatibility
service := ffmpegService
if service == nil {
service = ffmpeg.NewFFmpegService(5, false, "")
}
if service != nil {
if duration, err := service.GetMediaDuration(ctx, realPath); err == nil {
ffmpegService := ffmpeg.NewFFmpegService(5, false, "")
if ffmpegService != nil {
startTime := time.Now()
if duration, err := ffmpegService.GetMediaDuration(ctx, realPath); err == nil {
item.Metadata.Duration = int(duration)
elapsed := time.Since(startTime)
if elapsed > 50*time.Millisecond {
logger.Debugf("Duration extraction took %v for file: %s", elapsed, item.Name)
}
}
}
}
@@ -398,11 +270,11 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
return nil
}
// Extract album art if available (stored as raw bytes, auto-encoded to base64 in JSON)
// Extract album art and encode as base64 with strict size limits
if picture := m.Picture(); picture != nil && picture.Data != nil {
// Size limit to prevent memory issues (max 5MB)
// More aggressive size limit to prevent memory issues (max 5MB)
if len(picture.Data) <= 5*1024*1024 {
item.Metadata.AlbumArt = picture.Data
item.Metadata.AlbumArt = base64.StdEncoding.EncodeToString(picture.Data)
} else {
logger.Debugf("Skipping album art for %s: too large (%d bytes)", realPath, len(picture.Data))
}
@@ -412,15 +284,11 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
}
// extractVideoMetadata extracts duration from video files using ffprobe
// If ffmpegService is nil, a new service will be created (for backward compatibility)
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, ffmpegService *ffmpeg.FFmpegService) error {
// Use provided service or create a new one for backward compatibility
service := ffmpegService
if service == nil {
service = ffmpeg.NewFFmpegService(10, false, "")
}
if service != nil {
duration, err := service.GetMediaDuration(ctx, realPath)
func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string) error {
// Extract duration using the ffmpeg VideoService with concurrency control
videoService := ffmpeg.NewFFmpegService(10, false, "")
if videoService != nil {
duration, err := videoService.GetMediaDuration(ctx, realPath)
if err != nil {
return err
}
@@ -434,19 +302,14 @@ func extractVideoMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
return nil
}
func DeleteFiles(source, absPath string, isDir bool) error {
func DeleteFiles(source, absPath string, absDirPath string, isDir bool) error {
index := indexing.GetIndex(source)
if index == nil {
return fmt.Errorf("could not get index: %v ", source)
}
// 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)
indexPath := index.MakeIndexPath(absPath)
// Perform the physical deletion
err := os.RemoveAll(absPath)
@@ -454,19 +317,19 @@ func DeleteFiles(source, absPath string, isDir bool) error {
return err
}
// Clear cache entries
indexing.RealPathCache.Delete(absPath)
indexing.IsDirCache.Delete(absPath + ":isdir")
// Remove metadata from index
deleteSuccess := index.DeleteMetadata(indexPath, isDir, isDir)
if !deleteSuccess {
logger.Errorf("Failed to delete metadata from index for %s, but filesystem deletion succeeded", indexPath)
if isDir {
// Recursively remove directory and all subdirectories from index
index.DeleteMetadata(indexPath, true, true)
} else {
// Remove file from parent's file list
index.DeleteMetadata(indexPath, false, false)
}
// Refresh the parent directory to recalculate sizes and update counts
// This will traverse up the tree and update all parent sizes correctly
refreshConfig := utils.FileOptions{
Path: index.MakeIndexPath(filepath.Dir(absPath), true),
Path: index.MakeIndexPath(absDirPath),
IsDir: true,
}
err = index.RefreshFileInfo(refreshConfig)
@@ -486,20 +349,15 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
return nil
}
// Always normalize path using MakeIndexPath
path = idx.MakeIndexPath(path, isDir)
path = idx.MakeIndexPath(path)
// Check if path is a symlink
realPath, _, _ := idx.GetRealPath(path)
isSymlink := false
if realPath != "" {
if fileInfo, err := os.Lstat(realPath); err == nil {
isSymlink = fileInfo.Mode()&os.ModeSymlink != 0
}
// MakeIndexPath always adds trailing slash, but for files we need to remove it
if !isDir {
path = strings.TrimSuffix(path, "/")
}
// Skip indexing only for paths that are explicitly excluded (not viewable and should be skipped)
hidden := indexing.IsHidden(filepath.Join(idx.Path, path))
if idx.ShouldSkip(isDir, path, hidden, isSymlink, false) {
// Skip indexing for viewable paths (viewable: true means don't index, just allow FS access)
if idx.IsViewable(isDir, path) {
return nil
}
@@ -512,8 +370,6 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
if !Exists(realPath) {
// Directory no longer exists, remove it from the index
// This clears both Directories and DirectoriesLedger maps
indexing.RealPathCache.Delete(realPath)
indexing.IsDirCache.Delete(realPath + ":isdir")
idx.DeleteMetadata(path, true, false)
return nil
}
@@ -521,10 +377,6 @@ func RefreshIndex(source string, path string, isDir bool, recursive bool) error
}
err := idx.RefreshFileInfo(utils.FileOptions{Path: path, IsDir: isDir, Recursive: recursive})
if err != nil {
logger.Errorf("RefreshFileInfo failed: %v", err)
return err
}
return err
}
@@ -579,7 +431,7 @@ func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
}
// Prepare paths for index operations
srcIndexPath := srcIdx.MakeIndexPath(realsrc, isSrcDir)
srcIndexPath := srcIdx.MakeIndexPath(realsrc)
srcParentPath := filepath.Dir(realsrc)
// Perform the physical move
@@ -589,52 +441,47 @@ func MoveResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
}
// Handle SOURCE cleanup (treat as deletion)
// Run async to avoid blocking the HTTP response
if !srcIdx.Config.DisableIndexing {
go func() {
if isSrcDir {
srcIdx.DeleteMetadata(srcIndexPath, true, true)
} else {
srcIdx.DeleteMetadata(srcIndexPath, false, false)
}
if err := RefreshIndex(sourceIndex, srcParentPath, true, false); err != nil {
logger.Errorf("Failed to refresh source parent directory %s after move: %v", srcParentPath, err)
}
}()
// Remove metadata from source index
if isSrcDir {
// Recursively remove directory and all subdirectories from source index
srcIdx.DeleteMetadata(srcIndexPath, true, true)
} else {
// Remove file from source parent's file list
srcIdx.DeleteMetadata(srcIndexPath, false, false)
}
// Refresh the source parent directory to recalculate sizes and update counts
go RefreshIndex(sourceIndex, srcParentPath, true, false) //nolint:errcheck
}
// Handle DESTINATION indexing
// Run async to avoid blocking the HTTP response
if !dstIdx.Config.DisableIndexing {
if isSrcDir {
go func() {
// Recursively index the moved directory tree
if err := RefreshIndex(destIndex, realdst, true, true); err != nil {
logger.Errorf("Failed to index moved directory %s: %v", realdst, err)
return
}
// When moving a folder, refresh the folder itself recursively FIRST
// Must complete before parent refresh to avoid race condition
RefreshIndex(destIndex, realdst, true, true) //nolint:errcheck
// Refresh parent to update its size
// THEN refresh the parent directory so it sees the newly indexed child
parentDir := filepath.Dir(realdst)
if err := RefreshIndex(destIndex, parentDir, true, false); err != nil {
logger.Errorf("Failed to refresh destination parent %s: %v", parentDir, err)
}
RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
}()
} else {
// For files, refresh parent directory
// If moving a file, just refresh the parent directory
parentDir := filepath.Dir(realdst)
go RefreshIndex(destIndex, parentDir, true, false) //nolint:errcheck
}
}
// Use backend source paths to match how shares are stored
go s.UpdateShares(srcIdx.Path, srcIdx.MakeIndexPath(realsrc, isSrcDir), dstIdx.Path, dstIdx.MakeIndexPath(realdst, isSrcDir)) //nolint:errcheck
go s.UpdateShares(srcIdx.Path, srcIdx.MakeIndexPath(realsrc), dstIdx.Path, dstIdx.MakeIndexPath(realdst)) //nolint:errcheck
// Update access rules for the moved path
if a != nil {
// If moving within the same source, update the rules
if srcIdx.Path == dstIdx.Path {
go a.UpdateRules(srcIdx.Path, srcIdx.MakeIndexPath(realsrc, isSrcDir), dstIdx.MakeIndexPath(realdst, isSrcDir)) //nolint:errcheck
go a.UpdateRules(srcIdx.Path, srcIdx.MakeIndexPath(realsrc), dstIdx.MakeIndexPath(realdst)) //nolint:errcheck
}
// Cross-source moves don't preserve access rules (they're source-specific)
}
@@ -650,51 +497,37 @@ func CopyResource(isSrcDir bool, sourceIndex, destIndex, realsrc, realdst string
// Validate the copy operation before executing
if err := validateMoveDestination(realsrc, realdst, isSrcDir); err != nil {
logger.Errorf("[COPY] Copy validation failed: %v", err)
return err
}
// Perform the physical copy
err := fileutils.CopyFile(realsrc, realdst)
if err != nil {
logger.Errorf("[COPY] Physical copy failed: %v", err)
return err
}
// Refresh source (non-recursive, just metadata)
srcIdx := indexing.GetIndex(sourceIndex)
if srcIdx != nil && !srcIdx.Config.DisableIndexing {
srcRefreshPath := realsrc
if !isSrcDir {
srcRefreshPath = filepath.Dir(realsrc)
}
go RefreshIndex(sourceIndex, srcRefreshPath, true, false) //nolint:errcheck
// For copy operations:
// 1. Shallow refresh of source (just to update access times if needed)
// 2. Recursively index the destination to capture the entire copied tree
// Refresh source (parent directory if it's a file)
srcRefreshPath := realsrc
srcRefreshIsDir := isSrcDir
if !isSrcDir {
srcRefreshPath = filepath.Dir(realsrc)
srcRefreshIsDir = true
}
// Refresh destination (RECURSIVE for directories to capture full tree)
// Run async to avoid blocking the HTTP response
dstIdx := indexing.GetIndex(destIndex)
if dstIdx != nil && !dstIdx.Config.DisableIndexing {
if isSrcDir {
go func() {
// Recursively index the copied directory tree
if err := RefreshIndex(destIndex, realdst, true, true); err != nil {
logger.Errorf("[COPY] Failed to index copied directory %s: %v", realdst, err)
return
}
go RefreshIndex(sourceIndex, srcRefreshPath, srcRefreshIsDir, false) //nolint:errcheck
// Refresh parent to update its size
parentDir := filepath.Dir(realdst)
if err := RefreshIndex(destIndex, parentDir, true, false); err != nil {
logger.Errorf("[COPY] Failed to refresh parent %s: %v", parentDir, err)
}
}()
} else {
// For files, refresh parent directory
dstParent := filepath.Dir(realdst)
go RefreshIndex(destIndex, dstParent, true, false) //nolint:errcheck
}
// Refresh destination (parent directory if it's a file)
dstRefreshPath := realdst
if !isSrcDir {
// If copying a file (regardless of destination), refresh the parent directory
dstRefreshPath = filepath.Dir(realdst)
}
go RefreshIndex(destIndex, dstRefreshPath, true, true) //nolint:errcheck
return nil
}
@@ -717,7 +550,6 @@ func WriteDirectory(opts utils.FileOptions) error {
}
// Ensure the parent directories exist
// Permissions are set by MkdirAll (subject to umask, which is usually acceptable)
err = os.MkdirAll(realPath, fileutils.PermDir)
if err != nil {
return err
@@ -725,26 +557,11 @@ func WriteDirectory(opts utils.FileOptions) error {
// Explicitly set directory permissions to bypass umask
err = os.Chmod(realPath, fileutils.PermDir)
if err != nil {
// Handle chmod error gracefully
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", realPath, err)
}
// Refresh the directory itself recursively
err = RefreshIndex(idx.Name, opts.Path, true, true)
if err != nil {
return err
}
// Refresh parent directory to update its size
parentPath := filepath.Dir(opts.Path)
if parentPath != "." && parentPath != "/" && parentPath != opts.Path {
if err := RefreshIndex(idx.Name, parentPath, true, false); err != nil {
logger.Debugf("Could not refresh parent directory %s: %v", parentPath, err)
}
}
return nil
return RefreshIndex(idx.Name, opts.Path, true, true)
}
func WriteFile(source, path string, in io.Reader) error {
@@ -761,21 +578,17 @@ func WriteFile(source, path string, in io.Reader) error {
if err != nil {
return err
}
applyDefaultFilePerm := false
var stat os.FileInfo
stat, err = os.Stat(realPath)
if err != nil {
if !os.IsNotExist(err) {
return fmt.Errorf("stat file: %w", err)
// Check if the destination exists and is a directory
if stat, err = os.Stat(realPath); err == nil && stat.IsDir() {
// If it's a directory and we're trying to create a file, remove the directory first
err = os.RemoveAll(realPath)
if err != nil {
return fmt.Errorf("could not remove existing directory to create file: %v", err)
}
applyDefaultFilePerm = true
} else if stat.IsDir() {
return fmt.Errorf("%w: %q", errors.ErrIsDirectory, path)
}
// Open the file for writing (create if it doesn't exist, truncate if it does)
// For new files: permissions are set to fileutils.PermFile (subject to umask, then Chmod bypasses umask)
// For existing regular files: do not Chmod so saves preserve mode and special bits
file, err := os.OpenFile(realPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileutils.PermFile)
if err != nil {
return err
@@ -788,55 +601,137 @@ func WriteFile(source, path string, in io.Reader) error {
return err
}
if applyDefaultFilePerm {
err = os.Chmod(realPath, fileutils.PermFile)
if err != nil {
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", realPath, err)
}
}
// Refresh the file itself
err = RefreshIndex(source, path, false, false)
// Explicitly set file permissions to bypass umask
err = os.Chmod(realPath, fileutils.PermFile)
if err != nil {
return err
}
// Refresh parent directory to update its size
parentPath := filepath.Dir(path)
if parentPath != "." && parentPath != "/" && parentPath != path {
if err := RefreshIndex(source, parentPath, true, false); err != nil {
logger.Debugf("Could not refresh parent directory %s: %v", parentPath, err)
}
}
return nil
return RefreshIndex(source, path, false, false)
}
// getContent reads and returns the file content if it's considered an editable text file.
// This is a wrapper around utils.IsTextFile that also returns the file content.
func getContent(realPath string) (string, error) {
// First check if it's a text file using the consolidated validation logic
isText, err := utils.IsTextFile(realPath)
const headerSize = 4096
// Thresholds for detecting binary-like content (these can be tuned)
const maxNullBytesInHeaderAbs = 10 // Max absolute null bytes in header
const maxNullByteRatioInHeader = 0.1 // Max 10% null bytes in header
const maxNullByteRatioInFile = 0.05 // Max 5% null bytes in the entire file
const maxNonPrintableRuneRatio = 0.05 // Max 5% non-printable runes in the entire file
// Open file
f, err := os.Open(realPath)
if err != nil {
return "", err
}
if !isText {
// Not a text file, return empty string (no error, just not text)
return "", nil
}
defer f.Close()
// It's a text file, read and return the content
// Read header
headerBytes := make([]byte, headerSize)
n, err := f.Read(headerBytes)
if err != nil && err != io.EOF {
return "", err
}
actualHeader := headerBytes[:n]
// --- Start of new heuristic checks ---
if n > 0 {
// 1. Basic Check: Is the header valid UTF-8?
// If not, it's unlikely an editable UTF-8 text file.
if !utf8.Valid(actualHeader) {
return "", nil // Not an error, just not the text file we want
}
// 2. Check for excessive null bytes in the header
nullCountInHeader := 0
for _, b := range actualHeader {
if b == 0x00 {
nullCountInHeader++
}
}
// Reject if too many nulls absolutely or relatively in the header
if nullCountInHeader > 0 { // Only perform check if there are any nulls
if nullCountInHeader > maxNullBytesInHeaderAbs ||
(float64(nullCountInHeader)/float64(n) > maxNullByteRatioInHeader) {
return "", nil // Too many nulls in header
}
}
// 3. Check for other non-text ASCII control characters in the header
// (C0 controls excluding \t, \n, \r)
for _, b := range actualHeader {
if b < 0x20 && b != '\t' && b != '\n' && b != '\r' {
return "", nil // Found problematic control character
}
// C1 control characters (0x80-0x9F) would be caught by utf8.Valid if part of invalid sequences,
// or by the non-printable rune check later if they form valid (but undesirable) codepoints.
}
// Optional: Use http.DetectContentType for an additional check on the header
// contentType := http.DetectContentType(actualHeader)
// if !strings.HasPrefix(contentType, "text/") && contentType != "application/octet-stream" {
// // If it's clearly a non-text MIME type (e.g., "image/jpeg"), reject it.
// // "application/octet-stream" is ambiguous, so we rely on other heuristics.
// return "", nil
// }
}
// --- End of new heuristic checks for header ---
// Now read the full file (original logic)
content, err := os.ReadFile(realPath)
if err != nil {
return "", err
}
// Handle empty file (return specific marker string)
// Handle empty file (original logic - returns specific string)
if len(content) == 0 {
return "empty-file-x6OlSil", nil
}
return string(content), nil
stringContent := string(content)
// 4. Final UTF-8 validation for the entire file
// (This is crucial as the header might be fine, but the rest of the file isn't)
if !utf8.ValidString(stringContent) {
return "", nil
}
// 5. Check for excessive null bytes in the entire file content
if len(content) > 0 { // Check only for non-empty files
totalNullCount := 0
for _, b := range content {
if b == 0x00 {
totalNullCount++
}
}
if float64(totalNullCount)/float64(len(content)) > maxNullByteRatioInFile {
return "", nil // Too many nulls in the entire file
}
}
// 6. Check for excessive non-printable runes in the entire file content
// (Excluding tab, newline, carriage return, which are common in text files)
if len(stringContent) > 0 { // Check only for non-empty strings
nonPrintableRuneCount := 0
totalRuneCount := 0
for _, r := range stringContent {
totalRuneCount++
// unicode.IsPrint includes letters, numbers, punctuation, symbols, and spaces.
// It excludes control characters. We explicitly allow \t, \n, \r.
if !unicode.IsPrint(r) && r != '\t' && r != '\n' && r != '\r' {
nonPrintableRuneCount++
}
}
if totalRuneCount > 0 { // Avoid division by zero
if float64(nonPrintableRuneCount)/float64(totalRuneCount) > maxNonPrintableRuneRatio {
return "", nil // Too many non-printable runes
}
}
}
// The file has passed all checks and is considered editable text.
return stringContent, nil
}
func IsNamedPipe(mode os.FileMode) bool {
+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
+23 -17
View File
@@ -6,9 +6,7 @@ import (
"path/filepath"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// MakeUserDir makes the user directory according to settings.
@@ -19,7 +17,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)
@@ -29,29 +27,37 @@ func MakeUserDirs(u *users.User, createDir bool) error {
if !ok {
return fmt.Errorf("MakeUserDirs: source not found: %s", scope.Name)
}
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)
// validate that scope path exists
if !Exists(parentDir) {
if err := MakeUserDir(parentDir); err != nil {
logger.Errorf("MakeUserDirs: failed to create parent scope directory: %s - %v", scope.Scope, err)
continue
}
return fmt.Errorf("MakeUserDirs: scope path does not exist: %s", scope.Scope)
}
scope.Scope = filepath.Join(scope.Scope, cleanedUserName)
err := MakeUserDir(fullPath)
if err != nil {
return fmt.Errorf("MakeUserDirs: failed to create user home dir: %s", err)
}
} 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) {
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", parentDir)
}
err := MakeUserDir(fullPath)
if err != nil {
return fmt.Errorf("create user: failed to create user home dir: %s", err)
}
} 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) {
return fmt.Errorf("MakeUserDirs: scope folder does not exist: %s", path)
}
}
u.Scopes[i] = scope
}
return nil
-36
View File
@@ -1,36 +0,0 @@
package fileutils
import (
"io/fs"
"net/http"
)
var (
assetFs fs.FS
)
// Custom dirFS to handle both embedded and non-embedded file systems
type dirFS struct {
http.Dir
}
// Implement the Open method for dirFS, which wraps http.Dir
func (d dirFS) Open(name string) (fs.File, error) {
return d.Dir.Open(name)
}
// InitAssetFS initializes the asset filesystem for the application
// This should be called once during startup before http or preview services start
func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool) {
if useEmbedded {
assetFs = embeddedAssets
} else {
// Dev mode: Serve files from http/dist directory
assetFs = dirFS{Dir: http.Dir("http/dist")}
}
}
// GetAssetFS returns the initialized asset filesystem
func GetAssetFS() fs.FS {
return assetFs
}
+9 -54
View File
@@ -12,45 +12,9 @@ import (
var PermFile os.FileMode
var PermDir os.FileMode
// SetFsPermissions sets create modes from Unix chmod(2)-style values (for example
// values produced by strconv.ParseUint(s, 8, 32)). setuid, setgid, and sticky
// bits must be converted for Go's os.FileMode; a plain cast from Unix octal is
// incorrect for those bits.
func SetFsPermissions(unixFileMode, unixDirMode uint32) {
PermFile = unixModeToFileMode(unixFileMode)
PermDir = unixModeToFileMode(unixDirMode)
}
// EffectiveDirPerm returns [PermDir] if [SetFsPermissions] was called, otherwise 0o755. On Unix,
// [os.Mkdir] with perm 0 fails with permission denied, so use this (or [PermFile]) for creates when
// the process has not set globals yet.
func EffectiveDirPerm() os.FileMode {
if PermDir == 0 {
return 0o755
}
return PermDir
}
// EffectiveFilePerm returns [PermFile] if set, otherwise 0o644.
func EffectiveFilePerm() os.FileMode {
if PermFile == 0 {
return 0o644
}
return PermFile
}
func unixModeToFileMode(u uint32) os.FileMode {
m := os.FileMode(u & 0777)
if u&0o4000 != 0 {
m |= os.ModeSetuid
}
if u&0o2000 != 0 {
m |= os.ModeSetgid
}
if u&0o1000 != 0 {
m |= os.ModeSticky
}
return m
func SetFsPermissions(PermFileOctal os.FileMode, PermDirOctal os.FileMode) {
PermFile = PermFileOctal
PermDir = PermDirOctal
}
// MoveFile moves a file from src to dst.
@@ -98,13 +62,6 @@ func CopyFile(source, dest string) error {
// copySingleFile handles copying a single file.
func copySingleFile(source, dest string) error {
// Get source file info to preserve permissions
srcInfo, err := os.Stat(source)
if err != nil {
return err
}
sourcePerms := srcInfo.Mode().Perm()
// Open the source file.
src, err := os.Open(source)
if err != nil {
@@ -118,8 +75,8 @@ func copySingleFile(source, dest string) error {
return err
}
// Create the destination file with source permissions
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, sourcePerms)
// Create the destination file.
dst, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, PermFile)
if err != nil {
return err
}
@@ -131,13 +88,11 @@ func copySingleFile(source, dest string) error {
return err
}
// Preserve source file permissions
// Handle chmod errors gracefully (e.g., in rootless containers where chmod may be restricted)
err = os.Chmod(dest, sourcePerms)
// Set the configured file permissions instead of copying from source
err = os.Chmod(dest, PermFile)
if err != nil {
// Log but don't fail - chmod may be restricted in some environments
// The file was copied successfully, so we continue
logger.Debugf("Could not set file permissions for %s (this may be expected in restricted environments): %v", dest, err)
return err
}
return nil
+1 -17
View File
@@ -1,22 +1,6 @@
package fileutils
import (
"os"
"testing"
)
func TestUnixModeToFileMode_setgid(t *testing.T) {
m := unixModeToFileMode(0o2770)
if got := m.Perm(); got != 0o770 {
t.Fatalf("Perm() = %#o, want 0770", got)
}
if m&os.ModeSetgid == 0 {
t.Fatal("expected ModeSetgid")
}
if m&os.ModeSetuid != 0 || m&os.ModeSticky != 0 {
t.Fatalf("unexpected special bits: %#o", m)
}
}
import "testing"
func TestCommonPrefix(t *testing.T) {
testCases := map[string]struct {
@@ -1,44 +0,0 @@
//go:build !windows
package fileutils
import (
"syscall"
)
// GetPartitionSize returns the filesystem size for Unix systems
func GetPartitionSize(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
// Total size in bytes: Blocks * Block size
total := uint64(stat.Blocks) * uint64(stat.Bsize)
return total, nil
}
// GetFreeSpace returns the available free space for Unix systems
func GetFreeSpace(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
// Available free space in bytes: Available blocks * Block size
free := uint64(stat.Bavail) * uint64(stat.Bsize)
return free, nil
}
// GetPartitionUsed returns the used space for Unix systems (total - free)
func GetPartitionUsed(path string) (uint64, error) {
var stat syscall.Statfs_t
err := syscall.Statfs(path, &stat)
if err != nil {
return 0, err
}
total := uint64(stat.Blocks) * uint64(stat.Bsize)
free := uint64(stat.Bavail) * uint64(stat.Bsize)
used := total - free
return used, nil
}
@@ -1,50 +0,0 @@
//go:build windows
package fileutils
import (
"golang.org/x/sys/windows"
)
// GetPartitionSize returns the filesystem size for Windows systems
func GetPartitionSize(path string) (uint64, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, err
}
var freeBytes, totalBytes, totalFreeBytes uint64
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytes, &totalBytes, &totalFreeBytes)
if err != nil {
return 0, err
}
return totalBytes, nil
}
// GetFreeSpace returns the available free space for Windows systems
func GetFreeSpace(path string) (uint64, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, err
}
var freeBytes, totalBytes, totalFreeBytes uint64
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytes, &totalBytes, &totalFreeBytes)
if err != nil {
return 0, err
}
return freeBytes, nil
}
// GetPartitionUsed returns the used space for Windows systems (total - free)
func GetPartitionUsed(path string) (uint64, error) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return 0, err
}
var freeBytes, totalBytes, totalFreeBytes uint64
err = windows.GetDiskFreeSpaceEx(pathPtr, &freeBytes, &totalBytes, &totalFreeBytes)
if err != nil {
return 0, err
}
used := totalBytes - freeBytes
return used, nil
}
+17 -48
View File
@@ -1,65 +1,34 @@
package auth
import (
"fmt"
"net/http"
"time"
"sync"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
var (
revokedApiKeyList map[string]struct{}
revokeMu sync.Mutex
)
func init() {
revokedApiKeyList = make(map[string]struct{})
}
// Auther is the authentication interface.
type Auther interface {
// Auth is called to authenticate a request.
Auth(r *http.Request, userStore *users.Storage) (*users.User, error)
}
const FB_ISSUER = "FileBrowser Quantum" // don't change
// IsRevokedApiToken checks if a token is in the revoked list.
func IsRevokedApiToken(accessStore *access.Storage, token string) bool {
if accessStore == nil {
return false
}
return accessStore.IsTokenRevoked(token)
func IsRevokedApiKey(key string) bool {
_, exists := revokedApiKeyList[key]
return exists
}
// RevokeApiToken adds a token to the revoked list.
func RevokeApiToken(accessStore *access.Storage, token string) error {
if accessStore == nil {
return fmt.Errorf("access storage not available")
}
return accessStore.RevokeToken(token)
}
func MakeSignedTokenAPI(user *users.User, name string, duration time.Duration, perms users.Permissions, minimal bool) (string, users.AuthToken, error) {
if _, ok := user.Tokens[name]; ok {
return "", users.AuthToken{}, fmt.Errorf("key already exists with same name %v ", name)
}
now := time.Now()
expires := now.Add(duration)
// Create minimal token with only JWT standard claims
claim := users.AuthToken{
MinimalAuthToken: users.MinimalAuthToken{
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
Issuer: FB_ISSUER,
},
},
}
if !minimal {
claim.Permissions = perms
claim.BelongsTo = user.ID
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claim)
tokenString, err := token.SignedString([]byte(settings.Config.Auth.Key))
if err != nil {
return "", users.AuthToken{}, err
}
return tokenString, claim, nil
func RevokeAPIKey(key string) {
revokeMu.Lock()
defer revokeMu.Unlock()
revokedApiKeyList[key] = struct{}{}
}
+8 -20
View File
@@ -9,14 +9,12 @@ import (
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// JSONAuth is a json implementation of an Auther.
type JSONAuth struct {
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
DisableOtp bool `json:"disableOtp" yaml:"disableOtp"`
ReCaptcha *ReCaptcha `json:"recaptcha" yaml:"recaptcha"`
}
// Auth authenticates the user via a json in content body.
@@ -25,11 +23,6 @@ func (auther JSONAuth) Auth(r *http.Request, userStore *users.Storage) (*users.U
recaptcha := r.URL.Query().Get("recaptcha")
password := r.Header.Get("X-Password")
totpCode := r.Header.Get("X-Secret")
// URL-decode password to support special characters in headers
password, err := url.QueryUnescape(password)
if err != nil {
return nil, fmt.Errorf("invalid password encoding: %v", err)
}
// If ReCaptcha is enabled, check the code.
if auther.ReCaptcha != nil && len(auther.ReCaptcha.Secret) > 0 {
@@ -44,22 +37,17 @@ 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)
}
if user.TOTPSecret != "" && !auther.DisableOtp {
// check for OTP for password
if user.TOTPSecret != "" {
if totpCode == "" {
return nil, errors.ErrNoTotpProvided
}
-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")
}
-229
View File
@@ -1,229 +0,0 @@
package auth
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// MethodJwtAuth is used to identify JWT external authentication.
const MethodJwtAuth = "jwt"
// JwtAuth is an external JWT implementation of an auther.
// It verifies JWT tokens signed with a shared secret and extracts user identity.
type JwtAuth struct {
Secret string `json:"secret"` // shared secret for verifying JWT signatures
Algorithm string `json:"algorithm"` // JWT signing algorithm (HS256, HS384, HS512, RS256, ES256, etc.)
}
// ExternalJwtClaims represents the expected claims in an external JWT token
type ExternalJwtClaims struct {
jwt.RegisteredClaims
Username string `json:"sub"` // subject claim (username)
Email string `json:"email,omitempty"` // email claim
Name string `json:"name,omitempty"` // full name claim
Groups []string `json:"groups,omitempty"` // groups claim
Custom map[string]interface{} `json:"-"` // catch-all for custom claims
}
// Auth authenticates the user via an external JWT token.
// The token is verified using the configured secret and algorithm.
func (a JwtAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
// This should not be called directly - JWT auth is handled in middleware
// because we need access to the full config (header name, user identifier field, etc.)
return nil, fmt.Errorf("JWT auth must be handled in middleware")
}
// VerifyExternalJWT verifies an external JWT token and extracts the username claim
func VerifyExternalJWT(tokenString string, secret string, algorithm string, userIdentifierField string) (string, map[string]interface{}, error) {
// Parse the token with the secret
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Verify the signing method matches what we expect
expectedMethod := jwt.GetSigningMethod(algorithm)
if expectedMethod == nil {
return nil, fmt.Errorf("unsupported signing algorithm: %s", algorithm)
}
if token.Method.Alg() != expectedMethod.Alg() {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return jwtVerificationKey(secret, token.Method, algorithm)
})
if err != nil {
logger.Debugf("JWT token verification failed: %v", err)
return "", nil, fmt.Errorf("invalid JWT token: %w", err)
}
if !token.Valid {
return "", nil, fmt.Errorf("JWT token is invalid")
}
// Extract claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return "", nil, fmt.Errorf("failed to parse JWT claims")
}
// Check if token is expired
if err := claims.Valid(); err != nil {
logger.Debugf("JWT claims validation failed: %v", err)
return "", nil, fmt.Errorf("JWT token is expired or invalid: %w", err)
}
// Extract username from the configured field (default: "sub")
var username string
if userVal, ok := claims[userIdentifierField]; ok {
if userStr, ok := userVal.(string); ok {
username = userStr
}
}
if username == "" {
return "", nil, fmt.Errorf("JWT token missing required claim: %s", userIdentifierField)
}
// Convert claims to map for additional processing
claimsMap := make(map[string]interface{})
for k, v := range claims {
claimsMap[k] = v
}
logger.Debugf("Successfully verified JWT token for user: %s", username)
return username, claimsMap, nil
}
func jwtVerificationKey(secret string, method jwt.SigningMethod, algorithm string) (interface{}, error) {
switch method.(type) {
case *jwt.SigningMethodHMAC:
return []byte(secret), nil
case *jwt.SigningMethodRSA:
key, err := parsePublicKeyFromPEM(secret)
if err != nil {
return nil, err
}
rsaKey, ok := key.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("JWT secret must contain an RSA public key in PEM format for algorithm %s", algorithm)
}
return rsaKey, nil
case *jwt.SigningMethodECDSA:
key, err := parsePublicKeyFromPEM(secret)
if err != nil {
return nil, err
}
ecKey, ok := key.(*ecdsa.PublicKey)
if !ok {
return nil, fmt.Errorf("JWT secret must contain an ECDSA public key in PEM format for algorithm %s", algorithm)
}
if err := ecdsaCurveMatchesAlgorithm(algorithm, ecKey); err != nil {
return nil, err
}
return ecKey, nil
default:
return nil, fmt.Errorf("unsupported JWT signing method: %s", method.Alg())
}
}
func ecdsaCurveMatchesAlgorithm(algorithm string, pub *ecdsa.PublicKey) error {
var want elliptic.Curve
switch algorithm {
case "ES256":
want = elliptic.P256()
case "ES384":
want = elliptic.P384()
case "ES512":
want = elliptic.P521()
default:
return nil
}
if pub.Curve != want {
return fmt.Errorf("ECDSA public key curve does not match algorithm %s", algorithm)
}
return nil
}
func parsePublicKeyFromPEM(pemData string) (interface{}, error) {
rest := []byte(pemData)
var lastErr error
for len(rest) > 0 {
block, rem := pem.Decode(rest)
if block == nil {
break
}
rest = rem
var key interface{}
var err error
switch block.Type {
case "CERTIFICATE":
var cert *x509.Certificate
cert, err = x509.ParseCertificate(block.Bytes)
if err == nil {
key = cert.PublicKey
}
case "PUBLIC KEY":
key, err = x509.ParsePKIXPublicKey(block.Bytes)
case "RSA PUBLIC KEY":
key, err = x509.ParsePKCS1PublicKey(block.Bytes)
case "EC PUBLIC KEY":
key, err = x509.ParsePKIXPublicKey(block.Bytes)
default:
continue
}
if err != nil {
lastErr = err
continue
}
if key != nil {
return key, nil
}
}
if lastErr != nil {
return nil, fmt.Errorf("failed to parse JWT public key from PEM: %w", lastErr)
}
return nil, fmt.Errorf("no public key found in JWT secret (expected PEM: CERTIFICATE, PUBLIC KEY, RSA PUBLIC KEY, or EC PUBLIC KEY)")
}
// ExtractGroupsFromClaims extracts groups from JWT claims based on the configured groups claim field
func ExtractGroupsFromClaims(claims map[string]interface{}, groupsClaimField string) []string {
var groups []string
if groupsVal, ok := claims[groupsClaimField]; ok {
switch v := groupsVal.(type) {
case []interface{}:
// Groups as array of interfaces
for _, g := range v {
if groupStr, ok := g.(string); ok {
groups = append(groups, groupStr)
}
}
case []string:
// Groups as array of strings
groups = v
case string:
// Single group as string
groups = []string{v}
default:
// Try to unmarshal as JSON array
if jsonBytes, err := json.Marshal(v); err == nil {
var groupsArray []string
if err := json.Unmarshal(jsonBytes, &groupsArray); err == nil {
groups = groupsArray
}
}
}
}
return groups
}
-363
View File
@@ -1,363 +0,0 @@
package auth_test
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"testing"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/auth"
)
func TestVerifyExternalJWT_ValidToken(t *testing.T) {
secret := "test-secret-key"
algorithm := "HS256"
username := "testuser"
// Create a valid JWT token
claims := jwt.MapClaims{
"sub": username,
"email": "test@example.com",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("Failed to create test token: %v", err)
}
// Verify the token
extractedUsername, extractedClaims, err := auth.VerifyExternalJWT(tokenString, secret, algorithm, "sub")
if err != nil {
t.Fatalf("VerifyExternalJWT() failed: %v", err)
}
if extractedUsername != username {
t.Errorf("Expected username %s, got %s", username, extractedUsername)
}
if extractedClaims["email"] != "test@example.com" {
t.Errorf("Expected email test@example.com, got %v", extractedClaims["email"])
}
}
func TestVerifyExternalJWT_ExpiredToken(t *testing.T) {
secret := "test-secret-key"
algorithm := "HS256"
// Create an expired JWT token
claims := jwt.MapClaims{
"sub": "testuser",
"exp": time.Now().Add(-time.Hour).Unix(), // Expired 1 hour ago
"iat": time.Now().Add(-2 * time.Hour).Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("Failed to create test token: %v", err)
}
// Verify the token - should fail
_, _, err = auth.VerifyExternalJWT(tokenString, secret, algorithm, "sub")
if err == nil {
t.Fatal("VerifyExternalJWT() should fail for expired token")
}
}
func TestVerifyExternalJWT_InvalidSignature(t *testing.T) {
secret := "test-secret-key"
wrongSecret := "wrong-secret-key"
algorithm := "HS256"
// Create a token with one secret
claims := jwt.MapClaims{
"sub": "testuser",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("Failed to create test token: %v", err)
}
// Try to verify with a different secret - should fail
_, _, err = auth.VerifyExternalJWT(tokenString, wrongSecret, algorithm, "sub")
if err == nil {
t.Fatal("VerifyExternalJWT() should fail for invalid signature")
}
}
func TestVerifyExternalJWT_MissingUsernameClaim(t *testing.T) {
secret := "test-secret-key"
algorithm := "HS256"
// Create a token without the 'sub' claim
claims := jwt.MapClaims{
"email": "test@example.com",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("Failed to create test token: %v", err)
}
// Verify the token - should fail due to missing 'sub' claim
_, _, err = auth.VerifyExternalJWT(tokenString, secret, algorithm, "sub")
if err == nil {
t.Fatal("VerifyExternalJWT() should fail for missing username claim")
}
}
func TestVerifyExternalJWT_CustomUserIdentifier(t *testing.T) {
secret := "test-secret-key"
algorithm := "HS256"
username := "testuser"
// Create a token with custom username field
claims := jwt.MapClaims{
"username": username,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(secret))
if err != nil {
t.Fatalf("Failed to create test token: %v", err)
}
// Verify the token with custom user identifier field
extractedUsername, _, err := auth.VerifyExternalJWT(tokenString, secret, algorithm, "username")
if err != nil {
t.Fatalf("VerifyExternalJWT() failed: %v", err)
}
if extractedUsername != username {
t.Errorf("Expected username %s, got %s", username, extractedUsername)
}
}
func TestVerifyExternalJWT_ES256_PublicKeyPEM(t *testing.T) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
t.Fatalf("MarshalPKIXPublicKey: %v", err)
}
pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
username := "ec-user"
claims := jwt.MapClaims{
"sub": username,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
tokenString, err := token.SignedString(priv)
if err != nil {
t.Fatalf("SignedString: %v", err)
}
got, _, err := auth.VerifyExternalJWT(tokenString, pubPEM, "ES256", "sub")
if err != nil {
t.Fatalf("VerifyExternalJWT: %v", err)
}
if got != username {
t.Errorf("username: got %q want %q", got, username)
}
}
func TestVerifyExternalJWT_RS256_PublicKeyPEM(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
pubDER, err := x509.MarshalPKIXPublicKey(&priv.PublicKey)
if err != nil {
t.Fatalf("MarshalPKIXPublicKey: %v", err)
}
pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
username := "rsa-user"
claims := jwt.MapClaims{
"sub": username,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tokenString, err := token.SignedString(priv)
if err != nil {
t.Fatalf("SignedString: %v", err)
}
got, _, err := auth.VerifyExternalJWT(tokenString, pubPEM, "RS256", "sub")
if err != nil {
t.Fatalf("VerifyExternalJWT: %v", err)
}
if got != username {
t.Errorf("username: got %q want %q", got, username)
}
}
func TestVerifyExternalJWT_ES256_WrongCurveRejected(t *testing.T) {
p256Priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
p384Priv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
verifyDER, err := x509.MarshalPKIXPublicKey(&p384Priv.PublicKey)
if err != nil {
t.Fatalf("MarshalPKIXPublicKey: %v", err)
}
verifyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: verifyDER}))
claims := jwt.MapClaims{
"sub": "u",
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
tokenString, err := token.SignedString(p256Priv)
if err != nil {
t.Fatalf("SignedString: %v", err)
}
_, _, err = auth.VerifyExternalJWT(tokenString, verifyPEM, "ES256", "sub")
if err == nil {
t.Fatal("expected error when PEM public key curve does not match algorithm")
}
}
func TestVerifyExternalJWT_CertificatePEM(t *testing.T) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
tpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "jwt-test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
}
certDER, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("CreateCertificate: %v", err)
}
certPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
username := "cert-user"
claims := jwt.MapClaims{
"sub": username,
"exp": time.Now().Add(time.Hour).Unix(),
"iat": time.Now().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
tokenString, err := token.SignedString(priv)
if err != nil {
t.Fatalf("SignedString: %v", err)
}
got, _, err := auth.VerifyExternalJWT(tokenString, certPEM, "ES256", "sub")
if err != nil {
t.Fatalf("VerifyExternalJWT: %v", err)
}
if got != username {
t.Errorf("username: got %q want %q", got, username)
}
}
func TestExtractGroupsFromClaims_ArrayOfStrings(t *testing.T) {
claims := map[string]interface{}{
"groups": []string{"admin", "users"},
}
groups := auth.ExtractGroupsFromClaims(claims, "groups")
if len(groups) != 2 {
t.Fatalf("Expected 2 groups, got %d", len(groups))
}
if groups[0] != "admin" || groups[1] != "users" {
t.Errorf("Expected groups [admin, users], got %v", groups)
}
}
func TestExtractGroupsFromClaims_ArrayOfInterfaces(t *testing.T) {
claims := map[string]interface{}{
"groups": []interface{}{"admin", "users"},
}
groups := auth.ExtractGroupsFromClaims(claims, "groups")
if len(groups) != 2 {
t.Fatalf("Expected 2 groups, got %d", len(groups))
}
if groups[0] != "admin" || groups[1] != "users" {
t.Errorf("Expected groups [admin, users], got %v", groups)
}
}
func TestExtractGroupsFromClaims_SingleString(t *testing.T) {
claims := map[string]interface{}{
"groups": "admin",
}
groups := auth.ExtractGroupsFromClaims(claims, "groups")
if len(groups) != 1 {
t.Fatalf("Expected 1 group, got %d", len(groups))
}
if groups[0] != "admin" {
t.Errorf("Expected group admin, got %s", groups[0])
}
}
func TestExtractGroupsFromClaims_MissingClaim(t *testing.T) {
claims := map[string]interface{}{
"email": "test@example.com",
}
groups := auth.ExtractGroupsFromClaims(claims, "groups")
if len(groups) != 0 {
t.Fatalf("Expected 0 groups, got %d", len(groups))
}
}
func TestExtractGroupsFromClaims_CustomClaimName(t *testing.T) {
claims := map[string]interface{}{
"roles": []string{"admin", "editor"},
}
groups := auth.ExtractGroupsFromClaims(claims, "roles")
if len(groups) != 2 {
t.Fatalf("Expected 2 groups, got %d", len(groups))
}
if groups[0] != "admin" || groups[1] != "editor" {
t.Errorf("Expected groups [admin, editor], got %v", groups)
}
}
+8 -10
View File
@@ -67,17 +67,15 @@ func GenerateOtpForUser(user *users.User, userStore *users.Storage) (string, err
secretText := key.Secret()
nonce := ""
secretToStore := secretText
if settings.Config.Auth.TotpSecret != "" {
// If an encryption key is provided, encrypt the secret.
secretToStore, nonce, err = encryptSecret(secretText, encryptionKey)
secretText, nonce, err = encryptSecret(secretText, encryptionKey)
if err != nil {
return "", fmt.Errorf("failed to encrypt TOTP secret: %w", err)
}
}
// set cache so verify can attempt to use it but not require it for user yet.
TotpCache.Set(user.Username, secretToStore+"||"+nonce)
// Use the original base32 secret in the OTP URL, not the encrypted version
TotpCache.Set(user.Username, secretText+"||"+nonce)
url := fmt.Sprintf("otpauth://totp/%v?secret=%v", "FileBrowser Quantum: "+user.Username, secretText)
return url, nil
}
@@ -146,14 +144,14 @@ func decryptSecret(b64Ciphertext, b64Nonce string) (string, error) {
}
func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) error {
// get data from cache
cachedSecret, found := TotpCache.Get(user.Username)
if !found && user.TOTPSecret == "" {
return fmt.Errorf("OTP token not found in cache, please generate a new one")
}
useCache := found && user.TOTPSecret == ""
totpSecret := user.TOTPSecret // The encrypted or plaintext secret
totpNonce := user.TOTPNonce // The nonce if encrypted, or empty if plaintext
if useCache {
if found {
splitSecret := strings.Split(cachedSecret, "||")
if len(splitSecret) < 2 {
return fmt.Errorf("invalid cached OTP token format")
@@ -173,7 +171,9 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
}
secretToValidate = decryptedSecret
}
// --- END: ADD THIS DECRYPTION LOGIC ---
// Validate the token using the (now plaintext) secret.
valid, err := totp.ValidateCustom(code, secretToValidate, time.Now().UTC(), totp.ValidateOpts{
Period: TOTPPeriod,
Skew: TOTPSkew,
@@ -181,13 +181,12 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
Algorithm: TOTPAlgorithm,
})
if err != nil {
logger.Errorf("error during TOTP validation for user %s: %v", user.Username, err)
logger.Errorf("error during TOTP validation: %v", err)
}
if !valid {
logger.Warningf("Invalid TOTP code for user %s", user.Username)
return fmt.Errorf("invalid OTP token")
}
if totpSecret != "" || !user.OtpEnabled {
if totpSecret != "" {
user.TOTPSecret = totpSecret // The encrypted or plaintext secret
user.TOTPNonce = totpNonce // The nonce if encrypted, or empty if plaintext
user.OtpEnabled = true // Enable OTP for the user
@@ -196,7 +195,6 @@ func VerifyTotpCode(user *users.User, code string, userStore *users.Storage) err
logger.Error("error updating user with OTP token:", err)
return fmt.Errorf("error updating user with OTP token: %w", err)
}
TotpCache.Delete(user.Username)
} else {
return fmt.Errorf("opt secret is empty, cannot enable TOTP")
}
+108
View File
@@ -0,0 +1,108 @@
== Running benchmark ==
/usr/local/go/bin/go
? github.com/gtsteffaniak/filebrowser/backend [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/diskcache 0.005s
2025/11/07 17:48:07 [INFO] initializing index: [test]
2025/11/07 17:48:07 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /tmp/filebrowser_test3280924123/: no such file or directory
2025/11/07 17:48:07 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:07 [INFO] initializing index: [test]
2025/11/07 17:48:07 [INFO] initializing index: [test]
2025/11/07 17:48:07 [ERROR] Root scanner error: open /tmp/filebrowser_test3280924123/: no such file or directory
2025/11/07 17:48:07 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /tmp/filebrowser_test3280924123/: no such file or directory
2025/11/07 17:48:07 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:07 [DEBUG] Skipping source update event for mock index.
2025/11/07 17:48:07 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
2025/11/07 17:48:07 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:07 [ERROR] Root scanner error: open /mock/path/: no such file or directory
2025/11/07 17:48:07 [ERROR] Root scanner error: open /mock/path/: no such file or directory
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
2025/11/07 17:48:07 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:07 [DEBUG] Skipping source update event for mock index.
2025/11/07 17:48:07 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:07 [DEBUG] Skipping source update event for mock index.
2025/11/07 17:48:07 [ERROR] Failed to open root directory: open /mock/path/: no such file or directory
2025/11/07 17:48:07 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
PASS
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files 0.006s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils 0.002s
? github.com/gtsteffaniak/filebrowser/backend/auth [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/cmd 0.014s
? github.com/gtsteffaniak/filebrowser/backend/common/errors [no test files]
goos: linux
goarch: amd64
pkg: github.com/gtsteffaniak/filebrowser/backend/common/settings
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
10 374600 ns/op 192030 B/op 3237 allocs/op
PASS
ok github.com/gtsteffaniak/filebrowser/backend/common/settings 0.139s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/common/utils 0.004s
? github.com/gtsteffaniak/filebrowser/backend/common/version [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/database/access 0.287s
? github.com/gtsteffaniak/filebrowser/backend/database/crud [no test files]
? github.com/gtsteffaniak/filebrowser/backend/database/share [no test files]
? github.com/gtsteffaniak/filebrowser/backend/database/storage [no test files]
PASS
ok github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt 0.842s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/database/users 0.003s
? github.com/gtsteffaniak/filebrowser/backend/docs [no test files]
? github.com/gtsteffaniak/filebrowser/backend/events [no test files]
goos: linux
goarch: amd64
pkg: github.com/gtsteffaniak/filebrowser/backend/ffmpeg
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
BenchmarkGetOrientationFilter-8 10 63.00 ns/op 0 B/op 0 allocs/op
PASS
ok github.com/gtsteffaniak/filebrowser/backend/ffmpeg 0.006s
2025/11/07 17:48:08 [DEBUG] Saving user [admin] changepass: true
2025/11/07 17:48:08 [DEBUG] Saving user [non-admin] changepass: true
PASS
ok github.com/gtsteffaniak/filebrowser/backend/http 0.248s
2025/11/07 17:48:08 [INFO] initializing index: [test]
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
2025/11/07 17:48:08 [INFO] initializing index: [test]
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
goos: linux
goarch: amd64
pkg: github.com/gtsteffaniak/filebrowser/backend/indexing
cpu: 11th Gen Intel(R) Core(TM) i5-11320H @ 3.20GHz
BenchmarkFillIndex-8 2025/11/07 17:48:08 [INFO] initializing index: [test]
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
10 3611835 ns/op 323676 B/op 1655 allocs/op
2025/11/07 17:48:08 [INFO] initializing index: [test]
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
BenchmarkSearchAllIndexes-8 2025/11/07 17:48:08 [INFO] initializing index: [test]
2025/11/07 17:48:08 [INFO] Setting up multi-scanner system for [test]
2025/11/07 17:48:08 [DEBUG] Created 1 scanners for [test] (1 root + 0 children)
2025/11/07 17:48:08 [DEBUG] Time spent indexing [test]: 0s seconds
2025/11/07 17:48:08 [DEBUG] Skipping source update event for mock index.
10 784497 ns/op 97700 B/op 1061 allocs/op
PASS
ok github.com/gtsteffaniak/filebrowser/backend/indexing 0.077s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo 0.006s
PASS
ok github.com/gtsteffaniak/filebrowser/backend/preview 0.130s
? github.com/gtsteffaniak/filebrowser/backend/swagger/docs [no test files]
+4
View File
@@ -17,6 +17,9 @@ func validateAccessRules() {
}
// Get all sources
for sourcePath := range settings.Config.Server.SourceMap {
// Clear cache before migration to ensure we get fresh data
store.Access.ClearCacheForSource(sourcePath)
// Get all rules for this source
rules, err := store.Access.GetAllRules(sourcePath)
if err != nil {
@@ -62,6 +65,7 @@ func validateAccessRules() {
// After migration, clear cache
if migratedCount > 0 {
store.Access.ClearCacheForSource(sourcePath)
logger.Infof("Migrated %d access rules for source %s", migratedCount, sourcePath)
}
}
+77 -194
View File
@@ -23,7 +23,7 @@ var (
)
// return bool to indicate if the program should continue running
func runCLI() (bool, bool) {
func runCLI() bool {
generateYaml()
@@ -53,219 +53,102 @@ func runCLI() (bool, bool) {
// Show help if requested
if help {
generalUsage()
return false, false
return false
}
// Create a new FlagSet for the 'set' subcommand
setCmd := flag.NewFlagSet("set", flag.ExitOnError)
var user, dbConfig string
var asAdmin bool
var sourceName, indexPath, ruleCategory, value string
var allow bool
setCmd.StringVar(&user, "u", "", "Comma-separated username and password: \"set -u <username>,<password>\"")
setCmd.BoolVar(&asAdmin, "a", false, "Create a user as admin user, used in combination with -u")
setCmd.StringVar(&dbConfig, "c", "config.yaml", "Path to the config file, default: config.yaml")
setCmd.StringVar(&sourceName, "s", "", "source name to apply the rule to")
setCmd.StringVar(&indexPath, "p", "", "Index path (e.g. /secret) for rule command")
setCmd.StringVar(&ruleCategory, "r", "", "Rule category: 'user', 'group', or 'all' (for deny only)")
setCmd.StringVar(&value, "v", "", "Value: username or groupname (not required if ruleCategory is 'all')")
setCmd.BoolVar(&allow, "allow", false, "Allow access (default: false, which means deny)")
storeCfg := configPath
cliSubcmd := ""
var setKind string
// Parse subcommand flags only if a subcommand is specified
if len(os.Args) > 1 {
switch os.Args[1] {
case "setup":
createConfig(configPath)
return false
case "set":
err := setCmd.Parse(os.Args[2:])
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
userInfo := strings.Split(user, ",")
if len(userInfo) < 2 {
fmt.Printf("not enough info to create user: \"set -u username,password\", only provided %v\n", userInfo)
setCmd.PrintDefaults()
os.Exit(1)
}
username := userInfo[0]
password := userInfo[1]
_ = getStore(dbConfig) // ignore bool check
user, err := store.Users.Get(username)
if err != nil {
newUser := users.User{
Username: username,
LoginMethod: users.LoginMethodPassword,
NonAdminEditable: users.NonAdminEditable{
Password: password,
},
}
for _, source := range settings.Config.Server.SourceMap {
if source.Config.DefaultEnabled {
newUser.Scopes = append(newUser.Scopes, users.SourceScope{
Name: source.Name,
Scope: source.Config.DefaultUserScope,
})
}
}
// Create the user logic
if asAdmin {
logger.Infof("Creating user as admin: %s\n", username)
} else {
logger.Infof("Creating non-admin user: %s\n", username)
}
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Permissions)
newUser.Permissions.Admin = asAdmin
err = storage.CreateUser(newUser, newUser.Permissions)
if err != nil {
logger.Errorf("could not create user: %v", err)
}
return false
}
if user.LoginMethod != users.LoginMethodPassword {
logger.Fatalf("user %s is not allowed to login with password authentication, cannot set password", username)
}
user.Password = password
user.TOTPSecret = "" // reset TOTP secret if it exists
user.TOTPNonce = "" // reset TOTP nonce if it exists
user.LoginMethod = users.LoginMethodPassword
if asAdmin {
user.Permissions.Admin = true
}
// Ensure version is set for existing users being updated
if user.Version == 0 {
user.Version = 1
}
err = store.Users.Save(user, true, false)
if err != nil {
logger.Errorf("could not update user: %v", err)
}
fmt.Printf("successfully updated user: %s\n", username)
return false
case "version":
fmt.Printf(`FileBrowser Quantum - A modern web-based file manager
Version : %v
Commit : %v
Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
`, version.Version, version.CommitSHA, version.Version)
return false, false
case "setup":
cliSubcmd = "setup"
case "set":
cliSubcmd = "set"
if len(os.Args) < 3 {
fmt.Printf("error: missing subcommand for 'set'. Use 'set user' or 'set rule'\n")
os.Exit(1)
}
setKind = os.Args[2]
var err error
// Handle old syntax: "set -u user,pass -c config.yaml"
if setKind == "-u" {
err = setCmd.Parse(os.Args[2:])
} else {
// New pattern: subcommand-based (e.g., "set user" or "set rule")
err = setCmd.Parse(os.Args[3:])
}
if err != nil {
setCmd.PrintDefaults()
os.Exit(1)
}
storeCfg = dbConfig
`, version.Version, version.CommitSHA, version.Version)
return false
}
}
dbExists := getStore(storeCfg)
switch cliSubcmd {
case "setup":
createConfig(configPath)
return false, dbExists
case "set":
if setKind == "-u" {
logger.Debugf("setUser called with args: %v, config: %v, admin: %v", os.Args, dbConfig, asAdmin)
err := setUser(dbConfig, asAdmin)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false, dbExists
}
switch setKind {
case "rule":
sourceInfo, ok := settings.Config.Server.NameToSource[sourceName] // backend source is path
if !ok {
fmt.Printf("error: invalid source name: %s\n", sourceName)
os.Exit(1)
}
err := setRule(dbConfig, sourceInfo.Path, indexPath, ruleCategory, value, allow)
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
return false, dbExists
default:
fmt.Printf("error: unknown subcommand '%s' for 'set'. Use 'set user' or 'set rule'\n", setKind)
os.Exit(1)
}
return false, dbExists
default:
return true, dbExists
}
}
func setRule(dbConfig, fsPath, indexPath, ruleCategory, value string, allow bool) error {
// Validate required parameters
if fsPath == "" {
return fmt.Errorf("real filesystem path is required: use -f <fsPath>")
}
if indexPath == "" {
return fmt.Errorf("index path is required: use -p <indexPath>")
}
if ruleCategory == "" {
return fmt.Errorf("ruleCategory is required: use -r <user|group|all>")
}
if ruleCategory != "all" && value == "" {
return fmt.Errorf("value is required when ruleCategory is 'user' or 'group': use -v <username|groupname>")
}
// Apply the rule based on allow flag and ruleCategory
var err error
if allow {
switch ruleCategory {
case "user":
err = store.Access.AllowUser(fsPath, indexPath, value)
case "group":
err = store.Access.AllowGroup(fsPath, indexPath, value)
default:
return fmt.Errorf("invalid ruleCategory for allow: must be 'user' or 'group'")
}
} else {
switch ruleCategory {
case "user":
err = store.Access.DenyUser(fsPath, indexPath, value)
case "group":
err = store.Access.DenyGroup(fsPath, indexPath, value)
case "all":
err = store.Access.DenyAll(fsPath, indexPath)
default:
return fmt.Errorf("invalid ruleCategory: must be 'user', 'group', or 'all'")
}
}
if err != nil {
return fmt.Errorf("failed to add or update rule: %w", err)
}
action := "deny"
if allow {
action = "allow"
}
if ruleCategory == "all" {
fmt.Printf("successfully added %s rule for all users on index path '%s' in filesystem path '%s'\n", action, indexPath, fsPath)
} else {
fmt.Printf("successfully added %s rule for %s '%s' on index path '%s' in filesystem path '%s'\n", action, ruleCategory, value, indexPath, fsPath)
}
return nil
}
func setUser(dbConfig string, asAdmin bool) error {
if len(os.Args) < 4 {
return fmt.Errorf("missing username and password for 'set user'. Use 'set -u username,password'")
}
userInfo := strings.Split(os.Args[3], ",")
if len(userInfo) < 2 {
return fmt.Errorf("not enough info to create user: \"set -u username,password\", only provided %v", userInfo)
}
username := userInfo[0]
password := userInfo[1]
user, err := store.Users.Get(username)
if err != nil {
newUser := users.User{
Username: username,
LoginMethod: users.LoginMethodPassword,
NonAdminEditable: users.NonAdminEditable{
Password: password,
},
}
for _, source := range settings.Config.Server.SourceMap {
if source.Config.DefaultEnabled {
newUser.Scopes = append(newUser.Scopes, users.SourceScope{
Name: source.Name,
Scope: source.Config.DefaultUserScope,
})
}
}
// Create the user logic
if asAdmin {
logger.Infof("Creating user as admin: %s\n", username)
} else {
logger.Infof("Creating non-admin user: %s\n", username)
}
newUser.Permissions = settings.ConvertPermissionsToUsers(settings.Config.UserDefaults.Permissions)
newUser.Permissions.Admin = asAdmin
err = storage.CreateUser(newUser, newUser.Permissions)
if err != nil {
return fmt.Errorf("could not create user: %v", err)
}
fmt.Printf("successfully created user")
return nil
}
if user.LoginMethod != users.LoginMethodPassword {
return fmt.Errorf("user %s is not allowed to login with password authentication, cannot set password", username)
}
user.Password = password
user.TOTPSecret = "" // reset TOTP secret if it exists
user.TOTPNonce = "" // reset TOTP nonce if it exists
user.LoginMethod = users.LoginMethodPassword
if asAdmin {
user.Permissions.Admin = true
}
// Ensure version is set for existing users being updated
if user.Version == 0 {
user.Version = 1
}
err = store.Users.Save(user, true, false)
if err != nil {
return fmt.Errorf("could not update user: %v", err)
}
fmt.Printf("successfully updated user: %s\n", username)
return nil
return true
}
// UserDefaults defines default settings for new users.
+13 -80
View File
@@ -3,11 +3,9 @@ package cmd
import (
"context"
"fmt"
"io/fs"
"os"
"os/signal"
"syscall"
"time"
_ "net/http/pprof"
@@ -19,7 +17,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/database/storage/bolt"
"github.com/gtsteffaniak/filebrowser/backend/ffmpeg"
fbhttp "github.com/gtsteffaniak/filebrowser/backend/http"
"github.com/gtsteffaniak/filebrowser/backend/icons"
"github.com/gtsteffaniak/filebrowser/backend/indexing"
"github.com/gtsteffaniak/filebrowser/backend/preview"
"github.com/gtsteffaniak/filebrowser/backend/swagger/docs"
@@ -54,14 +51,10 @@ commands:
}
func StartFilebrowser() {
keepGoing, dbExists := runCLI()
keepGoing := runCLI()
if !keepGoing {
return
}
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
}
if !settings.Config.Server.DisableUpdateCheck {
info, _ := utils.CheckForUpdates()
if info.LatestVersion != "" {
@@ -80,6 +73,11 @@ func StartFilebrowser() {
done := make(chan struct{}) // Signals server has stopped
shutdownComplete := make(chan struct{}) // Signals shutdown process is complete
dbExists := getStore(configPath)
database := fmt.Sprintf("Using existing database : %v", settings.Config.Server.Database)
if !dbExists {
database = fmt.Sprintf("Creating new database : %v", settings.Config.Server.Database)
}
// Dev mode enables development features like template hot-reloading
_, err := os.Stat("http/dist")
@@ -88,11 +86,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))
@@ -103,15 +96,7 @@ func StartFilebrowser() {
logger.Info(database)
logger.Infof("Sources : %v", sourceList)
logger.Debugf("Using Embedded FS : %v", settings.Env.EmbeddedFs)
walModeStr := "OFF"
if settings.Config.Server.IndexSqlConfig.WalMode {
walModeStr = "WAL"
}
logger.Infof("SQL Journal Mode : %v", walModeStr)
if settings.Config.Server.CacheDirCleanup {
logger.Debugf("clearing cache dir: %s", settings.Config.Server.CacheDir)
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
}
serverConfig := settings.Config.Server
swagInfo := docs.SwaggerInfo
swagInfo.BasePath = serverConfig.BaseURL
@@ -120,25 +105,8 @@ func StartFilebrowser() {
if len(settings.Config.Server.SourceMap) == 0 {
logger.Fatal("No sources configured, exiting...")
}
// Initialize shared index database before starting HTTP service
isNewDb, err := indexing.InitializeIndexDB()
if err != nil {
logger.Fatalf("Failed to initialize index database: %v", err)
}
// Set indexing storage for persistence
if store != nil && store.Indexing != nil {
indexing.SetIndexingStorage(store.Indexing)
if isNewDb {
if err := store.Indexing.ResetAllComplexities(); err != nil {
logger.Errorf("Failed to reset index complexities: %v", err)
}
}
}
for _, source := range settings.Config.Server.SourceMap {
go indexing.Initialize(source, false, isNewDb)
go indexing.Initialize(source, false)
}
validateUserInfo(!dbExists)
validateOfficeIntegration()
@@ -159,23 +127,12 @@ func StartFilebrowser() {
case <-done:
logger.Info("Server stopped unexpectedly. Shutting down...")
}
// Stop all indexing scanners before closing the database
indexing.StopAllScanners()
// Give scanners a moment to finish their current scan operations
time.Sleep(100 * time.Millisecond)
// cleanup temp databases
indexDB := indexing.GetIndexDB()
if indexDB != nil {
indexDB.Close()
}
if settings.Config.Server.CacheDirCleanup {
logger.Debugf("clearing cache dir: %s", settings.Config.Server.CacheDir)
if !*settings.Config.Server.CacheDirCleanup {
fileutils.ClearCacheDir(settings.Config.Server.CacheDir)
}
<-shutdownComplete
<-shutdownComplete // Ensure we don't exit prematurely
// Wait for the server to stop
logger.Info("Shutdown complete.")
}
@@ -187,35 +144,11 @@ func rootCMD(ctx context.Context, store *bolt.BoltStore, serverConfig *settings.
numWorkers := settings.Config.Server.NumImageProcessors
ffmpeg.SetFFmpegPaths()
// Initialize asset filesystem before starting services
if settings.Env.EmbeddedFs {
embeddedAssets := fbhttp.GetEmbeddedAssets()
subAssets, err := fs.Sub(embeddedAssets, "embed")
if err != nil {
logger.Fatalf("Failed to create sub filesystem: %v", err)
}
fileutils.InitAssetFS(subAssets, true)
} else {
fileutils.InitAssetFS(nil, false)
}
// Start preview service
// setup disk cache
err := preview.StartPreviewGenerator(numWorkers, cacheDir)
if err != nil {
logger.Fatalf("Error starting preview service: %v", err)
}
logger.Debugf("MuPDF Enabled : %v", settings.Env.MuPdfAvailable)
logger.Debugf("Media Enabled : %v", settings.MediaEnabled())
logger.Debugf("Exiftool Enabled : %v", settings.Config.Integrations.Media.ExiftoolPath != "")
// Generate PWA icons after preview service is initialized
if err := icons.GeneratePWAIcons(); err != nil {
logger.Warningf("Failed to generate PWA icons: %v", err)
}
// Initialize PWA manifest after icons are generated
icons.InitializePWAManifest()
fbhttp.StartHttp(ctx, store, shutdownComplete)
return nil
}
+6 -87
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"os"
"reflect"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
@@ -38,15 +37,10 @@ func validateUserInfo(newDB bool) {
if updateShowFirstLogin(user) {
updateUser = true
}
if updateSidebarLinks(user) {
updateUser = true
}
if updateTokens(user) {
updateUser = true
}
adminUser := settings.Config.Auth.AdminUsername
adminPass := settings.Config.Auth.AdminPassword
if user.Username == adminUser && adminPass != "" && user.LoginMethod == users.LoginMethodPassword {
passwordEnabled := settings.Config.Auth.Methods.PasswordAuth.Enabled
if user.Username == adminUser && adminPass != "" && passwordEnabled {
logger.Info("Resetting admin user to default username and password.")
user.Permissions.Admin = true
user.Password = settings.Config.Auth.AdminPassword
@@ -62,11 +56,7 @@ func validateUserInfo(newDB bool) {
logger.Fatalf("Unable to create automatic backup of database due to error: %v", err)
}
}
fields := []string{"Scopes", "SidebarLinks", "Tokens", "Permissions", "Preview", "ShowFirstLogin", "LoginMethod", "Version"}
if changePass {
fields = append(fields, "Password")
}
err := store.Users.Update(user, true, fields...)
err := store.Users.Save(user, changePass, true)
if err != nil {
logger.Errorf("could not update user: %v", err)
}
@@ -97,6 +87,7 @@ func updateUserScopes(user *users.User) bool {
} else {
continue
}
newScopes = append(newScopes, users.SourceScope{
Name: src.Path,
Scope: existingScope.Scope,
@@ -125,7 +116,7 @@ func updateShowFirstLogin(user *users.User) bool {
// func to convert legacy user with perm key to permissions
func updatePermissions(user *users.User) bool {
if user.Version >= 1 {
if user.Version == 1 {
return false
}
updateUser := true
@@ -171,7 +162,7 @@ func updatePermissions(user *users.User) bool {
user.Permissions.Delete = true
updateUser = true
}
user.Version = 2
user.Version = 1
if updateUser {
createBackup = true
}
@@ -195,75 +186,3 @@ func updatePreviewSettings(user *users.User) bool {
}
return false
}
// updateSidebarLinks checks if user has stale source links and rebuilds them if needed
func updateSidebarLinks(user *users.User) bool {
// Count source links and check if any are still valid
sourceLinksCount := 0
validSourceLinksCount := 0
for _, link := range user.SidebarLinks {
if strings.HasPrefix(link.Category, "source") {
sourceLinksCount++
// Check if this source still exists
if link.SourceName != "" {
if _, ok := settings.Config.Server.SourceMap[link.SourceName]; ok {
validSourceLinksCount++
}
}
}
}
// If user has no source links, don't update anything
if sourceLinksCount == 0 {
return false
}
// If user has source links but NONE are valid, rebuild from their scopes
if validSourceLinksCount == 0 {
logger.Infof("User %s has %d stale source links, rebuilding from scopes", user.Username, sourceLinksCount)
// Remove all existing source links
newLinks := []users.SidebarLink{}
for _, link := range user.SidebarLinks {
if !strings.HasPrefix(link.Category, "source") {
newLinks = append(newLinks, link)
}
}
// Add new source links based on user's scopes
for _, scope := range user.Scopes {
if source, ok := settings.Config.Server.SourceMap[scope.Name]; ok {
// User has access to this source, add it to sidebar
newLinks = append(newLinks, users.SidebarLink{
Name: source.Name,
Category: "source",
Target: "/",
Icon: "",
SourceName: source.Path,
})
}
}
user.SidebarLinks = newLinks
return true
}
// User has at least one valid source link, no update needed
return false
}
func updateTokens(user *users.User) bool {
if user.Version >= 2 {
return false
}
if user.ApiKeys != nil {
user.Tokens = make(map[string]users.AuthToken)
for name, token := range user.ApiKeys {
token.Token = token.Key
user.Tokens[name] = token
}
}
user.Version = 2
return true
}
+1
View File
@@ -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 -2
View File
@@ -19,10 +19,9 @@ var (
ErrInvalidRequestParams = errors.New("invalid request params")
ErrSourceIsParent = errors.New("source is parent")
ErrNoTotpProvided = errors.New("OTP code is required for user")
ErrNoTotpConfigured = errors.New("OTP is enforced, but user is not configured")
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")
@@ -1,192 +0,0 @@
package settings
import (
"testing"
"github.com/google/go-cmp/cmp"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
// TestApplyUserDefaults_copiesUserDefaultsOntoUser guards against drift between
// [UserDefaults] and [ApplyUserDefaults] (regression for #2278-style bugs).
func TestApplyUserDefaults_copiesUserDefaultsOntoUser(t *testing.T) {
saved := Config
defer func() { Config = saved }()
dark := false
dlFalse := false
Config = Settings{
Server: Server{},
UserDefaults: UserDefaults{
EditorQuickSave: true,
HideSidebarFileActions: true,
DisableQuickToggles: true,
DisableSearchOptions: true,
StickySidebar: false,
HideFilesInTree: true,
DarkMode: &dark,
Locale: "de",
ViewMode: "list",
SingleClick: true,
ShowHidden: true,
DateFormat: true,
GallerySize: 7,
ThemeColor: "var(--red)",
QuickDownload: true,
DisablePreviewExt: ".exe",
DisableViewingExt: ".bat",
DisableSettings: true,
LockPassword: true,
DisableUpdateNotifications: true,
DeleteWithoutConfirming: true,
DeleteAfterArchive: false,
DisableOfficePreviewExt: "legacy",
DisableOnlyOfficeExt: ".x",
CustomTheme: "Night",
ShowSelectMultiple: true,
DebugOffice: true,
PreferEditorForMarkdown: true,
LoginMethod: "password",
FileLoading: users.FileLoading{
MaxConcurrent: 3,
UploadChunkSize: 5,
DownloadChunkSize: 2,
},
Permissions: UserDefaultsPermissions{
Api: true,
Admin: true,
Modify: true,
Share: true,
Realtime: true,
Delete: true,
Create: true,
Download: &dlFalse,
},
Preview: UserDefaultsPreview{
DisableHideSidebar: true,
Image: boolPtr(false),
Video: boolPtr(false),
Audio: boolPtr(false),
MotionVideoPreview: boolPtr(false),
Office: boolPtr(false),
PopUp: boolPtr(false),
AutoplayMedia: boolPtr(false),
DefaultMediaPlayer: true,
Folder: boolPtr(false),
Models: boolPtr(false),
},
},
}
u := &users.User{Username: "alice"}
ApplyUserDefaults(u)
want := users.User{
DisableSettings: true,
LockPassword: true,
LoginMethod: users.LoginMethodPassword,
Permissions: users.Permissions{
Api: true,
Admin: true,
Modify: true,
Share: true,
Realtime: true,
Delete: true,
Create: true,
Download: false,
},
NonAdminEditable: users.NonAdminEditable{
EditorQuickSave: true,
HideSidebarFileActions: true,
DisableQuickToggles: true,
DisableSearchOptions: true,
StickySidebar: false,
HideFilesInTree: true,
DarkMode: false,
Locale: "de",
ViewMode: "list",
SingleClick: true,
ShowHidden: true,
DateFormat: true,
GallerySize: 7,
ThemeColor: "var(--red)",
QuickDownload: true,
DisablePreviewExt: ".exe",
DisableViewingExt: ".bat",
DisableUpdateNotifications: true,
DisableOfficePreviewExt: "legacy",
DisableOnlyOfficeExt: ".x",
CustomTheme: "Night",
ShowSelectMultiple: true,
DebugOffice: true,
DeleteWithoutConfirming: true,
DeleteAfterArchive: false,
PreferEditorForMarkdown: true,
FileLoading: users.FileLoading{
MaxConcurrent: 3,
UploadChunkSize: 5,
DownloadChunkSize: 2,
},
Preview: users.Preview{
DisableHideSidebar: true,
Image: false,
Video: false,
Audio: false,
MotionVideoPreview: false,
Office: false,
PopUp: false,
AutoplayMedia: false,
DefaultMediaPlayer: true,
Folder: false,
Models: false,
},
},
}
if diff := cmp.Diff(want.Permissions, u.Permissions); diff != "" {
t.Fatalf("Permissions mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(want.NonAdminEditable, u.NonAdminEditable); diff != "" {
t.Fatalf("NonAdminEditable mismatch (-want +got):\n%s", diff)
}
if u.DisableSettings != want.DisableSettings {
t.Fatalf("DisableSettings: got %v want %v", u.DisableSettings, want.DisableSettings)
}
if u.LockPassword != want.LockPassword {
t.Fatalf("LockPassword: got %v want %v", u.LockPassword, want.LockPassword)
}
if u.LoginMethod != want.LoginMethod {
t.Fatalf("LoginMethod: got %q want %q", u.LoginMethod, want.LoginMethod)
}
}
func TestApplyUserDefaults_setsLoginMethodWhenEmpty(t *testing.T) {
saved := Config
defer func() { Config = saved }()
Config = Settings{
Server: Server{},
UserDefaults: UserDefaults{LoginMethod: "jwt"},
}
u := &users.User{Username: "x"}
ApplyUserDefaults(u)
if u.LoginMethod != users.LoginMethodJwt {
t.Fatalf("LoginMethod: got %q want jwt", u.LoginMethod)
}
}
func TestApplyUserDefaults_preservesLoginMethodWhenAlreadySet(t *testing.T) {
saved := Config
defer func() { Config = saved }()
Config = Settings{
Server: Server{},
UserDefaults: UserDefaults{LoginMethod: "password"},
}
u := &users.User{Username: "x", LoginMethod: users.LoginMethodProxy}
ApplyUserDefaults(u)
if u.LoginMethod != users.LoginMethodProxy {
t.Fatalf("LoginMethod: got %q want proxy", u.LoginMethod)
}
}
+17 -180
View File
@@ -6,11 +6,8 @@ import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
ldap "github.com/go-ldap/ldap/v3"
"github.com/gtsteffaniak/filebrowser/backend/common/version"
"github.com/gtsteffaniak/go-logger/logger"
)
@@ -32,18 +29,6 @@ func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
return t.base.RoundTrip(reqCopy)
}
// AuthCommon contains fields shared across multiple authentication methods
type AuthCommon struct {
Enabled bool `json:"enabled"` // whether to enable this authentication method.
AdminGroup string `json:"adminGroup"` // if set, users in this group will be granted admin privileges
UserGroups []string `json:"userGroups"` // if set, only users in these groups are allowed to log in. Blocks all other users even with valid credentials.
GroupsClaim string `json:"groupsClaim"` // the JSON field name to read groups from. Default is "groups"
UserIdentifier string `json:"userIdentifier"` // the field value to use as the username. Default is "preferred_username" in oidc, "sub" in jwt. Other common values are "email" or "username", or "phone"
DisableVerifyTLS bool `json:"disableVerifyTLS"` // disable TLS verification (insecure, for testing only)
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
CreateUser bool `json:"createUser"` // deprecated: always true for supported authentication methods
}
type Auth struct {
TokenExpirationHours int `json:"tokenExpirationHours"` // time in hours each web UI session token is valid for. Default is 2 hours.
Methods LoginMethods `json:"methods"`
@@ -59,8 +44,6 @@ type LoginMethods struct {
NoAuth bool `json:"noauth"` // if set to true, overrides all other auth methods and disables authentication
PasswordAuth PasswordAuthConfig `json:"password" validate:"omitempty"`
OidcAuth OidcConfig `json:"oidc" validate:"omitempty"`
LdapAuth LdapConfig `json:"ldap" validate:"omitempty"`
JwtAuth JwtAuthConfig `json:"jwt" validate:"omitempty"`
}
type PasswordAuthConfig struct {
@@ -72,8 +55,10 @@ type PasswordAuthConfig struct {
}
type ProxyAuthConfig struct {
AuthCommon `json:",inline"`
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
Enabled bool `json:"enabled"`
CreateUser bool `json:"createUser"` // create user if not exists
Header string `json:"header"` // required header to use for authentication. Security Warning: FileBrowser blindly accepts the header value as username.
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
}
type Recaptcha struct {
@@ -84,65 +69,19 @@ type Recaptcha struct {
// OpenID OAuth2.0
type OidcConfig struct {
AuthCommon `json:",inline"`
ClientID string `json:"clientId"` // secret: client id of the OIDC application
ClientSecret string `json:"clientSecret"` // secret: client secret of the OIDC application
IssuerUrl string `json:"issuerUrl"` // authorization URL of the OIDC provider
Scopes string `json:"scopes"` // scopes to request from the OIDC provider
Provider *oidc.Provider `json:"-"` // OIDC provider
Verifier *oidc.IDTokenVerifier `json:"-"` // OIDC verifier
}
type LdapConfig struct {
AuthCommon `json:",inline"`
Server string `json:"server"` // scheme://host:port of the LDAP server (e.g. ldap://localhost:389)
BaseDN string `json:"baseDN"` // LDAP search base DN (e.g. dc=ldap,dc=goauthentik,dc=io)
UserDN string `json:"userDN"` // Bind DN for service account (e.g. cn=admin,ou=users,dc=ldap,dc=goauthentik,dc=io)
UserPassword string `json:"userPassword"` // Bind password for service account
UserFilter string `json:"userFilter"` // Search filter for finding user by username. Default (&(cn=%s)(objectClass=user)); override e.g. (email=%s) or (sAMAccountName=%s) for other directories.
Port int `json:"-"` // derived from server
Scheme string `json:"-"` // derived from server
Host string `json:"-"` // derived from server
}
// JwtAuthConfig configures external JWT token authentication
// Similar to Grafana's JWT auth: verifies HS* tokens with a shared secret, or RS*/ES* with a PEM public key (or certificate)
// The query parameter is hardcoded to "jwt" (e.g. ?jwt=<token>)
type JwtAuthConfig struct {
AuthCommon `json:",inline"`
Header string `json:"header"` // HTTP header to look for JWT token (e.g. X-JWT-Assertion). Default is "X-JWT-Assertion"
Secret string `json:"secret"` // Shared secret key/bytes for verifying JWT token signatures (required, eg PUBLIC KEY, RSA PUBLIC KEY, EC PUBLIC KEY, or CERTIFICATE)
Algorithm string `json:"algorithm"` // JWT signing algorithm (HS256, HS384, HS512, RS256, ES256). Default is "HS256"
}
// ValidateLdapAuth checks LDAP config and sets defaults. Call when LDAP is enabled.
func ValidateLdapAuth() error {
ldapCfg := &Config.Auth.Methods.LdapAuth
if ldapCfg.Server == "" {
return fmt.Errorf("LDAP server is required when LDAP is enabled")
}
scheme, host, port, err := parseLdapServer(ldapCfg.Server)
if err != nil {
return fmt.Errorf("LDAP server is invalid: %w", err)
}
ldapCfg.Server = fmt.Sprintf("%s://%s:%d", scheme, host, port)
ldapCfg.Scheme = scheme
ldapCfg.Host = host
ldapCfg.Port = port
if ldapCfg.BaseDN == "" {
return fmt.Errorf("LDAP baseDN is required when LDAP is enabled")
}
if ldapCfg.UserDN == "" {
return fmt.Errorf("LDAP userDN (bind DN) is required when LDAP is enabled")
}
if ldapCfg.UserFilter == "" {
ldapCfg.UserFilter = "(&(cn=%s)(objectClass=user))"
}
if err := verifyLdapConnection(); err != nil {
logger.Fatalf("LDAP connection check failed: %v", err)
}
return nil
Enabled bool `json:"enabled"` // whether to enable OIDC authentication
ClientID string `json:"clientId"` // secret: client id of the OIDC application
ClientSecret string `json:"clientSecret"` // secret: client secret of the OIDC application
IssuerUrl string `json:"issuerUrl"` // authorization URL of the OIDC provider
Scopes string `json:"scopes"` // scopes to request from the OIDC provider
UserIdentifier string `json:"userIdentifier"` // the field value to use as the username. Default is "preferred_username", can also be "email" or "username", or "phone"
DisableVerifyTLS bool `json:"disableVerifyTLS"` // disable TLS verification for the OIDC provider. This is insecure and should only be used for testing.
LogoutRedirectUrl string `json:"logoutRedirectUrl"` // if provider logout url is provided, filebrowser will also redirect to logout url. Custom logout query params are respected.
CreateUser bool `json:"createUser"` // create user if not exists
AdminGroup string `json:"adminGroup"` // if set, users in this group will be granted admin privileges.
GroupsClaim string `json:"groupsClaim"` // the JSON field name to read groups from. Default is "groups"
Provider *oidc.Provider `json:"-"` // OIDC provider
Verifier *oidc.IDTokenVerifier `json:"-"` // OIDC verifier
}
// ValidateOidcAuth processes the OIDC callback and retrieves user identity
@@ -194,105 +133,3 @@ func validateOidcAuth() error {
return nil
}
func parseLdapServer(server string) (scheme, host string, port int, err error) {
parts := strings.Split(server, "://")
if len(parts) != 2 {
return "", "", 0, fmt.Errorf("invalid LDAP server: %s", server)
}
scheme = strings.ToLower(parts[0])
if scheme != "ldap" && scheme != "ldaps" {
return "", "", 0, fmt.Errorf("invalid LDAP scheme: %s", scheme)
}
hostParts := strings.Split(parts[1], ":")
host = strings.TrimSuffix(hostParts[0], "/")
if len(hostParts) > 1 && hostParts[1] != "" {
port, err = strconv.Atoi(strings.TrimSuffix(hostParts[1], "/"))
if err != nil || port == 0 {
return "", "", 0, fmt.Errorf("invalid LDAP port: %s", hostParts[1])
}
} else {
if scheme == "ldaps" {
port = 636
} else {
port = 389
}
}
return scheme, host, port, nil
}
// VerifyLdapConnection tests that the LDAP server is reachable, can bind (if credentials set), and responds to a search.
func verifyLdapConnection() error {
if Config.Auth.Methods.LdapAuth.Server == "" {
return fmt.Errorf("LDAP server is required when LDAP is enabled")
}
if Config.Auth.Methods.LdapAuth.BaseDN == "" {
return fmt.Errorf("LDAP baseDN is required for connection verification")
}
var opts []ldap.DialOpt
scheme := Config.Auth.Methods.LdapAuth.Scheme
host := Config.Auth.Methods.LdapAuth.Host
port := Config.Auth.Methods.LdapAuth.Port
if Config.Auth.Methods.LdapAuth.DisableVerifyTLS && scheme == "ldaps" {
opts = append(opts, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: true}))
}
fullURL := fmt.Sprintf("%s://%s:%d", scheme, host, port)
conn, err := ldap.DialURL(fullURL, opts...)
if err != nil {
return fmt.Errorf("LDAP connection failed (is the LDAP outpost running at %s? use port 389 for LDAP, 636 for LDAPS; this is not the HTTP port): %w", fullURL, err)
}
defer conn.Close()
if Config.Auth.Methods.LdapAuth.UserDN != "" && Config.Auth.Methods.LdapAuth.UserPassword != "" {
if err = conn.Bind(Config.Auth.Methods.LdapAuth.UserDN, Config.Auth.Methods.LdapAuth.UserPassword); err != nil {
return fmt.Errorf("LDAP bind failed (check userDN and userPassword): %w", err)
}
defer func() { _ = conn.Unbind() }()
}
// Dummy search to verify the server responds to LDAP search (same path that fails at login if misconfigured).
searchRequest := ldap.NewSearchRequest(
Config.Auth.Methods.LdapAuth.BaseDN,
ldap.ScopeBaseObject,
ldap.NeverDerefAliases,
0,
0,
false,
"(objectClass=*)",
[]string{"1.1"}, // no attributes
nil,
)
_, err = conn.Search(searchRequest)
if err != nil {
return fmt.Errorf("LDAP search test failed (server may require StartTLS or bind before search): %w", err)
}
return nil
}
// ValidateJwtAuth checks JWT config and sets defaults. Call when JWT auth is enabled.
func ValidateJwtAuth() error {
jwtCfg := &Config.Auth.Methods.JwtAuth
if jwtCfg.Secret == "" {
return fmt.Errorf("JWT secret is required when JWT auth is enabled")
}
if jwtCfg.Header == "" {
jwtCfg.Header = "X-JWT-Assertion"
}
if jwtCfg.Algorithm == "" {
jwtCfg.Algorithm = "HS256"
}
if jwtCfg.GroupsClaim == "" {
jwtCfg.GroupsClaim = "groups"
}
if jwtCfg.UserIdentifier == "" {
jwtCfg.UserIdentifier = "sub"
}
// Validate algorithm
validAlgos := map[string]bool{
"HS256": true, "HS384": true, "HS512": true,
"RS256": true, "RS384": true, "RS512": true,
"ES256": true, "ES384": true, "ES512": true,
}
if !validAlgos[jwtCfg.Algorithm] {
return fmt.Errorf("unsupported JWT algorithm: %s. Supported: HS256, HS384, HS512, RS256, RS384, RS512, ES256, ES384, ES512", jwtCfg.Algorithm)
}
return nil
}
-221
View File
@@ -1,221 +0,0 @@
package settings
import (
"strings"
"testing"
)
func TestParseLdapServer(t *testing.T) {
tests := []struct {
name string
server string
wantScheme string
wantHost string
wantPort int
wantErr bool
}{
{
name: "ldap with host and port",
server: "ldap://localhost:389",
wantScheme: "ldap",
wantHost: "localhost",
wantPort: 389,
wantErr: false,
},
{
name: "ldaps with host and port",
server: "ldaps://ldap.example.com:636",
wantScheme: "ldaps",
wantHost: "ldap.example.com",
wantPort: 636,
wantErr: false,
},
{
name: "ldap with trailing slash",
server: "ldap://host:389/",
wantScheme: "ldap",
wantHost: "host",
wantPort: 389,
wantErr: false,
},
{
name: "ldap port not provided defaults to 389",
server: "ldap://localhost",
wantScheme: "ldap",
wantHost: "localhost",
wantPort: 389,
wantErr: false,
},
{
name: "ldaps port not provided defaults to 636",
server: "ldaps://ldap.example.com",
wantScheme: "ldaps",
wantHost: "ldap.example.com",
wantPort: 636,
wantErr: false,
},
{
name: "port provided but invalid returns error",
server: "ldap://host:bad",
wantErr: true,
},
{
name: "port provided but invalid (ldaps) returns error",
server: "ldaps://host:xyz",
wantErr: true,
},
{
name: "port zero returns error",
server: "ldap://host:0",
wantErr: true,
},
{
name: "empty server",
server: "",
wantErr: true,
},
{
name: "no scheme",
server: "localhost:389",
wantErr: true,
},
{
name: "invalid scheme",
server: "http://localhost:389",
wantErr: true,
},
{
name: "invalid scheme ftp",
server: "ftp://localhost:389",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotScheme, gotHost, gotPort, err := parseLdapServer(tt.server)
if (err != nil) != tt.wantErr {
t.Errorf("parseLdapServer() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
if gotScheme != tt.wantScheme {
t.Errorf("parseLdapServer() scheme = %v, want %v", gotScheme, tt.wantScheme)
}
if gotHost != tt.wantHost {
t.Errorf("parseLdapServer() host = %v, want %v", gotHost, tt.wantHost)
}
if gotPort != tt.wantPort {
t.Errorf("parseLdapServer() port = %v, want %v", gotPort, tt.wantPort)
}
}
})
}
}
func TestVerifyLdapConnection_ValidationOnly(t *testing.T) {
// Save and restore global config so we don't affect other tests
saved := Config.Auth.Methods.LdapAuth
defer func() { Config.Auth.Methods.LdapAuth = saved }()
t.Run("empty server returns error", func(t *testing.T) {
Config.Auth.Methods.LdapAuth = LdapConfig{
AuthCommon: AuthCommon{Enabled: true},
Server: "",
BaseDN: "dc=test,dc=local",
}
err := verifyLdapConnection()
if err == nil {
t.Fatal("verifyLdapConnection() expected error for empty server")
}
if !strings.Contains(err.Error(), "LDAP server is required") {
t.Errorf("verifyLdapConnection() error = %v, want message containing 'LDAP server is required'", err)
}
})
t.Run("empty baseDN returns error", func(t *testing.T) {
Config.Auth.Methods.LdapAuth = LdapConfig{
AuthCommon: AuthCommon{Enabled: true},
Server: "ldap://localhost:389",
Scheme: "ldap",
Host: "localhost",
Port: 389,
BaseDN: "",
}
err := verifyLdapConnection()
if err == nil {
t.Fatal("verifyLdapConnection() expected error for empty baseDN")
}
if !strings.Contains(err.Error(), "baseDN is required") {
t.Errorf("verifyLdapConnection() error = %v, want message containing 'baseDN is required'", err)
}
})
}
func TestValidateLdapAuth_ConfigValidation(t *testing.T) {
saved := Config.Auth.Methods.LdapAuth
defer func() { Config.Auth.Methods.LdapAuth = saved }()
t.Run("empty server returns error", func(t *testing.T) {
Config.Auth.Methods.LdapAuth = LdapConfig{
AuthCommon: AuthCommon{Enabled: true},
Server: "",
BaseDN: "dc=test,dc=local",
UserDN: "cn=admin,dc=test,dc=local",
}
err := ValidateLdapAuth()
if err == nil {
t.Fatal("ValidateLdapAuth() expected error for empty server")
}
if !strings.Contains(err.Error(), "server is required") {
t.Errorf("ValidateLdapAuth() error = %v", err)
}
})
t.Run("invalid server URL returns error", func(t *testing.T) {
Config.Auth.Methods.LdapAuth = LdapConfig{
AuthCommon: AuthCommon{Enabled: true},
Server: "not-a-valid-ldap-url",
BaseDN: "dc=test,dc=local",
UserDN: "cn=admin,dc=test,dc=local",
}
err := ValidateLdapAuth()
if err == nil {
t.Fatal("ValidateLdapAuth() expected error for invalid server URL")
}
if !strings.Contains(err.Error(), "invalid") {
t.Errorf("ValidateLdapAuth() error = %v", err)
}
})
t.Run("empty baseDN returns error", func(t *testing.T) {
Config.Auth.Methods.LdapAuth = LdapConfig{
AuthCommon: AuthCommon{Enabled: true},
Server: "ldap://localhost:389",
BaseDN: "",
UserDN: "cn=admin,dc=test,dc=local",
}
err := ValidateLdapAuth()
if err == nil {
t.Fatal("ValidateLdapAuth() expected error for empty baseDN")
}
if !strings.Contains(err.Error(), "baseDN is required") {
t.Errorf("ValidateLdapAuth() error = %v", err)
}
})
t.Run("empty userDN returns error", func(t *testing.T) {
Config.Auth.Methods.LdapAuth = LdapConfig{
AuthCommon: AuthCommon{Enabled: true},
Server: "ldap://localhost:389",
BaseDN: "dc=test,dc=local",
UserDN: "",
}
err := ValidateLdapAuth()
if err == nil {
t.Fatal("ValidateLdapAuth() expected error for empty userDN")
}
if !strings.Contains(err.Error(), "userDN") {
t.Errorf("ValidateLdapAuth() error = %v", err)
}
})
}
+121 -315
View File
@@ -3,10 +3,8 @@ package settings
import (
"bytes"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
@@ -53,21 +51,15 @@ func Initialize(configFile string) {
setupServer()
setupAuth(false)
setupSources(false)
InitializeUserResolvers() // Initialize user package resolvers after sources are set up
setupUrls()
setupFrontend(false)
setupMedia(false)
setupMedia()
}
func setupServer() {
if Config.Server.ListenAddress == "" {
Config.Server.ListenAddress = "0.0.0.0"
}
// Check environment variable first (overrides config file)
if os.Getenv("FILEBROWSER_SQL_WAL") == "true" {
Config.Server.IndexSqlConfig.WalMode = true
}
// WalMode is false by default (OFF journaling)
}
func setupEnv() {
@@ -93,134 +85,19 @@ func setupFs() {
Config.Server.Filesystem.CreateDirectoryPermission = "755"
dirPermOctal, _ = strconv.ParseUint("755", 8, 32)
}
fileutils.SetFsPermissions(uint32(filePermOctal), uint32(dirPermOctal))
fileutils.SetFsPermissions(os.FileMode(filePermOctal), os.FileMode(dirPermOctal))
// Perform mandatory cache directory speed test
testCacheDirSpeed()
logger.Infof("cache directory setup successfully: %v", Config.Server.CacheDir)
}
// testCacheDirSpeed performs a mandatory speed test on the cache directory by writing,
// reading, and deleting a 10MB test file. Reports write and read performance in MB/s.
func testCacheDirSpeed() {
msgPrfx := "cacheDir"
failSuffix := "Please review documentation to ensure a valid cache directory is configured https://filebrowserquantum.com/en/docs/configuration/server/#cachedir"
const testFileSize = 10 * 1024 * 1024 // 10MB
// Ensure cache directory exists
err := os.MkdirAll(Config.Server.CacheDir, fileutils.PermDir)
// try writing to cache dir
absCacheDir, err := filepath.Abs(Config.Server.CacheDir)
if err != nil {
logger.Fatalf("%s failed to create cache directory: %v\n%s", msgPrfx, err, failSuffix)
logger.Errorf("error getting absolute path for 'server.cacheDir: %v': %v", Config.Server.CacheDir, err)
}
testFileName := filepath.Join(Config.Server.CacheDir, "speed_test.tmp")
// Create test data (10MB of zeros)
testData := make([]byte, testFileSize)
// Test write performance
writeStart := time.Now()
file, err := os.Create(testFileName)
cacheDir := filepath.Join(absCacheDir, "init_test")
err = os.MkdirAll(cacheDir, fileutils.PermDir)
if err != nil {
logger.Fatalf("%s failed to create test file: %v\n%s", msgPrfx, err, failSuffix)
logger.Errorf("Unable to write to 'server.cacheDir: %v', please ensure the cache directory is writable: %v", absCacheDir, err)
}
written, err := file.Write(testData)
if err != nil {
file.Close()
os.Remove(testFileName)
logger.Fatalf("%s failed to write test file: %v\n%s", msgPrfx, err, failSuffix)
}
err = file.Sync() // Ensure data is written to disk
if err != nil {
file.Close()
os.Remove(testFileName)
logger.Fatalf("%s failed to sync test file to disk: %v\n%s", msgPrfx, err, failSuffix)
}
err = file.Close()
if err != nil {
os.Remove(testFileName)
logger.Fatalf("%s failed to close test file: %v\n%s", msgPrfx, err, failSuffix)
}
writeDuration := time.Since(writeStart)
writeSpeedMBs := float64(written) / (1024 * 1024) / writeDuration.Seconds()
// Test read performance
readStart := time.Now()
file, err = os.Open(testFileName)
if err != nil {
os.Remove(testFileName)
logger.Fatalf("%s failed to open test file for reading: %v\n%s", msgPrfx, err, failSuffix)
}
readData := make([]byte, testFileSize)
readBytes, err := io.ReadFull(file, readData)
if err != nil {
file.Close()
os.Remove(testFileName)
logger.Fatalf("%s failed to read test file: %v\n%s", msgPrfx, err, failSuffix)
}
err = file.Close()
if err != nil {
os.Remove(testFileName)
logger.Fatalf("%s failed to close test file after reading: %v\n%s", msgPrfx, err, failSuffix)
}
readDuration := time.Since(readStart)
readSpeedMBs := float64(readBytes) / (1024 * 1024) / readDuration.Seconds()
// Verify data integrity
if readBytes != written {
os.Remove(testFileName)
logger.Fatalf("%s data integrity check failed: wrote %d bytes but read %d bytes\n%s", msgPrfx, written, readBytes, failSuffix)
}
// Clean up test file
err = os.Remove(testFileName)
if err != nil {
logger.Fatalf("%s failed to remove test file: %v\n%s", msgPrfx, err, failSuffix)
}
// Log performance results
writeDurationMs := writeDuration.Seconds() * 1000
readDurationMs := readDuration.Seconds() * 1000
const highLatencyThresholdMs = 1000.0 // 1 second for 10MB file indicates high latency
if writeSpeedMBs < 50 {
slowSuffix := " Ensure you configure a faster cache directory via the `server.cacheDir` configuration option."
logger.Warningf("%s slow write speed detected: %.2f MB/s (%.2f ms)\n%s\n%s", msgPrfx, writeSpeedMBs, writeDurationMs, failSuffix, slowSuffix)
} else if writeDurationMs > highLatencyThresholdMs {
slowSuffix := " Ensure you configure a faster cache directory via the `server.cacheDir` configuration option."
logger.Warningf("%s high write latency detected: %.2f ms for 10MB file (speed: %.2f MB/s). This may indicate network storage or I/O issues.\n%s\n%s", msgPrfx, writeDurationMs, writeSpeedMBs, failSuffix, slowSuffix)
} else {
logger.Debugf("%s write speed: %.2f MB/s (%.2f ms)", msgPrfx, writeSpeedMBs, writeDurationMs)
}
if readSpeedMBs < 50 {
slowSuffix := " Ensure you configure a faster cache directory via the `server.cacheDir` configuration option."
logger.Warningf("%s slow read speed detected: %.2f MB/s (%.2f ms)\n%s\n%s", msgPrfx, readSpeedMBs, readDurationMs, failSuffix, slowSuffix)
} else if readDurationMs > highLatencyThresholdMs {
slowSuffix := " Ensure you configure a faster cache directory via the `server.cacheDir` configuration option."
logger.Warningf("%s high read latency detected: %.2f ms for 10MB file (speed: %.2f MB/s). This may indicate network storage or I/O issues.\n%s\n%s", msgPrfx, readDurationMs, readSpeedMBs, failSuffix, slowSuffix)
} else {
logger.Debugf("%s read speed : %.2f MB/s (%.2f ms)", msgPrfx, readSpeedMBs, readDurationMs)
}
// check cache directory disk free space
freeSpace, err := fileutils.GetFreeSpace(Config.Server.CacheDir)
if err != nil {
logger.Fatalf("%s failed to get free space for cache directory: %v\n%s", msgPrfx, err, failSuffix)
}
freeSpaceGB := float64(freeSpace) / (1024 * 1024 * 1024)
logger.Debugf("%s cache directory has %.2f GB of free space", msgPrfx, freeSpaceGB)
const minRecommendedGB = 20.0
if freeSpaceGB < minRecommendedGB {
logger.Warningf("%s only has %.2f GB of free space, this is less than the %.0f GB minimum recommended free space.\n%s", msgPrfx, freeSpaceGB, minRecommendedGB, failSuffix)
}
}
func setupFrontend(generate bool) {
@@ -281,53 +158,42 @@ func setupFrontend(generate bool) {
loadCustomFavicon()
}
func setupMedia(generate bool) {
// Save user's explicit config before applying defaults
userImageConfig := make(map[ImagePreviewType]*bool)
for k, v := range Config.Integrations.Media.Convert.ImagePreview {
userImageConfig[k] = v
func setupMedia() {
// If VideoPreview is not initialized, initialize with all types enabled
if Config.Integrations.Media.Convert.VideoPreview == nil {
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
for _, t := range AllVideoPreviewTypes {
Config.Integrations.Media.Convert.VideoPreview[t] = true
}
return
}
// Re-initialize with defaults (gets wiped out during YAML unmarshal)
Config.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]*bool)
Config.Integrations.Media.Convert.ImagePreview[HEICImagePreview] = boolPtr(false) // HEIC defaults to disabled
Config.Integrations.Media.Convert.ImagePreview[JPEGImagePreview] = boolPtr(true) // JPEG fallback defaults to enabled
// Apply user overrides (only for keys explicitly set in YAML)
for k, v := range userImageConfig {
Config.Integrations.Media.Convert.ImagePreview[k] = v
// If VideoPreview map is empty, it means user didn't configure any video preview settings
// In this case, enable all by default
if len(Config.Integrations.Media.Convert.VideoPreview) == 0 {
for _, t := range AllVideoPreviewTypes {
Config.Integrations.Media.Convert.VideoPreview[t] = true
}
return
}
// VideoPreview: Merge user config with defaults
// Save user's explicit config from YAML
userVideoConfig := make(map[VideoPreviewType]*bool)
// User has explicitly configured some video preview settings
// Start with all enabled, then apply user overrides
userConfig := make(map[VideoPreviewType]bool)
for k, v := range Config.Integrations.Media.Convert.VideoPreview {
userVideoConfig[k] = v
userConfig[k] = v
}
// Re-initialize with defaults (all enabled)
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]*bool)
// Reset to defaults (all enabled)
Config.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
for _, t := range AllVideoPreviewTypes {
Config.Integrations.Media.Convert.VideoPreview[t] = boolPtr(true)
Config.Integrations.Media.Convert.VideoPreview[t] = true
}
// Apply user overrides (only for keys explicitly set in YAML)
for k, v := range userVideoConfig {
// Apply user overrides (only for explicitly set values)
for k, v := range userConfig {
Config.Integrations.Media.Convert.VideoPreview[k] = v
}
// Resolve exiftool path once at startup: validate user path or discover via PATH
if Config.Integrations.Media.ExiftoolPath != "" && !generate {
if err := exec.Command(Config.Integrations.Media.ExiftoolPath, "-ver").Run(); err != nil {
logger.Warningf("exiftool path is invalid or not executable: %q (%v); disabling exiftool", Config.Integrations.Media.ExiftoolPath, err)
Config.Integrations.Media.ExiftoolPath = ""
}
}
if Config.Integrations.Media.ExiftoolPath == "" && !generate {
if path, err := exec.LookPath("exiftool"); err == nil && path != "" {
Config.Integrations.Media.ExiftoolPath = path
}
}
}
func setupSources(generate bool) {
@@ -350,7 +216,11 @@ func setupSources(generate bool) {
if name == "\\" {
name = strings.Split(realPath, ":")[0]
}
source.Path = realPath // use absolute path
if generate {
source.Path = generatorPath // use placeholder path
} else {
source.Path = realPath // use absolute path
}
if source.Name == "" {
_, ok := Config.Server.SourceMap[source.Path]
if ok {
@@ -359,10 +229,6 @@ func setupSources(generate bool) {
source.Name = name
}
}
if generate {
source.Path = generatorPath // use placeholder path
source.Name = "Source Name"
}
modifyExcludeInclude(source)
setConditionals(source)
if source.Config.DefaultUserScope == "" {
@@ -434,34 +300,20 @@ func setupAuth(generate bool) {
if Config.Auth.Methods.ProxyAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "proxy")
}
if Config.Auth.Methods.OidcAuth.Enabled {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "oidc")
}
if Config.Auth.Methods.NoAuth {
logger.Warning("Configured with no authentication, this is not recommended.")
Config.Auth.AuthMethods = []string{"disabled"}
}
if Config.Auth.Methods.OidcAuth.Enabled || generate {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "oidc")
err := validateOidcAuth()
if err != nil && !generate {
logger.Fatalf("Error validating OIDC auth: %v", err)
}
logger.Info("OIDC Auth configured successfully")
}
if Config.Auth.Methods.LdapAuth.Enabled || generate {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "ldap")
err := ValidateLdapAuth()
if err != nil && !generate {
logger.Fatalf("Error validating LDAP auth: %v", err)
}
logger.Info("LDAP Auth configured successfully")
}
if Config.Auth.Methods.JwtAuth.Enabled || generate {
Config.Auth.AuthMethods = append(Config.Auth.AuthMethods, "jwt")
err := ValidateJwtAuth()
if err != nil && !generate {
logger.Fatalf("Error validating JWT auth: %v", err)
}
logger.Info("JWT Auth configured successfully")
}
// use password auth as default if no auth methods are set
if len(Config.Auth.AuthMethods) == 0 {
@@ -650,11 +502,6 @@ func loadEnvConfig() {
logger.Info("Using ReCaptcha Secret from FILEBROWSER_RECAPTCHA_SECRET environment variable")
}
ldapUserPassword := os.Getenv("FILEBROWSER_LDAP_USER_PASSWORD")
if ldapUserPassword != "" {
Config.Auth.Methods.LdapAuth.UserPassword = ldapUserPassword
logger.Info("Using LDAP bind password from FILEBROWSER_LDAP_USER_PASSWORD environment variable")
}
}
func setDefaults(generate bool) Settings {
@@ -667,9 +514,14 @@ func setDefaults(generate bool) Settings {
database := os.Getenv("FILEBROWSER_DATABASE")
if database == "" {
database = "database.db"
} else {
// check if database file exists
if _, err := os.Stat(database); os.IsNotExist(err) {
database = "database.db"
}
}
if _, err := os.Stat(database); os.IsNotExist(err) {
logger.Warning("database file could not be found. If this is unexpected, 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,15 +531,9 @@ 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,
},
CacheDirCleanup: boolPtr(true),
Filesystem: Filesystem{
CreateFilePermission: "644",
CreateDirectoryPermission: "755",
@@ -707,20 +553,18 @@ func setDefaults(generate bool) Settings {
Frontend: Frontend{
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,
@@ -729,37 +573,37 @@ func setDefaults(generate bool) Settings {
Download: boolPtr(true), // defaults to true
},
Preview: UserDefaultsPreview{
HighQuality: boolPtr(true),
Image: boolPtr(true),
Video: boolPtr(true),
Audio: boolPtr(true),
MotionVideoPreview: boolPtr(true),
Office: boolPtr(true),
PopUp: boolPtr(true),
AutoplayMedia: boolPtr(true),
Folder: boolPtr(true),
Models: boolPtr(true),
},
FileLoading: users.FileLoading{
MaxConcurrent: 10,
UploadChunkSize: 10, // 10MB
DownloadChunkSize: 0, // 0MB, default to no chunking
MaxConcurrent: 10,
ChunkSize: 10, // 10MB
},
},
}
// Initialize ImagePreview map with defaults
s.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]*bool)
s.Integrations.Media.Convert.ImagePreview[HEICImagePreview] = boolPtr(false) // HEIC defaults to disabled
s.Integrations.Media.Convert.ImagePreview[JPEGImagePreview] = boolPtr(true) // JPEG fallback defaults to enabled
// Initialize VideoPreview map with defaults (all enabled)
s.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]*bool)
// Initialize ImagePreview map with all supported types set to false by default
s.Integrations.Media.Convert.ImagePreview = make(map[ImagePreviewType]bool)
for _, t := range AllImagePreviewTypes {
s.Integrations.Media.Convert.ImagePreview[t] = false
}
// Initialize VideoPreview map with all supported types set to true by default
s.Integrations.Media.Convert.VideoPreview = make(map[VideoPreviewType]bool)
for _, t := range AllVideoPreviewTypes {
s.Integrations.Media.Convert.VideoPreview[t] = boolPtr(true)
s.Integrations.Media.Convert.VideoPreview[t] = true
}
return s
}
// validateCustomImage validates a custom image file path and returns the absolute path or error
func validateCustomImage(configPath, imageName string, allowedFormats []string) (absolutePath string, err error) {
func validateCustomImage(configPath, imageName string, maxSize int64, allowedFormats []string) (absolutePath string, err error) {
// Get absolute path
absolutePath, err = filepath.Abs(configPath)
if err != nil {
@@ -767,11 +611,16 @@ func validateCustomImage(configPath, imageName string, allowedFormats []string)
}
// Check if file exists
_, err = os.Stat(absolutePath)
stat, err := os.Stat(absolutePath)
if err != nil {
return "", fmt.Errorf("could not access file: %w", err)
}
// Check file size
if stat.Size() > maxSize {
return "", fmt.Errorf("file too large (%d bytes), maximum allowed is %d bytes", stat.Size(), maxSize)
}
// Validate file format
ext := strings.ToLower(filepath.Ext(absolutePath))
validFormat := false
@@ -790,10 +639,11 @@ func validateCustomImage(configPath, imageName string, allowedFormats []string)
func loadCustomFavicon() {
const imageName = "favicon"
allowedFormats := []string{".ico", ".png", ".svg", ".jpg", ".jpeg", ".gif", ".webp"}
const maxSize = 1024 * 1024 // 1MB
allowedFormats := []string{".ico", ".png", ".svg"}
// Set default embedded favicon path
Env.FaviconEmbeddedPath = "img/icons/favicon.svg"
Env.FaviconEmbeddedPath = "img/icons/favicon.ico"
// Check if a custom favicon path is configured
if Config.Frontend.Favicon == "" {
@@ -803,7 +653,7 @@ func loadCustomFavicon() {
}
// Validate custom favicon
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, allowedFormats)
validatedPath, err := validateCustomImage(Config.Frontend.Favicon, imageName, maxSize, allowedFormats)
if err != nil {
logger.Warningf("Custom favicon validation failed: %v, using default", err)
Config.Frontend.Favicon = ""
@@ -817,21 +667,20 @@ func loadCustomFavicon() {
Env.FaviconPath = validatedPath
Env.FaviconIsCustom = true
logger.Infof("Using custom favicon: %s", Env.FaviconPath)
// PWA and platform PNGs are generated at server startup from this path (SVG uses a raster
// sidecar next to the .svg when present; see icons.GeneratePWAIcons).
}
// PWAIconsCacheDir is where startup icon generation writes PNGs (under the server cache dir).
func PWAIconsCacheDir() string {
return filepath.Join(Config.Server.CacheDir, "icons")
}
func loadLoginIcon() {
const imageName = "login icon"
const maxSize = 1024 * 1024 // 1MB
allowedFormats := []string{".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico"}
// Set default embedded icon path - just use favicon.svg (light/dark handled by CSS)
Env.LoginIconEmbeddedPath = "img/icons/favicon.svg"
// Set default embedded icon path based on dark mode preference
isDarkMode := Config.UserDefaults.DarkMode != nil && *Config.UserDefaults.DarkMode
if isDarkMode {
Env.LoginIconEmbeddedPath = "img/icons/favicon.svg" // Dark mode: dark background
} else {
Env.LoginIconEmbeddedPath = "img/icons/favicon-light.svg" // Light mode: light background
}
// Check if a custom login icon path is configured
if Config.Frontend.LoginIcon == "" {
@@ -841,7 +690,7 @@ func loadLoginIcon() {
}
// Validate custom login icon
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, allowedFormats)
validatedPath, err := validateCustomImage(Config.Frontend.LoginIcon, imageName, maxSize, allowedFormats)
if err != nil {
logger.Warningf("Custom login icon validation failed: %v, using default", err)
Env.LoginIconPath = Env.LoginIconEmbeddedPath
@@ -858,51 +707,28 @@ func loadLoginIcon() {
// setConditionalsMap builds optimized map structures from conditional rules for O(1) lookups
func setConditionals(config *Source) {
// Merge rules from both old format (Conditionals.ItemRules) and new format (Rules)
rules := append(config.Config.Conditionals.ItemRules, config.Config.Rules...)
// Initialize the maps structure (only exact match maps for Names)
resolved := ResolvedRulesConfig{
FileNames: make(map[string]ConditionalRule),
FolderNames: make(map[string]ConditionalRule),
FilePaths: make(map[string]ConditionalRule),
FolderPaths: make(map[string]ConditionalRule),
FileEndsWith: make([]ConditionalRule, 0),
FolderEndsWith: make([]ConditionalRule, 0),
FileStartsWith: make([]ConditionalRule, 0),
FolderStartsWith: make([]ConditionalRule, 0),
NeverWatchPaths: make(map[string]struct{}),
IncludeRootItems: make(map[string]struct{}),
IgnoreAllHidden: false,
IgnoreAllZeroSizeFolders: false,
IgnoreAllSymlinks: false,
IndexingDisabled: false,
NoRules: len(rules) == 0,
// backwards compatibility
if config.Config.Conditionals.Hidden {
logger.Warning("conditionals.hidden is deprecated, use conditionals.ignoreHidden instead")
config.Config.Conditionals.IgnoreHidden = true
}
rules := config.Config.Conditionals.ItemRules
// Initialize the maps structure (only exact match maps for Names)
resolved := &ResolvedConditionalsConfig{
FileNames: make(map[string]ConditionalIndexConfig),
FolderNames: make(map[string]ConditionalIndexConfig),
FilePaths: make(map[string]ConditionalIndexConfig),
FolderPaths: make(map[string]ConditionalIndexConfig),
FileEndsWith: make([]ConditionalIndexConfig, 0),
FolderEndsWith: make([]ConditionalIndexConfig, 0),
FileStartsWith: make([]ConditionalIndexConfig, 0),
FolderStartsWith: make([]ConditionalIndexConfig, 0),
NeverWatchPaths: make(map[string]struct{}),
IncludeRootItems: make(map[string]struct{}),
}
// Process all rules and infer global flags from root-level rules
for _, rule := range rules {
// Check if this is a root-level rule (folderPath == "/")
// Root-level rules with ignoreHidden/ignoreZeroSizeFolders/viewable set global flags
isRootLevelRule := rule.FolderPath == "/"
// Infer global flags from root-level rules
if isRootLevelRule {
if rule.IgnoreHidden {
resolved.IgnoreAllHidden = true
}
if rule.IgnoreSymlinks {
resolved.IgnoreAllSymlinks = true
}
if rule.IgnoreZeroSizeFolders {
resolved.IgnoreAllZeroSizeFolders = true
}
if rule.Viewable {
resolved.IndexingDisabled = true
}
}
// Build optimized lookup structures
if rule.FileEndsWith != "" {
resolved.FileEndsWith = append(resolved.FileEndsWith, rule)
}
@@ -940,7 +766,7 @@ func setConditionals(config *Source) {
resolved.FolderNames[rule.FolderName] = rule
}
}
config.Config.ResolvedRules = resolved
config.Config.ResolvedConditionals = resolved
}
func modifyExcludeInclude(config *Source) {
@@ -972,40 +798,20 @@ func modifyExcludeInclude(config *Source) {
return strings.Trim(value, "/")
}
// Normalize []ConditionalRule slices for full paths
// Handle both old format (Conditionals.ItemRules) and new format (Rules)
allRules := append(config.Config.Conditionals.ItemRules, config.Config.Rules...)
// Normalize []ConditionalIndexConfig slices for full paths
for i, rule := range config.Config.Conditionals.ItemRules {
for i, rule := range allRules {
// normalize full paths
allRules[i].FilePath = normalizeFullPath(rule.FilePath, true)
// FolderPath gets trailing slash for proper prefix matching
if rule.FolderPath != "" {
normalized := normalizeFullPath(rule.FolderPath, true)
if normalized != "/" && !strings.HasSuffix(normalized, "/") {
normalized = normalized + "/"
}
allRules[i].FolderPath = normalized
}
allRules[i].NeverWatchPath = normalizeFullPath(rule.NeverWatchPath, true)
if rule.IncludeRootItem != "" {
normalized := normalizeFullPath(rule.IncludeRootItem, true)
if normalized != "/" && !strings.HasSuffix(normalized, "/") {
normalized = normalized + "/"
}
allRules[i].IncludeRootItem = normalized
}
config.Config.Conditionals.ItemRules[i].FilePath = normalizeFullPath(rule.FilePath, true)
config.Config.Conditionals.ItemRules[i].FolderPath = normalizeFullPath(rule.FolderPath, true)
config.Config.Conditionals.ItemRules[i].NeverWatchPath = normalizeFullPath(rule.NeverWatchPath, true)
config.Config.Conditionals.ItemRules[i].IncludeRootItem = normalizeFullPath(rule.IncludeRootItem, true)
// normalize names
allRules[i].FileNames = normalizeName(rule.FileNames)
allRules[i].FolderNames = normalizeName(rule.FolderNames)
allRules[i].FileName = normalizeName(rule.FileName)
allRules[i].FolderName = normalizeName(rule.FolderName)
config.Config.Conditionals.ItemRules[i].FileNames = normalizeName(rule.FileNames)
config.Config.Conditionals.ItemRules[i].FolderNames = normalizeName(rule.FolderNames)
config.Config.Conditionals.ItemRules[i].FileName = normalizeName(rule.FileName)
config.Config.Conditionals.ItemRules[i].FolderName = normalizeName(rule.FolderName)
}
// Update the original slices with normalized values
itemRulesLen := len(config.Config.Conditionals.ItemRules)
config.Config.Conditionals.ItemRules = allRules[:itemRulesLen]
config.Config.Rules = allRules[itemRulesLen:]
}
+2 -16
View File
@@ -1,25 +1,11 @@
package settings
func CanConvertImage(ext string) bool {
if !MediaEnabled() {
return false
}
val := Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
if val == nil {
return false // Extension not in the configured list
}
return *val
return MediaEnabled() && Config.Integrations.Media.Convert.ImagePreview[ImagePreviewType(ext)]
}
func CanConvertVideo(ext string) bool {
if !MediaEnabled() {
return false
}
val := Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
if val == nil {
return false // Extension not in the configured list
}
return *val
return MediaEnabled() && Config.Integrations.Media.Convert.VideoPreview[VideoPreviewType(ext)]
}
func MediaEnabled() bool {
+154 -48
View File
@@ -1,60 +1,166 @@
package settings
import (
"fmt"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
"github.com/gtsteffaniak/go-logger/logger"
)
// InitializeUserResolvers sets up the global resolvers and config providers in the users package
// This should be called once during application initialization
func InitializeUserResolvers() {
// Set up the source name resolver
users.SetSourceNameResolver(func(sourceName string) (string, error) {
source, ok := Config.Server.NameToSource[sourceName]
if !ok {
logger.Debug("Could not get scope from source name: ", sourceName)
return "", nil
}
return source.Path, nil
})
func ConvertToBackendScopes(scopes []users.SourceScope) ([]users.SourceScope, error) {
if len(scopes) == 0 {
return Config.UserDefaults.DefaultScopes, nil
}
newScopes := []users.SourceScope{}
for _, scope := range scopes {
// Set up the source config provider
users.SetSourceConfig(&users.SourceConfigProvider{
GetSourceByPath: func(path string) (users.SourceInfo, bool) {
source, ok := Config.Server.SourceMap[path]
if !ok {
return users.SourceInfo{}, false
// first check if its already a path name and keep it
source, ok := Config.Server.SourceMap[scope.Name]
if ok {
if scope.Scope == "" {
scope.Scope = source.Config.DefaultUserScope
}
return users.SourceInfo{
Path: source.Path,
Name: source.Name,
DefaultUserScope: source.Config.DefaultUserScope,
}, true
},
GetSourceByName: func(name string) (users.SourceInfo, bool) {
source, ok := Config.Server.NameToSource[name]
if !ok {
return users.SourceInfo{}, false
if !strings.HasPrefix(scope.Scope, "/") {
scope.Scope = "/" + scope.Scope
}
return users.SourceInfo{
Path: source.Path,
Name: source.Name,
DefaultUserScope: source.Config.DefaultUserScope,
}, true
},
GetAllSources: func() []users.SourceInfo {
sources := make([]users.SourceInfo, 0, len(Config.Server.Sources))
for _, source := range Config.Server.Sources {
sources = append(sources, users.SourceInfo{
Path: source.Path,
Name: source.Name,
DefaultUserScope: source.Config.DefaultUserScope,
})
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
}
return sources
},
GetDefaultScopes: func() []users.SourceScope {
return Config.UserDefaults.DefaultScopes
},
})
newScopes = append(newScopes, users.SourceScope{
Name: source.Path, // backend name is path
Scope: scope.Scope,
})
continue
}
// check if its the name of a source and convert it to a path
source, ok = Config.Server.NameToSource[scope.Name]
if !ok {
// source might no longer be configured
continue
}
if scope.Scope == "" {
scope.Scope = source.Config.DefaultUserScope
}
if !strings.HasPrefix(scope.Scope, "/") {
scope.Scope = "/" + scope.Scope
}
if scope.Scope != "/" && strings.HasSuffix(scope.Scope, "/") {
scope.Scope = strings.TrimSuffix(scope.Scope, "/")
}
newScopes = append(newScopes, users.SourceScope{
Name: source.Path, // backend name is path
Scope: scope.Scope,
})
}
return newScopes, nil
}
func ConvertToFrontendScopes(scopes []users.SourceScope) []users.SourceScope {
newScopes := []users.SourceScope{}
for _, scope := range scopes {
if source, ok := Config.Server.SourceMap[scope.Name]; ok {
// Replace scope.Name with source.Path while keeping the same Scope value
newScopes = append(newScopes, users.SourceScope{
Name: source.Name,
Scope: scope.Scope,
})
}
}
return newScopes
}
func ConvertToFrontendSidebarLinks(links []users.SidebarLink) []users.SidebarLink {
newLinks := []users.SidebarLink{}
for _, link := range links {
// For source links, validate that the source still exists
if link.Category == "source" {
if link.SourceName == "" {
logger.Warningf("source link missing sourceName: %v", link.Name)
continue
}
// Check if source exists
sourceInfo, ok := Config.Server.SourceMap[link.SourceName]
if !ok {
logger.Warningf("source not found: %v (link name: %v)", link.SourceName, link.Name)
continue
}
link.SourceName = sourceInfo.Name
}
// For share links, just pass through (shares are validated separately)
// For all other links, pass through as-is
newLinks = append(newLinks, link)
}
return newLinks
}
func ConvertToBackendSidebarLinks(links []users.SidebarLink) ([]users.SidebarLink, error) {
newLinks := []users.SidebarLink{}
for _, link := range links {
// For source links, validate that the source exists using SourceName
if link.Category == "source" {
if link.SourceName == "" {
return nil, fmt.Errorf("source link missing sourceName (link name: %v)", link.Name)
}
// Validate source exists
sourceInfo, ok := Config.Server.NameToSource[link.SourceName]
if !ok {
return nil, fmt.Errorf("source not found: %v (link name: %v)", link.SourceName, link.Name)
}
link.SourceName = sourceInfo.Path // if name chages keep link alive
}
// Store the link as-is with all fields preserved
newLinks = append(newLinks, link)
}
return newLinks, nil
}
func HasSourceByPath(scopes []users.SourceScope, sourcePath string) bool {
for _, scope := range scopes {
if scope.Name == sourcePath {
return true
}
}
return false
}
func GetScopeFromSourceName(scopes []users.SourceScope, sourceName string) (string, error) {
source, ok := Config.Server.NameToSource[sourceName]
if !ok {
logger.Debug("Could not get scope from source name: ", sourceName)
return "", fmt.Errorf("source with name not found %v", sourceName)
}
for _, scope := range scopes {
if scope.Name == source.Path {
return scope.Scope, nil
}
}
logger.Debugf("scope not found for source %v", sourceName)
return "", fmt.Errorf("scope not found for source %v", sourceName)
}
func GetScopeFromSourcePath(scopes []users.SourceScope, sourcePath string) (string, error) {
for _, scope := range scopes {
if scope.Name == sourcePath {
return scope.Scope, nil
}
}
return "", fmt.Errorf("scope not found for source %v", sourcePath)
}
// assumes backend style scopes
func GetSources(u *users.User) []string {
sources := []string{}
// preserves order of sources
for _, source := range Config.Server.Sources {
_, err := GetScopeFromSourcePath(u.Scopes, source.Path)
if err != nil {
logger.Warningf("could not get scope for source %v: %v", source.Path, err)
continue
}
sources = append(sources, source.Name)
}
return sources
}
+127 -222
View File
@@ -14,7 +14,6 @@ import (
"sort"
"strings"
"golang.org/x/tools/go/packages"
"gopkg.in/yaml.v3"
)
@@ -112,93 +111,74 @@ func CollectCommentsAndSecrets(srcPath string) (CommentsMap, SecretFieldsMap, De
return comments, secrets, deprecated, nil
}
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields.
// parseDirectoryComments parses a single directory for comments, secrets, and deprecated fields
func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, DeprecatedFieldsMap, error) {
absDir, err := filepath.Abs(dir)
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments)
if err != nil {
return nil, nil, nil, err
}
var files []*ast.File
cfg := &packages.Config{
Mode: packages.NeedSyntax | packages.NeedFiles,
Dir: absDir,
ParseFile: func(fset *token.FileSet, filename string, src []byte) (*ast.File, error) {
return parser.ParseFile(fset, filename, src, parser.ParseComments)
},
}
pkgs, err := packages.Load(cfg, ".")
if err == nil && len(pkgs) > 0 {
for _, pkg := range pkgs {
files = append(files, pkg.Syntax...)
}
}
if len(files) == 0 {
// Fallback for directories outside a module (e.g. test temp dirs): parse .go files directly
files, err = parseDirGoFiles(absDir)
if err != nil || len(files) == 0 {
return nil, nil, nil, err
}
}
comments := make(CommentsMap)
secrets := make(SecretFieldsMap)
deprecated := make(DeprecatedFieldsMap)
for _, file := range files {
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
continue
}
for _, spec := range gen.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
for _, pkg := range pkgs {
for _, file := range pkg.Files {
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
continue
}
st, ok := ts.Type.(*ast.StructType)
if !ok {
continue
}
typeName := ts.Name.Name
commentMap := make(map[string]string)
secretMap := make(map[string]bool)
deprecatedMap := make(map[string]bool)
comments[typeName] = commentMap
secrets[typeName] = secretMap
deprecated[typeName] = deprecatedMap
for _, field := range st.Fields.List {
if len(field.Names) == 0 {
for _, spec := range gen.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
name := field.Names[0].Name
var parts []string
var fullComment string
if field.Doc != nil {
docText := strings.TrimSpace(field.Doc.Text())
parts = append(parts, docText)
fullComment += docText + " "
}
if field.Comment != nil {
commentText := strings.TrimSpace(field.Comment.Text())
parts = append(parts, commentText)
fullComment += commentText
st, ok := ts.Type.(*ast.StructType)
if !ok {
continue
}
typeName := ts.Name.Name
commentMap := make(map[string]string)
secretMap := make(map[string]bool)
deprecatedMap := make(map[string]bool)
comments[typeName] = commentMap
secrets[typeName] = secretMap
deprecated[typeName] = deprecatedMap
if len(parts) > 0 {
commentMap[name] = strings.Join(parts, " : ")
}
for _, field := range st.Fields.List {
if len(field.Names) == 0 {
continue
}
name := field.Names[0].Name
var parts []string
var fullComment string
// Check if field should be treated as secret
if strings.Contains(strings.ToLower(fullComment), "secret:") {
secretMap[name] = true
}
if field.Doc != nil {
docText := strings.TrimSpace(field.Doc.Text())
parts = append(parts, docText)
fullComment += docText + " "
}
if field.Comment != nil {
commentText := strings.TrimSpace(field.Comment.Text())
parts = append(parts, commentText)
fullComment += commentText
}
// Check if field should be treated as deprecated
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
deprecatedMap[name] = true
if len(parts) > 0 {
commentMap[name] = strings.Join(parts, " : ")
}
// Check if field should be treated as secret
if strings.Contains(strings.ToLower(fullComment), "secret:") {
secretMap[name] = true
}
// Check if field should be treated as deprecated
if strings.Contains(strings.ToLower(fullComment), "deprecated:") {
deprecatedMap[name] = true
}
}
}
}
@@ -207,29 +187,6 @@ func parseDirectoryComments(dir string) (CommentsMap, SecretFieldsMap, Deprecate
return comments, secrets, deprecated, nil
}
// parseDirGoFiles parses all .go files in dir and returns their ASTs. Used when the directory
// is not inside a Go module (e.g. test temp dirs) so packages.Load would fail.
func parseDirGoFiles(dir string) ([]*ast.File, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
fset := token.NewFileSet()
var files []*ast.File
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
continue
}
path := filepath.Join(dir, e.Name())
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return nil, err
}
files = append(files, f)
}
return files, nil
}
// PathToTypeField represents a mapping from YAML path to Go type and field info
type PathToTypeField struct {
TypeName string
@@ -561,130 +518,6 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
currentField := v.Field(i)
// Check if this is an inline/embedded field
yamlTag := sf.Tag.Get("yaml")
jsonTag := sf.Tag.Get("json")
isInline := strings.Contains(yamlTag, ",inline") || strings.Contains(jsonTag, ",inline") || sf.Anonymous
// If field is inline, merge its content directly into parent
if isInline && sf.Type.Kind() == reflect.Struct {
// Get the embedded struct's fields and add them directly to this level
embeddedType := sf.Type
embeddedValue := currentField
embeddedTypeName := embeddedType.Name()
for j := 0; j < embeddedType.NumField(); j++ {
embeddedField := embeddedType.Field(j)
// Skip unexported fields
if embeddedField.PkgPath != "" {
continue
}
if embeddedYamlTag := embeddedField.Tag.Get("yaml"); embeddedYamlTag == "-" {
continue
}
if embeddedJsonTag := embeddedField.Tag.Get("json"); embeddedJsonTag == "-" {
continue
}
// Skip deprecated fields
if len(deprecated) > 0 && deprecated[embeddedTypeName][embeddedField.Name] {
continue
}
embeddedFieldValue := embeddedValue.Field(j)
// If we have defaults, compare and skip if values match
if defaults.IsValid() && i < defaults.NumField() {
defaultInlineField := defaults.Field(i)
if defaultInlineField.Kind() == reflect.Struct && j < defaultInlineField.NumField() {
defaultEmbeddedField := defaultInlineField.Field(j)
embeddedCurrentValue := embeddedFieldValue.Interface()
embeddedDefaultValue := defaultEmbeddedField.Interface()
if reflect.DeepEqual(embeddedCurrentValue, embeddedDefaultValue) {
continue
}
}
}
// Determine key name for embedded field
embeddedYamlTag := embeddedField.Tag.Get("yaml")
embeddedJsonTag := embeddedField.Tag.Get("json")
var embeddedKeyName string
if embeddedYamlTag != "" && embeddedYamlTag != "-" {
embeddedKeyName = strings.Split(embeddedYamlTag, ",")[0]
} else if embeddedJsonTag != "" && embeddedJsonTag != "-" {
embeddedKeyName = strings.Split(embeddedJsonTag, ",")[0]
} else {
embeddedKeyName = embeddedField.Name
}
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: embeddedKeyName}
// Attach comments for embedded field
var parts []string
var comment string
if cm := comm[embeddedTypeName][embeddedField.Name]; cm != "" {
comment = cm
}
if comment != "" {
parts = append(parts, comment)
}
if len(comm) > 0 {
if vt := embeddedField.Tag.Get("validate"); vt != "" {
parts = append(parts, fmt.Sprintf(" validate:%s", vt))
}
}
if len(parts) > 0 {
keyNode.LineComment = strings.Join(parts, " ")
}
// Build value node for embedded field
var valNode *yaml.Node
var err error
generateConfig := os.Getenv("FILEBROWSER_GENERATE_CONFIG") == "true"
if secrets[embeddedTypeName][embeddedField.Name] && !generateConfig {
fieldValue := embeddedFieldValue.Interface()
if str, ok := fieldValue.(string); ok && str == "" {
valNode = &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: "",
Style: yaml.DoubleQuotedStyle,
}
} else {
valNode = &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: "**hidden**",
Style: yaml.DoubleQuotedStyle,
}
}
} else {
var defaultEmbeddedField reflect.Value
if defaults.IsValid() && i < defaults.NumField() {
defaultInlineField := defaults.Field(i)
if defaultInlineField.Kind() == reflect.Struct && j < defaultInlineField.NumField() {
defaultEmbeddedField = defaultInlineField.Field(j)
}
}
valNode, err = buildNodeWithDefaults(embeddedFieldValue, comm, defaultEmbeddedField, secrets, deprecated)
if err != nil {
return nil, err
}
}
mapNode.Content = append(mapNode.Content, keyNode, valNode)
}
// Skip adding the embedded struct itself as a field
continue
}
// If we have defaults, compare and skip if values match
if defaults.IsValid() && i < defaults.NumField() {
defaultField := defaults.Field(i)
@@ -699,6 +532,8 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
}
// determine key: yaml tag > json tag > field name
yamlTag := sf.Tag.Get("yaml")
jsonTag := sf.Tag.Get("json")
var keyName string
if yamlTag != "" && yamlTag != "-" {
keyName = strings.Split(yamlTag, ",")[0]
@@ -814,6 +649,11 @@ func buildNodeWithDefaults(v reflect.Value, comm CommentsMap, defaults reflect.V
}
mapNode := &yaml.Node{Kind: yaml.MappingNode}
// Special handling for ImagePreview maps - show all supported types
if isImagePreviewMap(v) {
return buildImagePreviewMap(v, comm, secrets, deprecated)
}
// Get and sort map keys alphabetically for consistent output
mapKeys := v.MapKeys()
keyStrings := make([]string, len(mapKeys))
@@ -867,21 +707,36 @@ func GenerateYaml() {
{
Path: ".",
Config: SourceConfig{
Rules: []ConditionalRule{
{}, // Add empty sample rule to show all fields in generated YAML
Conditionals: ConditionalFilter{
ItemRules: []ConditionalIndexConfig{
{}, // Add empty sample rule to show all fields in generated YAML
},
},
},
},
}
setupLogging()
setupFs()
setupAuth(true)
// Save original paths before setupSources modifies them (for YAML generation)
originalPaths := make(map[*Source]string)
for _, source := range Config.Server.Sources {
originalPaths[source] = source.Path
}
setupSources(true)
setupUrls()
setupMedia(true)
setupMedia()
setupFrontend(true)
// Restore original paths so the YAML output has the correct paths, not the placeholder
for _, source := range Config.Server.Sources {
if originalPath, ok := originalPaths[source]; ok {
source.Path = originalPath
}
}
output := "../frontend/public/config.generated.yaml" // "output YAML file"
// Generate YAML with comments enabled, full config, and deprecated fields filtered
@@ -1281,3 +1136,53 @@ func AlignComments(input string) string {
return strings.Join(outputLines, "\n")
}
// isImagePreviewMap checks if the given map is an ImagePreview map
func isImagePreviewMap(v reflect.Value) bool {
if v.Kind() != reflect.Map {
return false
}
// Check if the map key type is ImagePreviewType
keyType := v.Type().Key()
return keyType.Name() == "ImagePreviewType"
}
// buildImagePreviewMap builds a YAML node for ImagePreview maps, showing all supported types
func buildImagePreviewMap(v reflect.Value, comm CommentsMap, secrets SecretFieldsMap, deprecated DeprecatedFieldsMap) (*yaml.Node, error) {
mapNode := &yaml.Node{Kind: yaml.MappingNode}
// Get all supported image preview types from the global slice
for _, imageType := range AllImagePreviewTypes {
typeName := string(imageType)
// Create key node
keyNode := &yaml.Node{
Kind: yaml.ScalarNode,
Value: typeName,
}
// Get the value from the map, defaulting to false if not present
var value bool
if v.Len() > 0 {
// Look for the key in the map
for _, mapKey := range v.MapKeys() {
if mapKey.String() == typeName {
value = v.MapIndex(mapKey).Bool()
break
}
}
}
// Create value node
valNode := &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!bool",
Value: fmt.Sprintf("%t", value),
}
mapNode.Content = append(mapNode.Content, keyNode, valNode)
}
return mapNode, nil
}
+37 -59
View File
@@ -65,71 +65,49 @@ func AdminPerms() users.Permissions {
}
// Apply applies the default options to a user.
// Keep this in sync with [UserDefaults]: every user-facing field there should be copied
// here (except DefaultScopes, which is wired through source config rather than this helper).
func ApplyUserDefaults(u *users.User) {
d := Config.UserDefaults
u.StickySidebar = Config.UserDefaults.StickySidebar
u.DisableSettings = Config.UserDefaults.DisableSettings
u.DisableSettings = d.DisableSettings
u.LockPassword = d.LockPassword
// Handle DarkMode with default - dereference pointer from config
u.DarkMode = boolValueOrDefault(Config.UserDefaults.DarkMode, true)
u.EditorQuickSave = d.EditorQuickSave
u.HideSidebarFileActions = d.HideSidebarFileActions
u.DisableQuickToggles = d.DisableQuickToggles
u.DisableSearchOptions = d.DisableSearchOptions
u.StickySidebar = d.StickySidebar
u.HideFilesInTree = d.HideFilesInTree
u.DarkMode = boolValueOrDefault(d.DarkMode, true)
u.Locale = d.Locale
u.ViewMode = d.ViewMode
u.SingleClick = d.SingleClick
u.ShowHidden = d.ShowHidden
u.DateFormat = d.DateFormat
u.GallerySize = d.GallerySize
u.ThemeColor = d.ThemeColor
u.QuickDownload = d.QuickDownload
u.DisablePreviewExt = d.DisablePreviewExt
u.DisableViewingExt = d.DisableViewingExt
u.DisableUpdateNotifications = d.DisableUpdateNotifications
u.DisableOfficePreviewExt = d.DisableOfficePreviewExt
u.DisableOnlyOfficeExt = d.DisableOnlyOfficeExt
u.CustomTheme = d.CustomTheme
u.ShowSelectMultiple = d.ShowSelectMultiple
u.DebugOffice = d.DebugOffice
u.DeleteWithoutConfirming = d.DeleteWithoutConfirming
u.DeleteAfterArchive = d.DeleteAfterArchive
u.PreferEditorForMarkdown = d.PreferEditorForMarkdown
u.FileLoading = d.FileLoading
u.Locale = Config.UserDefaults.Locale
u.ViewMode = Config.UserDefaults.ViewMode
u.SingleClick = Config.UserDefaults.SingleClick
u.Preview.DisableHideSidebar = d.Preview.DisableHideSidebar
u.Preview.Image = boolValueOrDefault(d.Preview.Image, true)
u.Preview.Video = boolValueOrDefault(d.Preview.Video, true)
u.Preview.Audio = boolValueOrDefault(d.Preview.Audio, true)
u.Preview.MotionVideoPreview = boolValueOrDefault(d.Preview.MotionVideoPreview, true)
u.Preview.Office = boolValueOrDefault(d.Preview.Office, true)
u.Preview.PopUp = boolValueOrDefault(d.Preview.PopUp, true)
u.Preview.AutoplayMedia = boolValueOrDefault(d.Preview.AutoplayMedia, true)
u.Preview.DefaultMediaPlayer = d.Preview.DefaultMediaPlayer
u.Preview.Folder = boolValueOrDefault(d.Preview.Folder, true)
u.Preview.Models = boolValueOrDefault(d.Preview.Models, true)
// Handle Permissions - convert from pointer-based defaults to regular bools
u.Permissions.Api = Config.UserDefaults.Permissions.Api
u.Permissions.Admin = Config.UserDefaults.Permissions.Admin
u.Permissions.Modify = Config.UserDefaults.Permissions.Modify
u.Permissions.Share = Config.UserDefaults.Permissions.Share
u.Permissions.Realtime = Config.UserDefaults.Permissions.Realtime
u.Permissions.Delete = Config.UserDefaults.Permissions.Delete
u.Permissions.Create = Config.UserDefaults.Permissions.Create
u.Permissions.Download = boolValueOrDefault(Config.UserDefaults.Permissions.Download, true)
if u.Username == "anonymous" {
return
}
u.Permissions.Api = d.Permissions.Api
u.Permissions.Admin = d.Permissions.Admin
u.Permissions.Modify = d.Permissions.Modify
u.Permissions.Share = d.Permissions.Share
u.Permissions.Realtime = d.Permissions.Realtime
u.Permissions.Delete = d.Permissions.Delete
u.Permissions.Create = d.Permissions.Create
u.Permissions.Download = boolValueOrDefault(d.Permissions.Download, true)
if u.LoginMethod == "" && d.LoginMethod != "" {
u.LoginMethod = users.LoginMethod(d.LoginMethod)
}
// Handle Preview - convert from pointer-based defaults to regular bools
u.Preview.DisableHideSidebar = Config.UserDefaults.Preview.DisableHideSidebar
u.Preview.HighQuality = boolValueOrDefault(Config.UserDefaults.Preview.HighQuality, true)
u.Preview.Image = boolValueOrDefault(Config.UserDefaults.Preview.Image, true)
u.Preview.Video = boolValueOrDefault(Config.UserDefaults.Preview.Video, true)
u.Preview.MotionVideoPreview = boolValueOrDefault(Config.UserDefaults.Preview.MotionVideoPreview, true)
u.Preview.Office = boolValueOrDefault(Config.UserDefaults.Preview.Office, true)
u.Preview.PopUp = boolValueOrDefault(Config.UserDefaults.Preview.PopUp, true)
u.Preview.AutoplayMedia = boolValueOrDefault(Config.UserDefaults.Preview.AutoplayMedia, true)
u.Preview.DefaultMediaPlayer = Config.UserDefaults.Preview.DefaultMediaPlayer
u.Preview.Folder = boolValueOrDefault(Config.UserDefaults.Preview.Folder, true)
u.ShowHidden = Config.UserDefaults.ShowHidden
u.DateFormat = Config.UserDefaults.DateFormat
u.DisableViewingExt = Config.UserDefaults.DisableViewingExt
u.ThemeColor = Config.UserDefaults.ThemeColor
u.GallerySize = Config.UserDefaults.GallerySize
u.QuickDownload = Config.UserDefaults.QuickDownload
u.LockPassword = Config.UserDefaults.LockPassword
u.DisableOnlyOfficeExt = Config.UserDefaults.DisableOnlyOfficeExt
u.FileLoading = Config.UserDefaults.FileLoading
u.DisableOfficePreviewExt = Config.UserDefaults.DisableOfficePreviewExt
if len(u.Scopes) == 0 {
for _, source := range Config.Server.Sources {
if source.Config.DefaultEnabled {
+47 -87
View File
@@ -39,29 +39,27 @@ type Environment struct {
}
type Server struct {
MinSearchLength int `json:"minSearchLength" yaml:"minSearchLength"` // minimum length of search query to begin searching (default: 3)
DisableUpdateCheck bool `json:"disableUpdateCheck"` // disables backend update check service
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
Socket string `json:"socket"` // socket to listen on
TLSKey string `json:"tlsKey"` // path to TLS key
TLSCert string `json:"tlsCert"` // path to TLS cert
DisablePreviews bool `json:"disablePreviews"` // disable all previews thumbnails, simple icons will be used
DisableResize bool `json:"disablePreviewResize"` // disable resizing of previews for faster loading over slow connections
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
Port int `json:"port"` // port to listen on
ListenAddress string `json:"listen"` // address to listen on (default: 0.0.0.0)
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
Logging []LogConfig `json:"logging" yaml:"logging"`
Database string `json:"database"` // path to the database file
Sources []*Source `json:"sources" validate:"required,dive"`
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
CacheDirCleanup bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory. 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)
Filesystem Filesystem `json:"filesystem"` // filesystem settings
IndexSqlConfig IndexSqlConfig `json:"indexSqlConfig"` // Index database SQL configuration
DisableWebDAV bool `json:"disableWebDAV"` // disable webdav support (default: false)
MinSearchLength int `json:"minSearchLength" yaml:"minSearchLength"` // minimum length of search query to begin searching (default: 3)
DisableUpdateCheck bool `json:"disableUpdateCheck"` // disables backend update check service
NumImageProcessors int `json:"numImageProcessors"` // number of concurrent image processing jobs used to create previews, default is number of cpu cores available.
Socket string `json:"socket"` // socket to listen on
TLSKey string `json:"tlsKey"` // path to TLS key
TLSCert string `json:"tlsCert"` // path to TLS cert
DisablePreviews bool `json:"disablePreviews"` // disable all previews thumbnails, simple icons will be used
DisableResize bool `json:"disablePreviewResize"` // disable resizing of previews for faster loading over slow connections
DisableTypeDetectionByHeader bool `json:"disableTypeDetectionByHeader"` // disable type detection by header, useful if filesystem is slow.
Port int `json:"port"` // port to listen on
ListenAddress string `json:"listen"` // address to listen on (default: 0.0.0.0)
BaseURL string `json:"baseURL"` // base URL for the server, the subpath that the server is running on.
Logging []LogConfig `json:"logging" yaml:"logging"`
Database string `json:"database"` // path to the database file
Sources []*Source `json:"sources" validate:"required,dive"`
ExternalUrl string `json:"externalUrl"` // used by share links if set (eg. http://mydomain.com)
InternalUrl string `json:"internalUrl"` // used by integrations if set, this is the base domain that an integration service will use to communicate with filebrowser (eg. http://localhost:8080)
CacheDir string `json:"cacheDir"` // path to the cache directory, used for thumbnails and other cached files
CacheDirCleanup *bool `json:"cacheDirCleanup"` // whether to automatically cleanup the cache directory (default: true)
MaxArchiveSizeGB int64 `json:"maxArchiveSize"` // max pre-archive combined size of files/folder that are allowed to be archived (in GB)
Filesystem Filesystem `json:"filesystem"` // filesystem settings
// not exposed to config
SourceMap map[string]*Source `json:"-" validate:"omitempty"` // uses realpath as key
NameToSource map[string]*Source `json:"-" validate:"omitempty"` // uses name as key
@@ -72,24 +70,6 @@ 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)
}
type Integrations struct {
OnlyOffice OnlyOffice `json:"office" validate:"omitempty"`
Media Media `json:"media" validate:"omitempty"`
@@ -109,19 +89,17 @@ type Media struct {
Convert FfmpegConvert `json:"convert"` // config for ffmpeg conversion settings
Debug bool `json:"debug"` // output ffmpeg stdout for media integration -- careful can produces lots of output!
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles"` // extract embedded subtitles from media files
ExiftoolPath string `json:"exiftoolPath"` // path to exiftool executable
}
type FfmpegConvert struct {
ImagePreview map[ImagePreviewType]*bool `json:"imagePreview"` // supported image preview formats. defaults vary by type (see individual type docs)
VideoPreview map[VideoPreviewType]*bool `json:"videoPreview"` // supported video preview formats. defaults to true for all types unless explicitly disabled.
ImagePreview map[ImagePreviewType]bool `json:"imagePreview"` // supported image preview formats. defaults to false for all types unless explicitly enabled.
VideoPreview map[VideoPreviewType]bool `json:"videoPreview"` // supported video preview formats. defaults to true for all types unless explicitly disabled.
}
type ImagePreviewType string
const (
HEICImagePreview ImagePreviewType = "heic"
JPEGImagePreview ImagePreviewType = "jpeg" // only used as fallback for JPEG formats that can't otherwise be decoded
//RAWImagePreview ImagePreviewType = "raw"
)
@@ -132,7 +110,6 @@ func (i ImagePreviewType) String() string {
// AllImagePreviewTypes contains all supported image preview types.
var AllImagePreviewTypes = []ImagePreviewType{
HEICImagePreview,
JPEGImagePreview,
//RAWImagePreview,
}
@@ -204,26 +181,24 @@ type SourceConfig struct {
DenyByDefault bool `json:"denyByDefault,omitempty"` // deny access unless an "allow" access rule was specifically created.
Private bool `json:"private"` // designate as source as private -- currently just means no sharing permitted.
Disabled bool `json:"disabled,omitempty"` // disable the source, this is useful so you don't need to remove it from the config file
IndexingInterval uint32 `json:"indexingIntervalMinutes,omitempty"` // deprecated: create a rule with indexingIntervalMinutes to set the indexing interval for this source
DisableIndexing bool `json:"disableIndexing,omitempty"` // deprecated: use indexingDisabled instead to disable the indexing of this source
Conditionals ConditionalFilter `json:"conditionals"` // deprecated: use source.rules instead
Rules []ConditionalRule `json:"rules"` // list of item rules to apply to specific paths
IndexingInterval uint32 `json:"indexingIntervalMinutes,omitempty"` // (optional) not recommended: manual overide interval in minutes to re-index the source
DisableIndexing bool `json:"disableIndexing,omitempty"` // (optional) not recommended: disable the indexing of this source
Conditionals ConditionalFilter `json:"conditionals"` // conditional rules to apply when indexing to include/exclude certain items
DefaultUserScope string `json:"defaultUserScope"` // defaults to root of index "/" should match folders under path
DefaultEnabled bool `json:"defaultEnabled"` // should be added as a default source for new users?
CreateUserDir bool `json:"createUserDir"` // create a user directory for each user under defaultUserScope + username
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:"-"`
ResolvedConditionals *ResolvedConditionalsConfig `json:"-"`
}
type ConditionalFilter struct {
Hidden bool `json:"hidden"` // deprecated: use ignoreHidden instead. eg, FolderPath: "/" and ignoreHidden: true will exclude hidden files and folders under the root folder.
IgnoreHidden bool `json:"ignoreHidden"` // deprecated: use ignoreHidden instead. eg, FolderPath: "/" and ignoreHidden: true will exclude hidden files and folders under the root folder.
ZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // deprecated: use ignoreZeroSizeFolders instead. eg, FolderPath: "/" and ignoreZeroSizeFolders: true will ignore folders with 0 size under the root folder.
ItemRules []ConditionalRule `json:"rules"` // list of item rules to apply to specific paths
Hidden bool `json:"hidden"` // deprecated: use ignoreHidden instead to exclude hidden files and folders.
IgnoreHidden bool `json:"ignoreHidden"` // exclude hidden files and folders.
ZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // ignore folders with 0 size
ItemRules []ConditionalIndexConfig `json:"rules"` // list of item rules to apply to specific paths
}
type ConditionalRule struct {
type ConditionalIndexConfig struct {
NeverWatchPath string `json:"neverWatchPath"` // index the folder in the first pass to get included in search, but never re-indexed.
IncludeRootItem string `json:"includeRootItem"` // include only these items at root folder level
FileStartsWith string `json:"fileStartsWith"` // (global) exclude files that start with these prefixes. Eg. "archive-" or "backup-"
@@ -237,38 +212,29 @@ type ConditionalRule struct {
FileName string `json:"fileName"` // (global) exclude files that match these names. Eg. "file.txt" or "test.csv"
FolderName string `json:"folderName"` // (global) exclude folders that match these names. Eg. "folder" or "subfolder"
Viewable bool `json:"viewable"` // Enable viewing in UI but exclude from indexing
IgnoreHidden bool `json:"ignoreHidden"` // Excludes only hidden files and folders
IgnoreZeroSizeFolders bool `json:"ignoreZeroSizeFolders"` // Excludes only folders with 0 size
IgnoreSymlinks bool `json:"ignoreSymlinks"` // Excludes symbolic links
Viewable bool `json:"viewable"` // Enable viewing in UI but exclude from indexing
}
// ConditionalMaps provides O(1) lookup performance for conditional rules
// Maps are built from ConditionalFilter during initialization
type ResolvedRulesConfig struct {
type ResolvedConditionalsConfig struct {
// Exact match maps - O(1) lookup (only for names, not StartsWith/EndsWith)
FileNames map[string]ConditionalRule // key: file name
FolderNames map[string]ConditionalRule // key: folder name
FileNames map[string]ConditionalIndexConfig // key: file name
FolderNames map[string]ConditionalIndexConfig // key: folder name
// exact match for paths
FilePaths map[string]ConditionalRule // key: file path
FolderPaths map[string]ConditionalRule // key: folder path
FilePaths map[string]ConditionalIndexConfig // key: file path
FolderPaths map[string]ConditionalIndexConfig // key: folder path
FileEndsWith []ConditionalRule // list of item rules that have been resolved for specific paths
FolderEndsWith []ConditionalRule // list of item rules that have been resolved for specific paths
FileStartsWith []ConditionalRule // list of item rules that have been resolved for specific paths
FolderStartsWith []ConditionalRule // list of item rules that have been resolved for specific paths
FileEndsWith []ConditionalIndexConfig // list of item rules that have been resolved for specific paths
FolderEndsWith []ConditionalIndexConfig // list of item rules that have been resolved for specific paths
FileStartsWith []ConditionalIndexConfig // list of item rules that have been resolved for specific paths
FolderStartsWith []ConditionalIndexConfig // list of item rules that have been resolved for specific paths
// NeverWatch paths map - O(1) lookup for all paths with neverWatch: true
// This replaces the old NeverWatchPaths slice
NeverWatchPaths map[string]struct{} // key: full folder path
IncludeRootItems map[string]struct{} // key: inclusive root item
IgnoreAllHidden bool // Excludes all hidden files and folders
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 {
@@ -278,11 +244,9 @@ type Frontend struct {
ExternalLinks []ExternalLink `json:"externalLinks"`
DisableNavButtons bool `json:"disableNavButtons"` // disable the nav buttons in the sidebar
Styling StylingConfig `json:"styling"`
Favicon string `json:"favicon"` // path to a favicon to use for the frontend
Description string `json:"description"` // description that shows up in html head meta description
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
LoginButtonText string `json:"loginButtonText"` // text to display on the login button
OIDCLoginButtonText string `json:"oidcLoginButtonText"` // text to display on the OIDC login button
Favicon string `json:"favicon"` // path to a favicon to use for the frontend
Description string `json:"description"` // description that shows up in html head meta description
LoginIcon string `json:"loginIcon"` // path to an image file for the login page icon
}
type StylingConfig struct {
@@ -311,17 +275,15 @@ type ExternalLink struct {
// UserDefaultsPreview holds preview settings with pointer types for defaults
type UserDefaultsPreview struct {
DisableHideSidebar bool `json:"disableHideSidebar"` // keep sidebar open when previewing files
HighQuality *bool `json:"highQuality"` // deprecated: always true in v1.3.0+
HighQuality *bool `json:"highQuality"` // use high quality thumbnails
Image *bool `json:"image"` // show thumbnails for image files
Video *bool `json:"video"` // show thumbnails for video files
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
@@ -344,7 +306,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
@@ -364,12 +325,11 @@ 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.
DefaultLandingPage string `json:"defaultLandingPage"` // deprecated: determined by sidebar link order since 1.1.0
}
+11 -11
View File
@@ -26,7 +26,7 @@ integrations:
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// All video formats should be enabled by default
for _, format := range AllVideoPreviewTypes {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
if !config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
}
}
@@ -52,7 +52,7 @@ integrations:
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// All video formats should be enabled by default
for _, format := range AllVideoPreviewTypes {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
if !config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
}
}
@@ -81,16 +81,16 @@ integrations:
`
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// Check explicitly set formats
if !*config.Integrations.Media.Convert.VideoPreview[MP4VideoPreview] {
if !config.Integrations.Media.Convert.VideoPreview[MP4VideoPreview] {
t.Error("Expected mp4 to be enabled")
}
if *config.Integrations.Media.Convert.VideoPreview[WebMVideoPreview] {
if config.Integrations.Media.Convert.VideoPreview[WebMVideoPreview] {
t.Error("Expected webm to be disabled")
}
if *config.Integrations.Media.Convert.VideoPreview[MKVVideoPreview] {
if config.Integrations.Media.Convert.VideoPreview[MKVVideoPreview] {
t.Error("Expected mkv to be disabled")
}
if *config.Integrations.Media.Convert.VideoPreview[WMVVideoPreview] {
if config.Integrations.Media.Convert.VideoPreview[WMVVideoPreview] {
t.Error("Expected wmv to be disabled")
}
@@ -100,7 +100,7 @@ integrations:
M4VVideoPreview, ThreeGPVideoPreview, TSVideoPreview, VOBVideoPreview,
}
for _, format := range unmentionedFormats {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
if !config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled (not mentioned in config), but it was disabled", format)
}
}
@@ -155,13 +155,13 @@ integrations:
}
for _, format := range expectedEnabled {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
if !config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled, but it was disabled", format)
}
}
for _, format := range expectedDisabled {
if *config.Integrations.Media.Convert.VideoPreview[format] {
if config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be disabled, but it was enabled", format)
}
}
@@ -182,7 +182,7 @@ integrations:
testVideoPreviewConfig(t, config, func(t *testing.T, config *Settings) {
// All video formats should be enabled by default
for _, format := range AllVideoPreviewTypes {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
if !config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s to be enabled by default, but it was disabled", format)
}
}
@@ -217,7 +217,7 @@ integrations:
for category, formats := range categories {
t.Run(category, func(t *testing.T) {
for _, format := range formats {
if !*config.Integrations.Media.Convert.VideoPreview[format] {
if !config.Integrations.Media.Convert.VideoPreview[format] {
t.Errorf("Expected %s (%s) to be enabled by default, but it was disabled", format, category)
}
}
-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)
}
+6 -36
View File
@@ -6,12 +6,9 @@ import (
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"fmt"
"hash"
"io"
"os"
"path/filepath"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
)
@@ -47,42 +44,15 @@ func GetChecksum(fullPath, algo string) (string, error) {
// FileOptions are the options when getting a file info.
type FileOptions struct {
Username string // username for access control
Path string // realpath
Source string
IsDir bool
Expand bool
ReadHeader bool
Content bool
Recursive bool // whether to recursively index directories
Metadata bool // whether to get metadata
ExtractEmbeddedSubtitles bool // whether to extract embedded subtitles from media files
AlbumArt bool // whether to get album art from media files
ShowHidden bool // whether to show hidden files (true = show, false = hide)
FollowSymlinks bool // whether to follow symlinks
Only string // whether to only get files or folders
SkipExtendedAttrs bool // whether to skip extended attributes
ShowSharedAttr bool // whether to show share attributes
}
// SanitizeUserPath prevents path traversal attacks by cleaning and validating user input.
// Rule 1: Do Not Use User Input in File Paths (without validation)
func SanitizeUserPath(userPath string) (string, error) {
clean := filepath.Clean(userPath)
// Split the path into segments to check for path traversal attempts
// We check if ".." appears as a complete path segment (not just in a filename)
segments := strings.Split(clean, string(filepath.Separator))
for _, segment := range segments {
// Check if any segment is exactly ".." (path traversal attempt)
// This catches any ".." that filepath.Clean couldn't resolve (i.e., escape attempts)
if segment == ".." {
return "", fmt.Errorf("invalid path: path traversal detected")
}
}
if clean == "." {
return "", fmt.Errorf("invalid path: path must standard index path")
}
return clean, nil
Recursive bool // whether to recursively index directories
Metadata bool // whether to get metadata
ExtractEmbeddedSubtitles bool // whether to extract embedded subtitles from media files
AlbumArt bool // whether to get album art from media files
}
+17 -15
View File
@@ -1,7 +1,9 @@
package utils
import (
"cmp"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
math "math/rand"
"os"
@@ -20,6 +22,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 +91,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 ""
@@ -114,20 +130,6 @@ func Ternary[T any](cond bool, vtrue, vfalse T) T {
return vfalse
}
// Clamp returns value clamped between min and max.
// If value < min, returns min.
// If value > max, returns max.
// Otherwise, returns value.
func Clamp[T cmp.Ordered](value, min, max T) T {
if value < min {
return min
}
if value > max {
return max
}
return value
}
// NormalizeRulePath ensures directory paths have trailing slashes for consistent rule storage
func AddTrailingSlashIfNotExists(indexPath string) string {
// Root path stays as "/"
-43
View File
@@ -1,43 +0,0 @@
package utils
import (
"errors"
"os"
)
type SubtitleTrack struct {
Name string `json:"name"` // filename for external, or descriptive name for embedded
Language string `json:"language"` // language code
Title string `json:"title,omitempty"` // title/description
Index *int `json:"index,omitempty"` // stream index for embedded subtitles (nil for external)
Codec string `json:"codec,omitempty"` // codec name for embedded subtitles
Embedded bool `json:"embedded"` // true for embedded subtitles, false for external files
}
func GetSubtitleSidecarContent(subtitlePath string) (string, error) {
// validate the size is not too large
info, err := os.Stat(subtitlePath)
if err != nil {
return "", err
}
if info.Size() > 1024*1024*50 { // 50MB
return "", errors.New("subtitle file is too large")
}
// First check if it's a text file using the consolidated validation logic
isText, err := IsTextFile(subtitlePath)
if err != nil {
return "", err
}
if !isText {
// Not a text file, return empty string (no error, just not text)
return "", nil
}
// read the file content
content, err := os.ReadFile(subtitlePath)
if err != nil {
return "", err
}
return string(content), nil
}
-65
View File
@@ -1,65 +0,0 @@
package utils
import (
"os"
"unicode/utf8"
)
// IsTextFile checks if a file is viewable/editable as text (like with cat or a text editor).
// It returns true if the file appears to be text, false if it's likely binary.
// This function uses simple heuristics:
// - Valid UTF-8 encoding
// - Not full of null bytes (which indicate binary data)
func IsTextFile(realPath string) (bool, error) {
const sampleSize = 8192 // Read first 8KB to check
const maxNullByteRatio = 0.05 // Reject if more than 5% null bytes
// Read sample from file
content, err := os.ReadFile(realPath)
if err != nil {
return false, err
}
// Empty files are considered text
if len(content) == 0 {
return true, nil
}
// Use sample for large files to avoid reading entire file into memory
sample := content
if len(content) > sampleSize {
sample = content[:sampleSize]
}
// Check 1: Count null bytes - binary files typically have many nulls
nullCount := 0
for _, b := range sample {
if b == 0x00 {
nullCount++
}
}
if nullCount > 0 {
nullRatio := float64(nullCount) / float64(len(sample))
if nullRatio > maxNullByteRatio {
return false, nil
}
}
// Check 2: Validate UTF-8 encoding
// Trim sample to last complete UTF-8 rune to avoid false negatives
trimmedSample := sample
for len(trimmedSample) > 0 {
lastRune, size := utf8.DecodeLastRune(trimmedSample)
if lastRune != utf8.RuneError {
break
}
if size == 1 {
trimmedSample = trimmedSample[:len(trimmedSample)-1]
} else {
break
}
}
if len(trimmedSample) > 0 && !utf8.Valid(trimmedSample) {
return false, nil
}
return true, nil
}
+1 -1
View File
@@ -8,4 +8,4 @@ userDefaults:
image: true
popup: true
video: true
audio: true
highQuality: true
+2 -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
@@ -26,16 +25,15 @@ userDefaults:
disableSettings: false # disable the user from viewing the settings page
preview:
disableHideSidebar: false # disable the hide sidebar preview for previews and editors
highQuality: true # generate high quality thumbnail preview images (defaults to true if not set)
image: true # show thumbnail preview image for image files (defaults to true if not set)
video: true # show thumbnail preview image for video files (defaults to true if not set)
audio: true # show thumbnail preview image for audio files (defaults to true if not set)
motionVideoPreview: true # show multiple frames for videos in thumbnail preview when hovering (defaults to true if not set)
office: true # show thumbnail preview image for office files (defaults to true if not set)
popup: true # show larger popup preview when hovering over thumbnail (defaults to true if not set)
autoplayMedia: true # autoplay media files in preview (defaults to true if not set)
defaultMediaPlayer: false # disable html5 media player and use the default media player
folder: true # show thumbnail preview image for folder files (defaults to true if not set)
models: true # show live thumbnail preview for 3D Models files (defaults to true if not set)
permissions:
api: false # allow api access
admin: false # allow admin access
@@ -47,9 +45,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
+124 -127
View File
@@ -37,10 +37,8 @@ type SourceRuleMap map[string]RuleMap
type StringSet map[string]struct{}
type dbStorage struct {
AllRules SourceRuleMap `json:"all_rules"`
Groups GroupMap `json:"groups"`
RevokedTokens map[string]struct{} `json:"revoked_tokens"` // set of revoked token hashes
HashedTokens map[string]uint `json:"hashed_tokens"` // maps token hash → user ID
AllRules SourceRuleMap `json:"all_rules"`
Groups GroupMap `json:"groups"`
}
// RuleSet groups users and groups for allow/deny lists.
@@ -74,26 +72,21 @@ type GroupMap map[string]StringSet
// Storage manages access rules and group membership.
type Storage struct {
mux sync.RWMutex
AllRules SourceRuleMap // AllRules[sourcePath][indexPath] - in-memory authoritative state
Groups GroupMap // key: group name, value: set of usernames - in-memory authoritative state
RevokedTokens map[string]struct{} // set of revoked token hashes - in-memory authoritative state
HashedTokens map[string]uint // maps token hash → user ID - in-memory authoritative state
DB *storm.DB // Optional: DB for persistence
Users *users.Storage // Reference to users storage
mux sync.RWMutex
AllRules SourceRuleMap // AllRules[sourcePath][indexPath] - in-memory authoritative state
Groups GroupMap // key: group name, value: set of usernames - in-memory authoritative state
DB *storm.DB // Optional: DB for persistence
Users *users.Storage // Reference to users storage
}
// SaveToDB persists all rules to the DB if DB is set.
// IMPORTANT: Caller must hold s.mux lock (either read or write).
func (s *Storage) SaveToDB() error {
if s.DB == nil {
return nil
}
data, err := json.Marshal(&dbStorage{
AllRules: s.AllRules,
Groups: s.Groups,
RevokedTokens: s.RevokedTokens,
HashedTokens: s.HashedTokens,
AllRules: s.AllRules,
Groups: s.Groups,
})
if err != nil {
return err
@@ -104,8 +97,6 @@ func (s *Storage) SaveToDB() error {
// Flush persists the current in-memory state to the backing store.
// Call during graceful shutdown to ensure DB matches memory.
func (s *Storage) Flush() error {
s.mux.RLock()
defer s.mux.RUnlock()
return s.SaveToDB()
}
@@ -132,18 +123,6 @@ func (s *Storage) LoadFromDB() error {
if s.Groups == nil {
s.Groups = make(GroupMap)
}
s.RevokedTokens = storage.RevokedTokens
if s.RevokedTokens == nil {
s.RevokedTokens = make(map[string]struct{})
}
s.HashedTokens = storage.HashedTokens
if s.HashedTokens == nil {
s.HashedTokens = make(map[string]uint)
}
s.HashedTokens = storage.HashedTokens
if s.HashedTokens == nil {
s.HashedTokens = make(map[string]uint)
}
s.mux.Unlock()
return nil
}
@@ -157,12 +136,10 @@ func (s *Storage) LoadFromDB() error {
// if err != nil { /* handle error */ }
func NewStorage(db *storm.DB, usersStore *users.Storage) *Storage {
var s = &Storage{
AllRules: make(SourceRuleMap),
Groups: make(GroupMap),
RevokedTokens: make(map[string]struct{}),
HashedTokens: make(map[string]uint),
DB: db,
Users: usersStore,
AllRules: make(SourceRuleMap),
Groups: make(GroupMap),
DB: db,
Users: usersStore,
}
return s
}
@@ -176,12 +153,18 @@ func ClearCache() {
rulesCache = cache.NewCache[map[string]FrontendAccessRule](1 * time.Minute)
}
// clearAllCaches clears ALL caches. This should be called whenever rules are created, updated, or deleted.
func (s *Storage) clearAllCaches() {
accessCache.ClearAll()
versionCache.ClearAll()
permissionCache.ClearAll()
rulesCache.ClearAll()
// ClearCacheForSource clears the cache for a specific source
func (s *Storage) ClearCacheForSource(sourcePath string) {
// Clear all caches related to this source
accessCache.Delete(accessChangedKey + sourcePath)
rulesCache.Delete(accessChangedKey + sourcePath)
versionCache.Delete("version:" + sourcePath)
// Clear all permission caches for this source
// We need to iterate through all permission cache keys and remove those for this source
// Since we can't iterate through cache keys, we'll just increment the version
// which will invalidate all permission caches for this source
s.incrementSourceVersion(sourcePath)
}
// RemoveRuleByPath removes a rule by its exact path from the internal storage
@@ -197,11 +180,18 @@ func (s *Storage) RemoveRuleByPath(sourcePath, indexPath string) {
// Remove the rule by exact path match (don't normalize)
if _, exists := rulesBySource[indexPath]; exists {
delete(rulesBySource, indexPath)
// If no rules left for this source, remove the source entry
if len(rulesBySource) == 0 {
delete(s.AllRules, sourcePath)
}
s.clearAllCaches()
// Invalidate caches
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
// Save to database
err := s.SaveToDB()
if err != nil {
logger.Errorf("error saving access rules to database: %v", err)
@@ -243,7 +233,9 @@ func (s *Storage) DenyUser(sourcePath, indexPath, username string) error {
return errors.ErrExist
}
rule.Deny.Users[username] = struct{}{}
s.clearAllCaches()
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return s.SaveToDB()
}
@@ -262,7 +254,9 @@ func (s *Storage) AllowUser(sourcePath, indexPath, username string) error {
return errors.ErrExist
}
rule.Allow.Users[username] = struct{}{}
s.clearAllCaches()
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return s.SaveToDB()
}
@@ -279,7 +273,9 @@ func (s *Storage) DenyGroup(sourcePath, indexPath, groupname string) error {
return errors.ErrExist
}
rule.Deny.Groups[groupname] = struct{}{}
s.clearAllCaches()
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return s.SaveToDB()
}
@@ -296,7 +292,9 @@ func (s *Storage) AllowGroup(sourcePath, indexPath, groupname string) error {
return errors.ErrExist
}
rule.Allow.Groups[groupname] = struct{}{}
s.clearAllCaches()
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return s.SaveToDB()
}
@@ -309,17 +307,19 @@ func (s *Storage) DenyAll(sourcePath, indexPath string) error {
return errors.ErrExist
}
rule.DenyAll = true
s.clearAllCaches()
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return s.SaveToDB()
}
// Permitted checks if a username is permitted for a given sourcePath and indexPath, recursively checking parent directories.
func (s *Storage) Permitted(sourcePath, indexPath, username string) bool {
// Ensure leading slash
// SECURITY: All paths MUST start with "/" - reject any path that doesn't
// This prevents path normalization bypass attacks
if !strings.HasPrefix(indexPath, "/") {
indexPath = "/" + indexPath
return false
}
indexPath = utils.AddTrailingSlashIfNotExists(indexPath)
// Get current version for the sourcePath
versionKey := "version:" + sourcePath
@@ -659,7 +659,11 @@ func (s *Storage) RemoveAllowUser(sourcePath, indexPath, username string) (bool,
if exists {
delete(rule.Allow.Users, username)
}
removed := exists
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], normalizedPath)
@@ -668,7 +672,8 @@ func (s *Storage) RemoveAllowUser(sourcePath, indexPath, username string) (bool,
}
}
if removed {
s.clearAllCaches()
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return exists, s.SaveToDB()
}
return false, nil
@@ -687,7 +692,11 @@ func (s *Storage) RemoveAllowGroup(sourcePath, indexPath, groupname string) (boo
if exists {
delete(rule.Allow.Groups, groupname)
}
removed := exists
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], normalizedPath)
@@ -696,7 +705,8 @@ func (s *Storage) RemoveAllowGroup(sourcePath, indexPath, groupname string) (boo
}
}
if removed {
s.clearAllCaches()
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return exists, s.SaveToDB()
}
return exists, nil
@@ -715,7 +725,11 @@ func (s *Storage) RemoveDenyUser(sourcePath, indexPath, username string) (bool,
if exists {
delete(rule.Deny.Users, username)
}
removed := exists
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], normalizedPath)
@@ -724,7 +738,8 @@ func (s *Storage) RemoveDenyUser(sourcePath, indexPath, username string) (bool,
}
}
if removed {
s.clearAllCaches()
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return exists, s.SaveToDB()
}
return false, nil
@@ -743,7 +758,11 @@ func (s *Storage) RemoveDenyGroup(sourcePath, indexPath, groupname string) (bool
if exists {
delete(rule.Deny.Groups, groupname)
}
removed := exists
removed := false
if exists {
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], normalizedPath)
@@ -752,7 +771,8 @@ func (s *Storage) RemoveDenyGroup(sourcePath, indexPath, groupname string) (bool
}
}
if removed {
s.clearAllCaches()
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return exists, s.SaveToDB()
}
return exists, nil
@@ -770,6 +790,7 @@ func (s *Storage) RemoveDenyAll(sourcePath, indexPath string) (bool, error) {
removed := false
if rule.DenyAll {
rule.DenyAll = false
s.incrementSourceVersion(sourcePath)
removed = true
}
// If rule is now empty, remove it
@@ -780,7 +801,8 @@ func (s *Storage) RemoveDenyAll(sourcePath, indexPath string) (bool, error) {
}
}
if removed {
s.clearAllCaches()
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return true, s.SaveToDB()
}
return false, nil
@@ -812,8 +834,12 @@ func (s *Storage) RemoveAllRulesForUser(username string) error {
}
}
}
for sp := range changedSourcePaths {
s.incrementSourceVersion(sp)
accessCache.Set(accessChangedKey+sp, "false")
rulesCache.Delete(accessChangedKey + sp)
}
if changed {
s.clearAllCaches()
return s.SaveToDB()
}
return nil
@@ -845,8 +871,12 @@ func (s *Storage) RemoveAllRulesForGroup(groupname string) error {
}
}
}
for sp := range changedSourcePaths {
s.incrementSourceVersion(sp)
accessCache.Set(accessChangedKey+sp, "false")
rulesCache.Delete(accessChangedKey + sp)
}
if changed {
s.clearAllCaches()
return s.SaveToDB()
}
return nil
@@ -1054,11 +1084,25 @@ func (s *Storage) GetAllRulesByGroups(sourcePath string) map[string]map[string]F
return allGroupRules
}
// incrementSourceVersion increments the version of a sourcePath to invalidate caches.
func (s *Storage) incrementSourceVersion(sourcePath string) {
key := "version:" + sourcePath
version := 0
if v, ok := versionCache.Get(key); ok {
version = v
}
versionCache.Set(key, version+1)
}
// HasAnyVisibleItems checks if a user has access to any items in a given parent path.
// This is used to determine if a user should see a folder's contents even when
// they don't have direct access to the parent folder.
func (s *Storage) HasAnyVisibleItems(sourcePath, parentPath string, itemNames []string, username string) bool {
parentPath = utils.AddTrailingSlashIfNotExists(parentPath)
// Ensure parentPath has trailing slash for proper path construction
if !strings.HasSuffix(parentPath, "/") {
parentPath = parentPath + "/"
}
// Check if user has access to any of the items
for _, itemName := range itemNames {
indexPath := parentPath + itemName
@@ -1120,7 +1164,9 @@ func (s *Storage) RemoveUserCascade(sourcePath, indexPath, username string, allo
}
if changed {
s.clearAllCaches()
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return removedCount, s.SaveToDB()
}
@@ -1177,7 +1223,9 @@ func (s *Storage) RemoveGroupCascade(sourcePath, indexPath, groupname string, al
}
if changed {
s.clearAllCaches()
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
return removedCount, s.SaveToDB()
}
@@ -1224,7 +1272,11 @@ func (s *Storage) UpdateRules(sourcePath, oldPath, newPath string) (int, error)
}
if updated > 0 {
s.clearAllCaches()
// Invalidate caches
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
if err := s.SaveToDB(); err != nil {
return updated, err
}
@@ -1255,67 +1307,12 @@ func (s *Storage) UpdateRulePath(sourcePath, oldPath, newPath string) error {
// Remove the old rule and add it with the new path
delete(rulesBySource, oldPath)
rulesBySource[newPath] = rule
s.clearAllCaches()
// Invalidate caches
s.incrementSourceVersion(sourcePath)
accessCache.Set(accessChangedKey+sourcePath, "false")
rulesCache.Delete(accessChangedKey + sourcePath)
logger.Debugf("access rule path updated: source=%s, fromPath=%s, toPath=%s", sourcePath, oldPath, newPath)
return s.SaveToDB()
}
// RevokeToken adds a token hash to the revoked list and persists to DB.
func (s *Storage) RevokeToken(tokenHash string) error {
s.mux.Lock()
defer s.mux.Unlock()
tokenHash = utils.HashSHA256(tokenHash)
s.RevokedTokens[tokenHash] = struct{}{}
// Also remove from HashedTokens to prevent future lookups
delete(s.HashedTokens, tokenHash)
return s.SaveToDB()
}
// IsTokenRevoked checks if a token hash is in the revoked list (memory-only read).
func (s *Storage) IsTokenRevoked(tokenHash string) bool {
s.mux.RLock()
defer s.mux.RUnlock()
tokenHash = utils.HashSHA256(tokenHash)
_, exists := s.RevokedTokens[tokenHash]
return exists
}
// AddHashedToken adds a token hash to user ID mapping.
func (s *Storage) AddApiToken(tokenString string, userID uint) error {
s.mux.Lock()
defer s.mux.Unlock()
tokenHash := utils.HashSHA256(tokenString)
s.HashedTokens[tokenHash] = userID
return s.SaveToDB()
}
// GetUserIDFromToken retrieves the user ID for a given token string (memory-only read).
func (s *Storage) GetUserIDFromToken(tokenString string) (uint, bool) {
s.mux.RLock()
defer s.mux.RUnlock()
tokenHash := utils.HashSHA256(tokenString)
userID, exists := s.HashedTokens[tokenHash]
return userID, exists
}
// GetRevokedTokens returns a copy of all revoked token hashes.
func (s *Storage) GetRevokedTokens() map[string]struct{} {
s.mux.RLock()
defer s.mux.RUnlock()
// Return a copy to prevent external modification
result := make(map[string]struct{}, len(s.RevokedTokens))
for k := range s.RevokedTokens {
result[k] = struct{}{}
}
return result
}
// RemoveApiToken removes a token hash mapping (used when deleting API keys).
func (s *Storage) RemoveApiToken(tokenString string) error {
s.mux.Lock()
defer s.mux.Unlock()
tokenHash := utils.HashSHA256(tokenString)
delete(s.HashedTokens, tokenHash)
return s.SaveToDB()
}
+2 -17
View File
@@ -38,32 +38,17 @@ func setupTestSources() {
Path: "mnt/storage",
Name: "storage",
Config: settings.SourceConfig{
DenyByDefault: false,
DefaultUserScope: "/",
DenyByDefault: false,
},
},
"mnt/open": {
Path: "mnt/open",
Name: "open",
Config: settings.SourceConfig{
DenyByDefault: false,
DefaultUserScope: "/",
DenyByDefault: false,
},
},
}
settings.Config.Server.NameToSource = map[string]*settings.Source{
"storage": settings.Config.Server.SourceMap["mnt/storage"],
"open": settings.Config.Server.SourceMap["mnt/open"],
}
settings.Config.Server.Sources = []*settings.Source{
settings.Config.Server.SourceMap["mnt/storage"],
settings.Config.Server.SourceMap["mnt/open"],
}
settings.Config.UserDefaults.DefaultScopes = []users.SourceScope{
{Name: "mnt/storage", Scope: "/"},
}
// Initialize user package resolvers
settings.InitializeUserResolvers()
}
func TestPermitted_UserBlacklist(t *testing.T) {
+3 -4
View File
@@ -7,7 +7,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/indexing/iteminfo"
)
func (s *Storage) CheckChildItemAccess(response *iteminfo.FileInfo, index *indexing.Index, username string) error {
func (s *Storage) CheckChildItemAccess(response *iteminfo.ExtendedFileInfo, index *indexing.Index, username string) error {
// Collect all item names to check
allItemNames := make([]string, 0, len(response.Folders)+len(response.Files))
@@ -19,11 +19,10 @@ func (s *Storage) CheckChildItemAccess(response *iteminfo.FileInfo, index *index
}
// Use standardized path with trailing slash for proper path construction
// response is an ExtendedFileInfo which represents a directory (has Folders and Files)
parentPath := index.MakeIndexPath(response.Path, true)
parentPath := index.MakeIndexPath(response.Path)
// Check if user has access to any items
if !s.HasAnyVisibleItems(index.Path, parentPath, allItemNames, username) && len(allItemNames) > 0 {
if !s.HasAnyVisibleItems(index.Path, parentPath, allItemNames, username) {
return errors.ErrAccessDenied
}
-39
View File
@@ -1,39 +0,0 @@
package dbindex
import (
"time"
)
// IndexInfo stores persistent information about an index and its scanners
type IndexInfo struct {
// Index identifier - uses the real filesystem path (index.Path) as the key
Path string `json:"path" storm:"id,index"`
// Source name (index.Name) for reference
Source string `json:"source" storm:"index"`
// Index-level stats
Complexity uint `json:"complexity"`
NumDirs uint64 `json:"numDirs"`
NumFiles uint64 `json:"numFiles"`
// Disk usage (cached from periodic GetIndexInfo / partition probes; aligns with ReducedIndex Stats)
UsedAsIndexed uint64 `json:"used"`
UsedDisk uint64 `json:"usedAlt"`
DiskTotal uint64 `json:"total"`
// Scanner information - map of scanner path to scanner stats
Scanners map[string]*PersistedScannerInfo `json:"scanners"`
}
// PersistedScannerInfo stores persistent information about a scanner
type PersistedScannerInfo struct {
Path string `json:"path"`
Complexity uint `json:"complexity"`
CurrentSchedule int `json:"currentSchedule"`
NumDirs uint64 `json:"numDirs"`
NumFiles uint64 `json:"numFiles"`
QuickScanTime int `json:"quickScanTime"`
FullScanTime int `json:"fullScanTime"`
LastScanned time.Time `json:"lastScanned"`
}
-203
View File
@@ -1,203 +0,0 @@
package dbindex
import (
"sync"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/crud"
)
// StorageBackend is the interface to implement for an indexing storage.
type StorageBackend interface {
All() ([]*IndexInfo, error)
GetByPath(path string) (*IndexInfo, error)
GetBySource(source string) ([]*IndexInfo, error)
Save(info *IndexInfo) error
Delete(path string) error
}
// crudBackend implements crud.CrudBackend[IndexInfo] for indexing storage.
type crudBackend struct {
back StorageBackend
}
func (c *crudBackend) GetByID(id any) (*IndexInfo, error) {
path, ok := id.(string)
if !ok {
return nil, errors.ErrInvalidDataType
}
return c.back.GetByPath(path)
}
func (c *crudBackend) GetAll() ([]*IndexInfo, error) {
return c.back.All()
}
func (c *crudBackend) Save(obj *IndexInfo) error {
return c.back.Save(obj)
}
func (c *crudBackend) DeleteByID(id any) error {
path, ok := id.(string)
if !ok {
return errors.ErrInvalidDataType
}
return c.back.Delete(path)
}
// Storage is an indexing storage using generics.
type Storage struct {
Generic *crud.Storage[IndexInfo]
back StorageBackend
indexCache map[string]*IndexInfo
mu sync.RWMutex
}
// NewStorage creates an indexing storage from a backend.
func NewStorage(back StorageBackend) *Storage {
return &Storage{
Generic: crud.NewStorage[IndexInfo](&crudBackend{back: back}),
back: back,
indexCache: make(map[string]*IndexInfo),
}
}
// All wraps StorageBackend.All
func (s *Storage) All() ([]*IndexInfo, error) {
infos, err := s.back.All()
if err != nil {
return nil, err
}
s.mu.Lock()
for i, info := range infos {
if info == nil {
continue
}
if existing, ok := s.indexCache[info.Path]; ok && existing != nil {
infos[i] = existing
} else {
s.indexCache[info.Path] = info
}
}
s.mu.Unlock()
return infos, nil
}
// GetByPath wraps StorageBackend.GetByPath
func (s *Storage) GetByPath(path string) (*IndexInfo, error) {
// return stable in-memory pointer if available
s.mu.RLock()
if info, ok := s.indexCache[path]; ok && info != nil {
s.mu.RUnlock()
return info, nil
}
s.mu.RUnlock()
info, err := s.back.GetByPath(path)
if err != nil {
return nil, err
}
s.mu.Lock()
s.indexCache[path] = info
s.mu.Unlock()
return info, nil
}
// GetBySource wraps StorageBackend.GetBySource
func (s *Storage) GetBySource(source string) ([]*IndexInfo, error) {
infos, err := s.back.GetBySource(source)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, info := range infos {
if info == nil {
continue
}
if existing, ok := s.indexCache[info.Path]; ok && existing != nil {
infos[i] = existing
} else {
s.indexCache[info.Path] = info
}
}
s.mu.Unlock()
return infos, nil
}
// Save wraps StorageBackend.Save
func (s *Storage) Save(info *IndexInfo) error {
if err := s.back.Save(info); err != nil {
return err
}
s.mu.Lock()
s.indexCache[info.Path] = info
s.mu.Unlock()
return nil
}
// Delete wraps StorageBackend.Delete
func (s *Storage) Delete(path string) error {
if err := s.back.Delete(path); err != nil {
return err
}
s.mu.Lock()
delete(s.indexCache, path)
s.mu.Unlock()
return nil
}
// Flush persists the current in-memory state of all indexes to the backing store.
// Call during graceful shutdown to ensure DB matches memory.
func (s *Storage) Flush() error {
s.mu.RLock()
infos := make([]*IndexInfo, 0, len(s.indexCache))
for _, info := range s.indexCache {
if info != nil {
infos = append(infos, info)
}
}
s.mu.RUnlock()
for _, info := range infos {
if err := s.back.Save(info); err != nil {
return err
}
}
return nil
}
// ResetAllComplexities sets the complexity to 0 for all indexes and their scanners.
// This should be called when the index database is recreated (either fresh start or after corruption).
func (s *Storage) ResetAllComplexities() error {
// Get all indexes from storage
infos, err := s.All()
if err != nil {
return err
}
// Reset complexity for each index
for _, info := range infos {
if info == nil {
continue
}
// Reset index-level complexity
info.Complexity = 0
// Reset scanner-level complexities
for _, scanner := range info.Scanners {
if scanner != nil {
scanner.Complexity = 0
}
}
// Save the updated info
if err := s.Save(info); err != nil {
return err
}
}
return nil
}
+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
}
-7
View File
@@ -25,9 +25,6 @@ type CommonShare struct {
Source string `json:"source,omitempty"` // backend source is path to maintain between name changes
Path string `json:"path,omitempty"`
DownloadURL string `json:"downloadURL,omitempty"`
ShareURL string `json:"shareURL,omitempty"`
FaviconUrl string `json:"faviconUrl,omitempty"`
BannerUrl string `json:"bannerUrl,omitempty"`
DisableShareCard bool `json:"disableShareCard,omitempty"`
EnforceDarkLightMode string `json:"enforceDarkLightMode,omitempty"` // "dark" or "light"
ViewMode string `json:"viewMode,omitempty"` // default view mode for anonymous users: "list", "compact", "normal", "gallery"
@@ -43,10 +40,6 @@ type CommonShare struct {
AllowReplacements bool `json:"allowReplacements,omitempty"` // allow replacements of files
SidebarLinks []users.SidebarLink `json:"sidebarLinks"` // customizable sidebar links
HasPassword bool `json:"hasPassword,omitempty"`
ShowHidden bool `json:"showHidden,omitempty"` // show hidden files in share (true = show, false = hide)
DisableLoginOption bool `json:"disableLoginOption"` // disable login option in share (true = hide, false = show)
SourceURL string `json:"sourceURL,omitempty"`
CanEditShare bool `json:"canEditShare,omitempty"`
}
type CreateBody struct {
CommonShare
+148 -194
View File
@@ -56,179 +56,86 @@ func (c *crudBackend) DeleteByID(id any) error {
// Storage is a share storage using generics.
type Storage struct {
Generic *crud.Storage[Link]
back StorageBackend
shareByHash map[string]*Link // key: link hash
shareByPath map[string]map[string]string // key: pathKey(Source, Path), value: set of hashes (hash -> "")
mu sync.RWMutex
users *users.Storage
Generic *crud.Storage[Link]
back StorageBackend
shareCache map[string]*Link
mu sync.RWMutex
users *users.Storage
}
// pathKey returns the cache key for shareByPath (source + path).
func pathKey(source, path string) string {
return source + ":" + path
}
// setCacheLocked updates both shareByHash and shareByPath for the given link.
// Caller must hold s.mu.
func (s *Storage) setCacheLocked(link *Link) {
if link == nil {
return
}
adjustedPath := utils.AddTrailingSlashIfNotExists(link.Path)
adjustedSource := utils.AddTrailingSlashIfNotExists(link.Source)
s.shareByHash[link.Hash] = link
key := pathKey(adjustedSource, adjustedPath)
if s.shareByPath[key] == nil {
s.shareByPath[key] = make(map[string]string)
}
s.shareByPath[key][link.Hash] = ""
}
// setCache updates both caches for the given link. It acquires s.mu.
func (s *Storage) setCache(link *Link) {
s.mu.Lock()
s.setCacheLocked(link)
s.mu.Unlock()
}
// deleteFromCacheLocked removes the link (by hash) from both caches.
// Caller must hold s.mu.
func (s *Storage) deleteFromCacheLocked(hash string) {
link, ok := s.shareByHash[hash]
if !ok {
return
}
adjustedPath := utils.AddTrailingSlashIfNotExists(link.Path)
adjustedSource := utils.AddTrailingSlashIfNotExists(link.Source)
key := pathKey(adjustedSource, adjustedPath)
if inner, ok := s.shareByPath[key]; ok {
delete(inner, hash)
if len(inner) == 0 {
delete(s.shareByPath, key)
}
}
delete(s.shareByHash, hash)
}
// deleteFromCache removes the link (by hash) from both caches. It acquires s.mu.
func (s *Storage) deleteFromCache(hash string) {
s.mu.Lock()
s.deleteFromCacheLocked(hash)
s.mu.Unlock()
}
// setCacheAfterMove updates both caches when a link's Source or Path changed. It acquires s.mu.
func (s *Storage) setCacheAfterMove(link *Link, oldSource, oldPath string) {
s.mu.Lock()
if link == nil {
return
}
adjustedOldPath := utils.AddTrailingSlashIfNotExists(oldPath)
adjustedOldSource := utils.AddTrailingSlashIfNotExists(oldSource)
oldKey := pathKey(adjustedOldSource, adjustedOldPath)
if inner, ok := s.shareByPath[oldKey]; ok {
delete(inner, link.Hash)
if len(inner) == 0 {
delete(s.shareByPath, oldKey)
}
}
s.setCacheLocked(link)
s.mu.Unlock()
}
// NewStorage creates a share links storage from a backend and populates the
// in-memory cache from the database so all reads can be served from cache.
// NewStorage creates a share links storage from a backend.
func NewStorage(back StorageBackend, usersStore *users.Storage) *Storage {
s := &Storage{
Generic: crud.NewStorage[Link](&crudBackend{back: back}),
back: back,
shareByHash: make(map[string]*Link),
shareByPath: make(map[string]map[string]string),
users: usersStore,
return &Storage{
Generic: crud.NewStorage[Link](&crudBackend{back: back}),
back: back,
shareCache: make(map[string]*Link),
users: usersStore,
}
s.loadShareCache()
return s
}
// loadShareCache fills shareByHash and shareByPath from the backend and removes expired links from the DB.
// Call once at startup so all reads can be served from cache.
func (s *Storage) loadShareCache() {
links, err := s.back.All()
if err != nil && err != errors.ErrNotExist {
return
}
if links == nil {
links = []*Link{}
}
s.mu.Lock()
for _, link := range links {
if link == nil {
continue
}
if link.Expire != 0 && link.Expire <= time.Now().Unix() && !link.KeepAfterExpiration {
_ = s.back.Delete(link.Hash)
continue
}
link.InitUserDownloads()
s.setCacheLocked(link)
}
s.mu.Unlock()
}
// LoadShareCacheFromDB repopulates the in-memory cache from the database.
// Call at startup (e.g. from InitializeDb) to ensure shares are loaded after the store is ready.
func (s *Storage) LoadShareCacheFromDB() {
s.loadShareCache()
}
// All returns all non-expired shares from the cache (populated at startup and by writes).
// All wraps StorageBackend.All and handles expiry.
func (s *Storage) All() ([]*Link, error) {
links, err := s.back.All()
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
result := make([]*Link, 0, len(s.shareByHash))
for _, l := range s.shareByHash {
for i, l := range filtered {
if l == nil {
continue
}
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
result = append(result, l)
}
s.mu.Unlock()
return result, nil
return filtered, nil
}
// FindByUserID returns non-expired shares for the user from the cache.
// FindByUserID wraps StorageBackend.FindByUserID and handles expiry.
func (s *Storage) FindByUserID(id uint) ([]*Link, error) {
links, err := s.back.FindByUserID(id)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
result := make([]*Link, 0)
for _, l := range s.shareByHash {
if l == nil || l.UserID != id {
for i, l := range filtered {
if l == nil {
continue
}
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
result = append(result, l)
}
s.mu.Unlock()
return result, nil
return filtered, nil
}
// GetByHash wraps StorageBackend.GetByHash and handles expiry.
func (s *Storage) GetByHash(hash string) (*Link, error) {
// return stable in-memory pointer if available
s.mu.RLock()
if link, ok := s.shareByHash[hash]; ok && link != nil {
if link, ok := s.shareCache[hash]; ok && link != nil {
s.mu.RUnlock()
if link.Expire != 0 && link.Expire <= time.Now().Unix() {
_ = s.back.Delete(hash)
s.deleteFromCache(hash)
s.mu.Lock()
delete(s.shareCache, hash)
s.mu.Unlock()
return nil, errors.ErrNotExist
}
return link, nil
@@ -247,7 +154,9 @@ func (s *Storage) GetByHash(hash string) (*Link, error) {
// Initialize UserDownloads map
link.InitUserDownloads()
s.setCache(link)
s.mu.Lock()
s.shareCache[hash] = link
s.mu.Unlock()
return link, nil
}
@@ -255,74 +164,68 @@ func (s *Storage) GetByHash(hash string) (*Link, error) {
func (s *Storage) GetPermanent(path, source string, id uint) (*Link, error) {
l, err := s.back.GetPermanent(path, source, id)
if err == nil && l != nil {
s.setCache(l)
s.mu.Lock()
s.shareCache[l.Hash] = l
s.mu.Unlock()
}
return l, err
}
// Gets returns shares for the given path, source, and user from the cache.
// Gets wraps StorageBackend.Gets and handles expiry.
func (s *Storage) Gets(sourcePath, source string, id uint) ([]*Link, error) {
s.mu.Lock()
adjustedPath := utils.AddTrailingSlashIfNotExists(sourcePath)
adjustedSource := utils.AddTrailingSlashIfNotExists(source)
key := pathKey(adjustedSource, adjustedPath)
hashes := s.shareByPath[key]
result := make([]*Link, 0, len(hashes))
for h := range hashes {
l := s.shareByHash[h]
if l == nil || l.UserID != id {
continue
}
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
}
result = append(result, l)
links, err := s.back.Gets(sourcePath, source, id)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Unlock()
return result, nil
}
// GetBySourcePath returns shares for the given path and source from the cache.
func (s *Storage) GetBySourcePath(path, source string) ([]*Link, error) {
s.mu.Lock()
adjustedPath := utils.AddTrailingSlashIfNotExists(path)
adjustedSource := utils.AddTrailingSlashIfNotExists(source)
key := pathKey(adjustedSource, adjustedPath)
hashes := s.shareByPath[key]
result := make([]*Link, 0, len(hashes))
for h := range hashes {
l := s.shareByHash[h]
for i, l := range filtered {
if l == nil {
continue
}
if l.Expire != 0 && l.Expire <= time.Now().Unix() && !l.KeepAfterExpiration {
_ = s.back.Delete(l.Hash)
s.deleteFromCacheLocked(l.Hash)
continue
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
result = append(result, l)
}
s.mu.Unlock()
return result, nil
return filtered, nil
}
// IsShared returns whether the given path and source have any shares in the cache.
func (s *Storage) IsShared(path, source string, id uint) bool {
links, _ := s.GetBySourcePath(path, source)
for _, l := range links {
if l.UserID == id {
return true
// GetBySourcePath wraps StorageBackend.GetBySourcePath and handles expiry.
func (s *Storage) GetBySourcePath(path, source string) ([]*Link, error) {
links, err := s.back.GetBySourcePath(path, source)
if err != nil {
return nil, err
}
filtered, err := s.filterExpired(links)
if err != nil {
return nil, err
}
s.mu.Lock()
for i, l := range filtered {
if l == nil {
continue
}
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
filtered[i] = existing
} else {
s.shareCache[l.Hash] = l
}
}
return len(links) > 0
s.mu.Unlock()
return filtered, nil
}
// UpdateShares updates all shares that match oldSource and oldPath to point to newSource and newPath.
// Handles both exact matches and subdirectories, regardless of trailing slashes.
func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (int, error) {
links, err := s.All()
links, err := s.back.All()
if err != nil && err != errors.ErrNotExist {
logger.Error("failed to list shares", "error", err)
return 0, err
@@ -344,6 +247,7 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
continue
}
oldFullPath := l.Path
l.Source = newSource
l.Path = newPath
@@ -352,9 +256,22 @@ func (s *Storage) UpdateShares(oldSource, oldPath, newSource, newPath string) (i
return updated, err
}
s.setCacheAfterMove(l, oldSource, oldPath)
s.mu.Lock()
if existing, ok := s.shareCache[l.Hash]; ok && existing != nil {
existing.Source = l.Source
existing.Path = l.Path
} else {
s.shareCache[l.Hash] = l
}
s.mu.Unlock()
logger.Info("share updated", "hash", l.Hash, "fromPath", oldFullPath, "toPath", l.Path)
updated++
}
if updated == 0 {
logger.Warning("no matching shares to update for provided source/path")
}
return updated, nil
}
@@ -373,7 +290,11 @@ func (s *Storage) UpdateSharePath(hash, newPath string) error {
return err
}
s.setCacheAfterMove(link, link.Source, oldPath)
s.mu.Lock()
if existing, ok := s.shareCache[hash]; ok && existing != nil {
existing.Path = newPath
}
s.mu.Unlock()
logger.Debug("share path updated", "hash", hash, "fromPath", oldPath, "toPath", newPath)
return nil
@@ -384,7 +305,9 @@ func (s *Storage) Save(l *Link) error {
if err := s.back.Save(l); err != nil {
return err
}
s.setCache(l)
s.mu.Lock()
s.shareCache[l.Hash] = l
s.mu.Unlock()
return nil
}
@@ -393,16 +316,47 @@ func (s *Storage) Delete(hash string) error {
if err := s.back.Delete(hash); err != nil {
return err
}
s.deleteFromCache(hash)
s.mu.Lock()
delete(s.shareCache, hash)
s.mu.Unlock()
return nil
}
// Flush is a no-op: every share write (Save, Delete, UpdateShares, UpdateSharePath) already
// updates the database, so the cache and DB stay in sync without flushing.
// Flush persists the current in-memory state of all shares to the backing store.
// Call during graceful shutdown to ensure DB matches memory.
func (s *Storage) Flush() error {
s.mu.RLock()
links := make([]*Link, 0, len(s.shareCache))
for _, l := range s.shareCache {
if l != nil {
links = append(links, l)
}
}
s.mu.RUnlock()
for _, l := range links {
if err := s.back.Save(l); err != nil {
return err
}
}
return nil
}
// filterExpired removes expired links and deletes them from storage.
func (s *Storage) filterExpired(links []*Link) ([]*Link, error) {
var filtered []*Link
for _, link := range links {
if link.Expire != 0 && link.Expire <= time.Now().Unix() && !link.KeepAfterExpiration {
_ = s.back.Delete(link.Hash)
s.mu.Lock()
delete(s.shareCache, link.Hash)
s.mu.Unlock()
continue
}
filtered = append(filtered, link)
}
return filtered, nil
}
// GetCommonShareByHash retrieves only the CommonShare attributes for a given hash
func (s *Storage) GetCommonShareByHash(hash string) (*CommonShare, error) {
return s.back.GetCommonShareByHash(hash)
-10
View File
@@ -1,10 +0,0 @@
//go:build cgosql
// +build cgosql
package sql
import (
_ "github.com/mattn/go-sqlite3"
)
const SqliteDriver = "sqlite3"
-10
View File
@@ -1,10 +0,0 @@
//go:build !cgosql
// +build !cgosql
package sql
import (
_ "modernc.org/sqlite"
)
const SqliteDriver = "sqlite"
-121
View File
@@ -1,121 +0,0 @@
package sql
import (
"database/sql"
"fmt"
)
// FileLocation represents a file location in the index with metadata
// needed for duplicate detection operations.
type FileLocation struct {
DirPath string
FileIdx int
Name string
NormalizedName string
Extension string
}
// CreateDuplicatesTable creates the files table and indexes needed for duplicate detection.
// This should be called once after creating a TempDB for duplicate operations.
// Indexes are created before data insertion so they're immediately available for queries.
// For small datasets (typical for duplicate detection), the performance difference is negligible.
func (t *TempDB) CreateDuplicatesTable() error {
createTableSQL := `
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dir_path TEXT NOT NULL,
file_idx INTEGER NOT NULL,
size INTEGER NOT NULL,
name TEXT NOT NULL,
normalized_name TEXT NOT NULL,
extension TEXT NOT NULL,
UNIQUE(dir_path, file_idx)
);
CREATE INDEX IF NOT EXISTS idx_size ON files(size);
CREATE INDEX IF NOT EXISTS idx_size_count ON files(size, normalized_name);
`
_, err := t.Exec(createTableSQL)
return err
}
// InsertFileForDuplicates inserts a file entry into the duplicates table.
// This is called during the first pass through the index to stream files into the database.
func (t *TempDB) InsertFileForDuplicates(dirPath string, fileIdx int, size int64, name, normalizedName, extension string) error {
_, err := t.Exec(
"INSERT OR IGNORE INTO files (dir_path, file_idx, size, name, normalized_name, extension) VALUES (?, ?, ?, ?, ?, ?)",
dirPath, fileIdx, size, name, normalizedName, extension,
)
return err
}
// GetSizeGroupsForDuplicates queries for all size groups that have 2+ files.
// Returns sizes in descending order (largest first) as a slice, and a map of size -> count.
// This is used to identify potential duplicate groups before detailed comparison.
// The SQL query efficiently filters and sorts, avoiding the need to create intermediate maps.
func (t *TempDB) GetSizeGroupsForDuplicates(minSize int64) ([]int64, map[int64]int, error) {
// Query to get sizes with 2+ files, sorted by size DESC
rows, err := t.Query(`
SELECT size, COUNT(*) as count
FROM files
WHERE size >= ?
GROUP BY size
HAVING COUNT(*) >= 2
ORDER BY size DESC
`, minSize)
if err != nil {
return nil, nil, fmt.Errorf("failed to query size groups: %w", err)
}
defer rows.Close()
var sizes []int64
sizeCounts := make(map[int64]int)
for rows.Next() {
var size int64
var count int
if err := rows.Scan(&size, &count); err != nil {
return nil, nil, fmt.Errorf("failed to scan row: %w", err)
}
sizes = append(sizes, size)
sizeCounts[size] = count
}
return sizes, sizeCounts, rows.Err()
}
// GetFilesBySizeForDuplicates queries for all files with a specific size.
// Used for processing one size group at a time to minimize memory usage.
func (t *TempDB) GetFilesBySizeForDuplicates(size int64) ([]FileLocation, error) {
rows, err := t.Query(`
SELECT dir_path, file_idx, name, normalized_name, extension
FROM files
WHERE size = ?
ORDER BY normalized_name
`, size)
if err != nil {
return nil, fmt.Errorf("failed to query files by size: %w", err)
}
defer rows.Close()
var locations []FileLocation
for rows.Next() {
var loc FileLocation
if err := rows.Scan(&loc.DirPath, &loc.FileIdx, &loc.Name, &loc.NormalizedName, &loc.Extension); err != nil {
return nil, fmt.Errorf("failed to scan row: %w", err)
}
locations = append(locations, loc)
}
return locations, rows.Err()
}
// BulkInsertFilesForDuplicates inserts multiple files in a single transaction.
// This is more efficient than calling InsertFileForDuplicates multiple times.
// The transaction must be started by the caller using BeginTransaction().
func BulkInsertFilesForDuplicates(tx *sql.Tx, dirPath string, fileIdx int, size int64, name, normalizedName, extension string) error {
_, err := tx.Exec(
"INSERT OR IGNORE INTO files (dir_path, file_idx, size, name, normalized_name, extension) VALUES (?, ?, ?, ?, ?, ?)",
dirPath, fileIdx, size, name, normalizedName, extension,
)
return err
}
File diff suppressed because it is too large. Load diff
-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()
}
-103
View File
@@ -1,103 +0,0 @@
package sql
import (
"database/sql"
"fmt"
"strings"
)
// SearchItem represents a single search result row from the database
type SearchItem struct {
Source string
Path string
Name string
Size int64
ModTime int64
Type string
IsDir bool
HasPreview bool
}
// SearchItems queries the database for items matching the search criteria for a single source.
// Returns rows that can be iterated to scan search results.
func (db *IndexDB) SearchItems(source string, scope string, largest bool) (*sql.Rows, error) {
query := `
SELECT path, name, size, mod_time, type, is_dir, has_preview
FROM index_items
WHERE source = ?
`
args := []interface{}{source}
// Apply scope filter
if scope != "" {
// Use GLOB for prefix matching which is supported by SQLite and efficient
query += " AND path GLOB ?"
args = append(args, scope+"*")
}
if largest {
query += " ORDER BY size DESC"
}
return db.Query(query, args...)
}
// SearchItemsMultiSource queries the database for items matching the search criteria across multiple sources.
// Returns rows that can be iterated to scan search results.
func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[string]string, largest bool) (*sql.Rows, error) {
if len(sources) == 0 {
return nil, fmt.Errorf("at least one source is required")
}
query := `
SELECT source, path, name, size, mod_time, type, is_dir, has_preview
FROM index_items
`
args := []interface{}{}
whereClauses := []string{}
// Filter by sources using IN clause
if len(sources) == 1 {
whereClauses = append(whereClauses, "source = ?")
args = append(args, sources[0])
} else {
placeholders := make([]string, len(sources))
for i, source := range sources {
placeholders[i] = "?"
args = append(args, source)
}
whereClauses = append(whereClauses, "source IN ("+strings.Join(placeholders, ",")+")")
}
// Apply scope filters - need OR conditions for each source+scope combination
// Build conditions for each source: if it has a scope, filter by it; otherwise include all paths for that source
scopeConditions := []string{}
for _, source := range sources {
scope, hasScope := sourceScopes[source]
if hasScope && scope != "" {
// Source has a scope - filter by it
scopeConditions = append(scopeConditions, "(source = ? AND path GLOB ?)")
args = append(args, source, scope+"*")
} else {
// Source has no scope or empty scope - include all paths for this source
// The source IN clause already filters by source, so we just need to ensure
// this source is included in the OR condition (which it already is via source IN)
// We add a simple source check to make the OR condition work correctly
scopeConditions = append(scopeConditions, "source = ?")
args = append(args, source)
}
}
if len(scopeConditions) > 0 {
whereClauses = append(whereClauses, "("+strings.Join(scopeConditions, " OR ")+")")
}
if len(whereClauses) > 0 {
query += " WHERE " + strings.Join(whereClauses, " AND ")
}
if largest {
query += " ORDER BY size DESC"
}
return db.Query(query, args...)
}
-565
View File
@@ -1,565 +0,0 @@
package sql
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/fileutils"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/go-logger/logger"
// SQLite driver is imported in driver_cgo.go or driver_nocgo.go based on build tags
)
const (
// soft heap limit in bytes
defaultSoftHeapLimitBytes = 32 * 1024 * 1024 // 32MB
// Pager cache target expressed as number of 4KB pages (negative = pages).
defaultCacheSizePages = -4096 // ~16MB
)
func init() {
if SqliteDriver == "sqlite3" {
logger.Debugf("CGO SQLite driver initialized")
} else {
logger.Debugf("Default SQLite driver initialized")
}
}
// TempDB manages a temporary SQLite database for operations that need
// to stream large datasets without loading everything into memory.
// This can be used by any part of the codebase that needs temporary SQLite storage.
type TempDB struct {
db *sql.DB
path string
mu sync.Mutex
startTime time.Time
config *TempDBConfig
BatchSize int // Exported batch size for bulk operations (from config)
}
// TempDBConfig holds configuration options for temporary SQLite databases.
type TempDBConfig struct {
// BatchSize is the number of items to batch for bulk insert.
// Default: 2500
BatchSize int
// CacheSizeKB is the page cache size in KB. Negative values are in pages.
// For one-time databases, a smaller cache (e.g., -4096 = ~16MB) is often sufficient.
// Default: -4096 (approximately 16MB)
CacheSizeKB int
// MmapSize is the memory-mapped I/O size in bytes. Set to 0 to disable mmap.
// For databases that fit in RAM, set this larger than the expected DB size.
// Default: 0 (disabled)
MmapSize int64
// Synchronous controls the synchronous mode. OFF is fastest but less safe.
// For temporary databases, OFF is acceptable.
// Default: OFF
Synchronous string
// TempStore controls where temporary tables and indices are stored.
// Valid values: "FILE" (default), "MEMORY", "DEFAULT"
// Default: FILE (temporary tables stored on disk)
TempStore string
// JournalMode controls the journal mode. WAL is better for concurrent reads/writes.
// DELETE is faster for write-heavy single-writer workloads.
// Valid values: "DELETE", "WAL", "TRUNCATE", "PERSIST", "MEMORY", "OFF"
// Default: WAL
JournalMode string
// LockingMode controls the locking mode. EXCLUSIVE prevents other processes from accessing.
// Valid values: "NORMAL", "EXCLUSIVE"
// Default: NORMAL
LockingMode string
// PageSize sets the database page size in bytes. Larger pages improve write performance.
// Valid values: 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536
// Default: 4096 (SQLite default)
PageSize int
// AutoVacuum controls automatic vacuuming. NONE disables it for better write performance.
// Valid values: "NONE", "FULL", "INCREMENTAL"
// Default: NONE
AutoVacuum string
// SoftHeapLimitBytes, when >0, sets PRAGMA soft_heap_limit so that SQLite
// proactively frees caches before exceeding this many bytes. Zero disables it.
SoftHeapLimitBytes int64
// HardHeapLimitBytes, when >0, sets PRAGMA hard_heap_limit as a hard cap on SQLite's heap.
// Operations will fail if they would exceed this limit. Zero disables it.
HardHeapLimitBytes int64
// CacheSpillThreshold sets PRAGMA cache_spill threshold in pages. When cache exceeds both
// this threshold and cache_size, SQLite will spill dirty pages to disk.
// Set to 0 to use default behavior (spill enabled but no specific threshold).
// Default: 500 pages (~2MB) to trigger early spilling and reduce memory usage.
CacheSpillThreshold int
// WalAutocheckpoint sets PRAGMA wal_autocheckpoint in pages. Controls how many pages
// can accumulate in the WAL file before an automatic checkpoint.
// Default: 1000 pages (~4MB). Larger values reduce checkpoint frequency.
WalAutocheckpoint int
// JournalSizeLimit sets PRAGMA journal_size_limit in bytes. Limits the maximum size
// of the WAL journal file. Set to -1 for no limit, 0 for default.
// Default: 0 (uses SQLite default)
JournalSizeLimit int64
// If empty, a temp file with random suffix will be created.
// The file will not be deleted on Close() if this is set.
PersistentFile string
}
// mergeConfig merges the provided config with defaults, returning a new config.
// If provided config is nil or empty, returns default config.
func mergeConfig(provided *TempDBConfig) *TempDBConfig {
defaults := &TempDBConfig{
BatchSize: 2500,
CacheSizeKB: defaultCacheSizePages, // ~16MB pager cache
MmapSize: 0, // Disable mmap to keep RSS predictable
Synchronous: "OFF",
TempStore: "FILE", // Default to FILE, not MEMORY
JournalMode: "WAL", // WAL for better concurrency by default
LockingMode: "NORMAL",
PageSize: 4096, // SQLite default
AutoVacuum: "NONE",
// Soft heap limit keeps SQLite from retaining excessive pager cache memory.
SoftHeapLimitBytes: defaultSoftHeapLimitBytes,
// Hard heap limit provides a hard cap - operations fail if exceeded.
HardHeapLimitBytes: 0, // Disabled by default, can be set per-database
// Cache spill threshold triggers early spilling to reduce memory usage.
CacheSpillThreshold: 500, // ~2MB threshold to trigger early spilling
}
if provided == nil {
return defaults
}
merged := *defaults // Copy defaults
// Override with provided values if they are non-zero/non-empty
if provided.BatchSize != 0 {
merged.BatchSize = provided.BatchSize
}
if provided.CacheSizeKB != 0 {
merged.CacheSizeKB = provided.CacheSizeKB
}
if provided.MmapSize != 0 {
merged.MmapSize = provided.MmapSize
}
if provided.Synchronous != "" {
merged.Synchronous = provided.Synchronous
}
if provided.TempStore != "" {
merged.TempStore = provided.TempStore
}
if provided.JournalMode != "" {
merged.JournalMode = provided.JournalMode
}
if provided.LockingMode != "" {
merged.LockingMode = provided.LockingMode
}
if provided.PageSize != 0 {
merged.PageSize = provided.PageSize
}
if provided.AutoVacuum != "" {
merged.AutoVacuum = provided.AutoVacuum
}
if provided.HardHeapLimitBytes != 0 {
merged.HardHeapLimitBytes = provided.HardHeapLimitBytes
}
if provided.CacheSpillThreshold != 0 {
merged.CacheSpillThreshold = provided.CacheSpillThreshold
}
// SoftHeapLimitBytes: use provided value if non-zero (default is 32MB, but provided might be different)
if provided.SoftHeapLimitBytes != 0 {
merged.SoftHeapLimitBytes = provided.SoftHeapLimitBytes
}
// PersistentFile: always copy if non-empty (this determines if file is persistent or temp)
if provided.PersistentFile != "" {
merged.PersistentFile = provided.PersistentFile
}
return &merged
}
// NewTempDB creates a new temporary SQLite database.
// The database is created in the cache directory's sql/ subdirectory.
// The database will be cleaned up on Close().
func NewTempDB(id string, config ...*TempDBConfig) (*TempDB, error) {
startTime := time.Now()
// Merge provided config with defaults
var providedConfig *TempDBConfig
if len(config) > 0 {
providedConfig = config[0]
}
cfg := mergeConfig(providedConfig)
// Create sql subdirectory in the cache directory
dbDir := filepath.Join(settings.Config.Server.CacheDir, "sql")
if err := os.MkdirAll(dbDir, fileutils.PermDir); err != nil {
return nil, fmt.Errorf("failed to create sql directory: %w", err)
}
// Determine database file path
var tmpPath string
if cfg.PersistentFile != "" {
// Use fixed filename for persistent databases
tmpPath = filepath.Join(dbDir, cfg.PersistentFile)
// File might already exist (persistent), that's fine
} else {
// Create temporary file with random suffix
tmpFile, err := os.CreateTemp(dbDir, fmt.Sprintf("%s.db", id))
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
}
tmpPath = tmpFile.Name()
tmpFile.Close()
}
// Open SQLite database with basic connection string
// Driver is selected at compile time: "sqlite3" (CGO) or "sqlite" (pure Go)
db, err := sql.Open(SqliteDriver, tmpPath)
if err != nil {
os.Remove(tmpPath)
return nil, fmt.Errorf("failed to open SQLite database: %w", err)
}
// Test the connection
if err := db.Ping(); err != nil {
db.Close()
os.Remove(tmpPath)
return nil, fmt.Errorf("failed to ping SQLite database: %w", err)
}
// Limit connection pool to 1 for SQLite - it's a file-based database and multiple connections
// can cause locking issues. With busy_timeout, SQLite will queue operations automatically.
if cfg.LockingMode == "EXCLUSIVE" {
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
}
// Calculate cache_size in pages (negative value) to ensure consistent unit handling
// SQLite's cache_size: positive = KB, negative = pages
// When using positive KB values, SQLite seems to have issues with cache_spill being less than cache_size
// So we convert to pages (negative) to ensure both cache_size and cache_spill use the same units
cacheSizeInPages := cfg.CacheSizeKB
if cfg.CacheSizeKB > 0 {
// Convert KB to pages: KB / 4 = pages, then negate to indicate pages
cacheSizeInPages = -(cfg.CacheSizeKB / 4)
}
basePragmas := []struct {
sql string
err string
}{
{fmt.Sprintf("PRAGMA journal_mode = %s;", cfg.JournalMode), "failed to set journal_mode"},
{fmt.Sprintf("PRAGMA cache_size = %d;", cacheSizeInPages), "failed to set cache_size"},
{fmt.Sprintf("PRAGMA synchronous = %s;", cfg.Synchronous), "failed to set synchronous"},
{fmt.Sprintf("PRAGMA temp_store = %s;", cfg.TempStore), "failed to set temp_store"},
{fmt.Sprintf("PRAGMA locking_mode = %s;", cfg.LockingMode), "failed to set locking_mode"},
{fmt.Sprintf("PRAGMA auto_vacuum = %s;", cfg.AutoVacuum), "failed to set auto_vacuum"},
}
// Memory management PRAGMAs - set early to constrain memory usage
if cfg.SoftHeapLimitBytes > 0 {
basePragmas = append([]struct {
sql string
err string
}{{fmt.Sprintf("PRAGMA soft_heap_limit = %d;", cfg.SoftHeapLimitBytes), "failed to set soft_heap_limit"}}, basePragmas...)
}
if cfg.HardHeapLimitBytes > 0 {
basePragmas = append([]struct {
sql string
err string
}{{fmt.Sprintf("PRAGMA hard_heap_limit = %d;", cfg.HardHeapLimitBytes), "failed to set hard_heap_limit"}}, basePragmas...)
}
// Cache spill will be set later, after all other pragmas, to avoid interaction issues
// Page size must be set before any tables are created
if cfg.PageSize > 0 {
basePragmas = append([]struct {
sql string
err string
}{{fmt.Sprintf("PRAGMA page_size = %d;", cfg.PageSize), "failed to set page_size"}}, basePragmas...)
}
// Always explicitly set mmap_size to ensure it's disabled (0) or enabled as configured
// Setting it to 0 explicitly disables memory-mapped I/O, which helps reduce OS page cache usage
basePragmas = append(basePragmas, struct {
sql string
err string
}{fmt.Sprintf("PRAGMA mmap_size = %d;", cfg.MmapSize), "failed to set mmap_size"})
// WAL autocheckpoint - controls checkpoint frequency in WAL mode
if cfg.WalAutocheckpoint > 0 {
basePragmas = append(basePragmas, struct {
sql string
err string
}{fmt.Sprintf("PRAGMA wal_autocheckpoint = %d;", cfg.WalAutocheckpoint), "failed to set wal_autocheckpoint"})
}
// Journal size limit - limits WAL file growth
if cfg.JournalSizeLimit > 0 {
basePragmas = append(basePragmas, struct {
sql string
err string
}{fmt.Sprintf("PRAGMA journal_size_limit = %d;", cfg.JournalSizeLimit), "failed to set journal_size_limit"})
} else if cfg.JournalSizeLimit == -1 {
// -1 means no limit
basePragmas = append(basePragmas, struct {
sql string
err string
}{"PRAGMA journal_size_limit = -1;", "failed to set journal_size_limit"})
}
for _, pragma := range basePragmas {
if _, err := db.Exec(pragma.sql); err != nil {
db.Close()
os.Remove(tmpPath)
return nil, fmt.Errorf("%s: %w", pragma.err, err)
}
}
// Set cache_spill LAST, after all other pragmas are configured
// SQLite may adjust cache_spill based on internal constraints or formulas
if cfg.CacheSpillThreshold > 0 {
// Try setting cache_spill multiple times with different approaches
// Approach 1: Standard syntax
if _, err := db.Exec(fmt.Sprintf("PRAGMA cache_spill = %d;", cfg.CacheSpillThreshold)); err != nil {
logger.Warningf("[SQLITE_CONFIG] Failed to set cache_spill with standard syntax: %v", err)
}
// Immediately verify what value was actually set
var actualValue int64
if err := db.QueryRow("PRAGMA cache_spill").Scan(&actualValue); err == nil {
if actualValue != int64(cfg.CacheSpillThreshold) {
// SQLite adjusted our value - calculate what percentage it used
percentage := float64(actualValue) / float64(-cacheSizeInPages) * 100.0
logger.Warningf("[SQLITE_CONFIG] SQLite adjusted cache_spill from %d to %d pages (%.1f%% of cache_size). This may be due to internal constraints.",
cfg.CacheSpillThreshold, actualValue, percentage)
}
}
} else {
// Enable cache_spill with default behavior
if _, err := db.Exec("PRAGMA cache_spill = 1;"); err != nil {
logger.Warningf("[SQLITE_CONFIG] Failed to enable cache_spill: %v", err)
}
}
// Verify PRAGMA settings were applied correctly
if err := verifyPragmaSettings(db, cfg); err != nil {
logger.Warningf("Failed to verify PRAGMA settings: %v", err)
}
return &TempDB{
db: db,
path: tmpPath,
startTime: startTime,
config: cfg,
BatchSize: cfg.BatchSize, // Export batch size for external packages
}, nil
}
// DB returns the underlying *sql.DB connection.
// This allows callers to execute custom SQL queries if needed.
func (t *TempDB) DB() *sql.DB {
return t.db
}
// BeginTransaction starts a transaction for bulk operations.
// The caller must call Commit() or Rollback() on the returned transaction.
// IMPORTANT: The mutex is acquired here and MUST be released by calling
// t.mu.Unlock() after commit/rollback to ensure exclusive transaction access.
func (t *TempDB) BeginTransaction() (*sql.Tx, error) {
t.mu.Lock()
// NOTE: Mutex is NOT unlocked here! Caller must unlock after commit/rollback
return t.db.Begin()
}
// Exec executes a SQL statement that doesn't return rows.
// This is a convenience method that handles locking.
func (t *TempDB) Exec(query string, args ...interface{}) (sql.Result, error) {
t.mu.Lock()
defer t.mu.Unlock()
return t.db.Exec(query, args...)
}
// Query executes a query that returns rows.
// This is a convenience method that handles locking.
func (t *TempDB) Query(query string, args ...interface{}) (*sql.Rows, error) {
t.mu.Lock()
defer t.mu.Unlock()
return t.db.Query(query, args...)
}
// QueryRow executes a query that is expected to return at most one row.
// This is a convenience method that handles locking.
func (t *TempDB) QueryRow(query string, args ...interface{}) *sql.Row {
t.mu.Lock()
defer t.mu.Unlock()
return t.db.QueryRow(query, args...)
}
// Close closes the database connection and removes the temporary file if it is not persistent.
func (t *TempDB) Close() error {
t.mu.Lock()
defer t.mu.Unlock()
if t.db != nil {
if err := t.db.Close(); err != nil {
// Only remove file if it's not persistent
if t.config != nil && t.config.PersistentFile == "" {
os.Remove(t.path)
}
return err
}
}
// Only remove file if it's not persistent
if t.config != nil && t.config.PersistentFile == "" {
return os.Remove(t.path)
}
return nil
}
// verifyPragmaSettings reads back PRAGMA values to verify they were applied correctly
func verifyPragmaSettings(db *sql.DB, cfg *TempDBConfig) error {
var cacheSize, mmapSize, softHeapLimit, hardHeapLimit, cacheSpill int64
var journalMode, synchronous, tempStore, lockingMode, autoVacuum string
// Read back PRAGMA values
if err := db.QueryRow("PRAGMA cache_size").Scan(&cacheSize); err != nil {
return fmt.Errorf("failed to read cache_size: %w", err)
}
if err := db.QueryRow("PRAGMA mmap_size").Scan(&mmapSize); err != nil {
return fmt.Errorf("failed to read mmap_size: %w", err)
}
if err := db.QueryRow("PRAGMA soft_heap_limit").Scan(&softHeapLimit); err != nil {
return fmt.Errorf("failed to read soft_heap_limit: %w", err)
}
if err := db.QueryRow("PRAGMA hard_heap_limit").Scan(&hardHeapLimit); err != nil {
return fmt.Errorf("failed to read hard_heap_limit: %w", err)
}
if err := db.QueryRow("PRAGMA cache_spill").Scan(&cacheSpill); err != nil {
return fmt.Errorf("failed to read cache_spill: %w", err)
}
if err := db.QueryRow("PRAGMA journal_mode").Scan(&journalMode); err != nil {
return fmt.Errorf("failed to read journal_mode: %w", err)
}
if err := db.QueryRow("PRAGMA synchronous").Scan(&synchronous); err != nil {
return fmt.Errorf("failed to read synchronous: %w", err)
}
if err := db.QueryRow("PRAGMA temp_store").Scan(&tempStore); err != nil {
return fmt.Errorf("failed to read temp_store: %w", err)
}
if err := db.QueryRow("PRAGMA locking_mode").Scan(&lockingMode); err != nil {
return fmt.Errorf("failed to read locking_mode: %w", err)
}
if err := db.QueryRow("PRAGMA auto_vacuum").Scan(&autoVacuum); err != nil {
return fmt.Errorf("failed to read auto_vacuum: %w", err)
}
// Convert cache_size from pages to KB (negative = pages, positive = KB)
cacheSizeKB := cacheSize
if cacheSize < 0 {
cacheSizeKB = -cacheSize * 4 // pages * 4KB per page
}
// Expected cache_spill value in pages (cache_spill is always specified and returned in pages)
expectedCacheSpill := int64(cfg.CacheSpillThreshold)
// Convert numeric PRAGMA values to human-readable strings
tempStoreMap := map[string]string{"0": "DEFAULT", "1": "FILE", "2": "MEMORY"}
synchronousMap := map[string]string{"0": "OFF", "1": "NORMAL", "2": "FULL", "3": "EXTRA"}
autoVacuumMap := map[string]string{"0": "NONE", "1": "FULL", "2": "INCREMENTAL"}
tempStoreStr := tempStoreMap[tempStore]
if tempStoreStr == "" {
tempStoreStr = tempStore
}
synchronousStr := synchronousMap[synchronous]
if synchronousStr == "" {
synchronousStr = synchronous
}
autoVacuumStr := autoVacuumMap[autoVacuum]
if autoVacuumStr == "" {
autoVacuumStr = autoVacuum
}
// Log all settings
logger.Debugf("[SQLITE_CONFIG] Database PRAGMA settings verified:")
logger.Debugf(" cache_size : %d KB", cacheSizeKB)
logger.Debugf(" mmap_size : %d bytes", mmapSize)
logger.Debugf(" soft_heap_limit : %d bytes", softHeapLimit)
logger.Debugf(" hard_heap_limit : %d bytes", hardHeapLimit)
logger.Debugf(" cache_spill : %d pages", cacheSpill)
logger.Debugf(" journal_mode : %s", journalMode)
logger.Debugf(" synchronous : %s", synchronousStr)
logger.Debugf(" temp_store : %s", tempStoreStr)
logger.Debugf(" locking_mode : %s", lockingMode)
logger.Debugf(" auto_vacuum : %s", autoVacuumStr)
// Verify critical settings match
if cfg.CacheSizeKB != 0 {
expectedCache := int64(cfg.CacheSizeKB)
if expectedCache < 0 {
expectedCache = -expectedCache * 4 // Convert pages to KB
}
if cacheSizeKB != expectedCache {
logger.Warningf("[SQLITE_CONFIG] cache_size mismatch: got %d KB, expected %d KB", cacheSizeKB, expectedCache)
}
}
if cfg.MmapSize > 0 && mmapSize != cfg.MmapSize {
logger.Warningf("[SQLITE_CONFIG] mmap_size mismatch: got %d bytes, expected %d bytes", mmapSize, cfg.MmapSize)
}
if cfg.SoftHeapLimitBytes > 0 && softHeapLimit != cfg.SoftHeapLimitBytes {
logger.Warningf("[SQLITE_CONFIG] soft_heap_limit mismatch: got %d bytes, expected %d bytes", softHeapLimit, cfg.SoftHeapLimitBytes)
}
if cfg.HardHeapLimitBytes > 0 && hardHeapLimit != cfg.HardHeapLimitBytes {
logger.Warningf("[SQLITE_CONFIG] hard_heap_limit mismatch: got %d bytes, expected %d bytes", hardHeapLimit, cfg.HardHeapLimitBytes)
}
// Verify cache_spill matches expected value (accounting for unit conversion)
if cfg.CacheSpillThreshold > 0 && cacheSpill != expectedCacheSpill {
logger.Warningf("[SQLITE_CONFIG] cache_spill mismatch: got %d pages, expected %d pages (from threshold %d pages)", cacheSpill, expectedCacheSpill, cfg.CacheSpillThreshold)
// If cache_spill equals cache_size in pages, it means SQLite ignored our setting
var cacheSizePages int64
if cfg.CacheSizeKB > 0 {
cacheSizePages = int64(cfg.CacheSizeKB) / 4 // KB to pages
} else {
cacheSizePages = -int64(cfg.CacheSizeKB) // Already in pages (negative)
}
if cacheSpill == cacheSizePages {
logger.Warningf("[SQLITE_CONFIG] cache_spill equals cache_size (%d pages), indicating SQLite may have ignored our setting", cacheSizePages)
}
}
// Verify string-based PRAGMA settings match (using converted values)
if synchronousStr != cfg.Synchronous {
logger.Warningf("[SQLITE_CONFIG] synchronous mismatch: got %s, expected %s", synchronousStr, cfg.Synchronous)
}
if tempStoreStr != cfg.TempStore {
logger.Warningf("[SQLITE_CONFIG] temp_store mismatch: got %s, expected %s", tempStoreStr, cfg.TempStore)
}
if autoVacuumStr != cfg.AutoVacuum {
logger.Warningf("[SQLITE_CONFIG] auto_vacuum mismatch: got %s, expected %s", autoVacuumStr, cfg.AutoVacuum)
}
return nil
}
// Path returns the path to the temporary database file.
// This is useful for debugging or if you need to inspect the database.
func (t *TempDB) Path() string {
return t.path
}
-3
View File
@@ -6,7 +6,6 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/database/access"
"github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
"github.com/gtsteffaniak/filebrowser/backend/database/share"
"github.com/gtsteffaniak/filebrowser/backend/database/users"
)
@@ -19,7 +18,6 @@ type BoltStore struct {
Auth *auth.Storage
Settings *settings.Storage
Access *access.Storage
Indexing *dbindex.Storage
}
// NewStorage creates a storage.Storage based on Bolt DB.
@@ -35,6 +33,5 @@ func NewStorage(db *storm.DB) (*BoltStore, error) {
Auth: authStore,
Settings: settings.NewStorage(settingsBackend{db: db}),
Access: access.NewStorage(db, userStore),
Indexing: dbindex.NewStorage(indexingBackend{db: db}),
}, nil
}
-55
View File
@@ -1,55 +0,0 @@
package bolt
import (
storm "github.com/asdine/storm/v3"
"github.com/asdine/storm/v3/q"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/database/dbindex"
)
type indexingBackend struct {
db *storm.DB
}
func (s indexingBackend) All() ([]*dbindex.IndexInfo, error) {
var v []*dbindex.IndexInfo
err := s.db.All(&v)
if err == storm.ErrNotFound {
return v, errors.ErrNotExist
}
return v, err
}
func (s indexingBackend) GetByPath(path string) (*dbindex.IndexInfo, error) {
var v dbindex.IndexInfo
err := s.db.One("Path", path, &v)
if err == storm.ErrNotFound {
return nil, errors.ErrNotExist
}
return &v, err
}
func (s indexingBackend) GetBySource(source string) ([]*dbindex.IndexInfo, error) {
var v []*dbindex.IndexInfo
err := s.db.Select(q.Eq("Source", source)).Find(&v)
if err == storm.ErrNotFound {
return nil, errors.ErrNotExist
}
return v, err
}
func (s indexingBackend) Save(info *dbindex.IndexInfo) error {
return s.db.Save(info)
}
func (s indexingBackend) Delete(path string) error {
err := s.db.DeleteStruct(&dbindex.IndexInfo{Path: path})
if err == storm.ErrNotFound {
return nil
}
return err
}
+22 -26
View File
@@ -9,6 +9,7 @@ import (
storm "github.com/asdine/storm/v3"
"github.com/gtsteffaniak/filebrowser/backend/adapters/fs/files"
"github.com/gtsteffaniak/filebrowser/backend/auth"
"github.com/gtsteffaniak/filebrowser/backend/common/errors"
"github.com/gtsteffaniak/filebrowser/backend/common/settings"
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
@@ -74,6 +75,11 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
return err
}
passwordUser := existingUser.LoginMethod == users.LoginMethodPassword
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
if passwordUser && enforcedOtp && !user.OtpEnabled {
return errors.ErrNoTotpConfigured
}
if user.LoginMethod == "" {
user.LoginMethod = existingUser.LoginMethod
}
@@ -81,21 +87,11 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
if err != nil {
return err
}
enforcedOtp := settings.Config.Auth.Methods.PasswordAuth.EnforcedOtp
if user.LoginMethod == "" {
user.LoginMethod = existingUser.LoginMethod
}
if user.TOTPNonce == "" {
user.TOTPNonce = existingUser.TOTPNonce
}
if user.TOTPSecret == "" {
user.TOTPSecret = existingUser.TOTPSecret
}
if user.LoginMethod == users.LoginMethodPassword && enforcedOtp && !user.OtpEnabled {
if slices.Contains(fields, "OtpEnabled") {
return errors.ErrNoTotpConfigured
}
}
if !slices.Contains(fields, "Password") {
user.Password = existingUser.Password
} else {
@@ -114,25 +110,19 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
// converting scopes to map of paths intead of names (names can change)
if slices.Contains(fields, "Scopes") {
adjustedScopes, err := user.GetBackendScopes()
adjustedScopes, err := settings.ConvertToBackendScopes(user.Scopes)
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
}
}
// converting scopes to map of paths intead of names (names can change)
if slices.Contains(fields, "SidebarLinks") {
adjustedLinks, err := user.GetBackendSidebarLinks()
adjustedLinks, err := settings.ConvertToBackendSidebarLinks(user.SidebarLinks)
if err != nil {
return err
}
@@ -168,6 +158,12 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
}
}
// last revoke api keys if needed.
if existingUser.Permissions.Api && !user.Permissions.Api && slices.Contains(fields, "Permissions") {
for _, key := range existingUser.ApiKeys {
auth.RevokeAPIKey(key.Key) // add to blacklist
}
}
return nil
}
@@ -184,7 +180,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,12 +188,12 @@ func (st usersBackend) Save(user *users.User, changePass, disableScopeChange boo
}
// converting scopes to map of paths intead of names (names can change)
adjustedScopes, err := user.GetBackendScopes()
adjustedScopes, err := settings.ConvertToBackendScopes(user.Scopes)
if err != nil {
return err
}
user.Scopes = adjustedScopes
err = files.MakeUserDirs(user, true)
err = files.MakeUserDirs(user, disableScopeChange)
if err != nil {
logger.Error(err.Error())
}
@@ -261,7 +257,7 @@ func parseFields(user *users.User, fields []string, actorIsAdmin bool) ([]string
field := t.Field(i)
// which=all can't update password
switch strings.ToLower(field.Name) {
case "id", "username", "password", "tokens", "totpsecret", "totpnonce":
case "id", "username", "password", "apikeys", "totpsecret", "totpnonce":
// Skip these fields
continue
}
@@ -303,7 +299,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())
}
@@ -5,36 +5,9 @@ import (
"testing"
"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
// Initialize test source configuration
settings.Config.Server.SourceMap = map[string]*settings.Source{
"/tmp/test": {
Path: "/tmp/test",
Name: "test",
Config: settings.SourceConfig{
DefaultUserScope: "/",
},
},
}
settings.Config.Server.NameToSource = map[string]*settings.Source{
"test": settings.Config.Server.SourceMap["/tmp/test"],
}
settings.Config.Server.Sources = []*settings.Source{
settings.Config.Server.SourceMap["/tmp/test"],
}
// Don't set default scopes for tests - let tests explicitly set scopes when needed
settings.Config.UserDefaults.DefaultScopes = []users.SourceScope{}
// Initialize user package resolvers
settings.InitializeUserResolvers()
}
func createTestUsersBackend(t *testing.T) users.StorageBackend {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
+1 -4
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)
@@ -37,8 +36,6 @@ func InitializeDb(path string) (*bolt.BoltStore, bool, error) {
// Load access rules from DB on startup
// ignoring errors because
_ = store.Access.LoadFromDB()
// Load share cache from DB so shareByHash/shareByPath are populated at startup
store.Share.LoadShareCacheFromDB()
userStore = store.Users
err = bolt.Save(db, "version", 2)
if err != nil {
@@ -118,7 +115,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
}
-156
View File
@@ -1,156 +0,0 @@
package users
import (
"fmt"
"strings"
)
// GetSourceNames returns all source names the user has access to (assumes backend-style scopes)
func (u *User) GetSourceNames() []string {
if sourceConfig == nil {
return []string{}
}
sources := []string{}
allSources := sourceConfig.GetAllSources()
// Preserves order of sources
for _, source := range allSources {
_, err := u.GetScopeForSourcePath(source.Path)
if err == nil {
sources = append(sources, source.Name)
}
}
return sources
}
// GetBackendScopes converts the user's scopes from frontend-style to backend-style
func (u *User) GetBackendScopes() ([]SourceScope, error) {
// Only convert scopes if they are not empty
// Empty scopes during update should remain empty (not filled with defaults)
if len(u.Scopes) == 0 {
return []SourceScope{}, nil
}
if sourceConfig == nil {
return nil, fmt.Errorf("source config not initialized")
}
newScopes := []SourceScope{}
for _, scope := range u.Scopes {
// First check if its already a path name and keep it
source, ok := sourceConfig.GetSourceByPath(scope.Name)
if ok {
if scope.Scope == "" {
scope.Scope = source.DefaultUserScope
}
scope.Scope = normalizeScope(scope.Scope)
newScopes = append(newScopes, SourceScope{
Name: source.Path, // backend name is path
Scope: scope.Scope,
})
continue
}
// Check if its the name of a source and convert it to a path
source, ok = sourceConfig.GetSourceByName(scope.Name)
if !ok {
// source might no longer be configured
continue
}
if scope.Scope == "" {
scope.Scope = source.DefaultUserScope
}
scope.Scope = normalizeScope(scope.Scope)
newScopes = append(newScopes, SourceScope{
Name: source.Path, // backend name is path
Scope: scope.Scope,
})
}
return newScopes, nil
}
// GetFrontendScopes converts the user's scopes from backend-style to frontend-style
// Backend scopes use source paths, frontend scopes use source names
func (u *User) GetFrontendScopes() []SourceScope {
if sourceConfig == nil {
return []SourceScope{}
}
newScopes := []SourceScope{}
for _, scope := range u.Scopes {
if source, ok := sourceConfig.GetSourceByPath(scope.Name); ok {
// Replace scope.Name with source.Name while keeping the same Scope value
newScopes = append(newScopes, SourceScope{
Name: source.Name,
Scope: scope.Scope,
})
}
}
return newScopes
}
// GetBackendSidebarLinks converts the user's sidebar links from frontend-style to backend-style
// Validates that sources exist and converts source names to paths
func (u *User) GetBackendSidebarLinks() ([]SidebarLink, error) {
if sourceConfig == nil {
return nil, fmt.Errorf("source config not initialized")
}
newLinks := []SidebarLink{}
for _, link := range u.SidebarLinks {
// For source links, validate that the source exists using SourceName
if strings.HasPrefix(link.Category, "source") {
if link.SourceName == "" {
return nil, fmt.Errorf("source link missing sourceName (link name: %v)", link.Name)
}
// Validate source exists
sourceInfo, ok := sourceConfig.GetSourceByName(link.SourceName)
if !ok {
return nil, fmt.Errorf("source not found: %v (link name: %v)", link.SourceName, link.Name)
}
link.SourceName = sourceInfo.Path // if name changes keep link alive
}
// Store the link as-is with all fields preserved
newLinks = append(newLinks, link)
}
return newLinks, nil
}
// GetFrontendSidebarLinks converts the user's sidebar links from backend-style to frontend-style
// Converts source paths to source names for the frontend
func (u *User) GetFrontendSidebarLinks() []SidebarLink {
if sourceConfig == nil {
return []SidebarLink{}
}
newLinks := []SidebarLink{}
for _, link := range u.SidebarLinks {
// For source links, validate that the source still exists
if strings.HasPrefix(link.Category, "source") {
if link.SourceName == "" {
continue
}
// Check if source exists
sourceInfo, ok := sourceConfig.GetSourceByPath(link.SourceName)
if !ok {
continue
}
link.SourceName = sourceInfo.Name
}
// For share links, just pass through (shares are validated separately)
// For all other links, pass through as-is
newLinks = append(newLinks, link)
}
return newLinks
}
// normalizeScope ensures scope starts with / and doesn't end with / (except for root)
func normalizeScope(scope string) string {
if !strings.HasPrefix(scope, "/") {
scope = "/" + scope
}
if scope != "/" && strings.HasSuffix(scope, "/") {
scope = strings.TrimSuffix(scope, "/")
}
return scope
}
+16
View File
@@ -0,0 +1,16 @@
package users
import (
"golang.org/x/crypto/bcrypt"
)
// HashPwd hashes a password.
func HashPwd(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
// CheckPwd checks if a password is correct.
func CheckPwd(password, hash string) error {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
}
Loaded 100 of 440 files, more files were not shown because too many files have changed in this diff. Show more