Compare commits

...
Author SHA1 Message Date
+11 45a14bf2f2 Beta/v2.1.0 (#3046)
Signed-off-by: MichaIng <micha@dietpi.com>
Co-authored-by: Kevin B. <kurami32_kbm@proton.me>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Mattijs Bliek <mattijs@mattijsbliek.com>
Co-authored-by: GammelSami <39372285+GammelSami@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: yzxcj797 <54314860+yzxcj797@users.noreply.github.com>
Co-authored-by: yzxcj797 <yzxcj797@users.noreply.github.com>
Co-authored-by: DireMunchkin <dire.munchkin@proton.me>
Co-authored-by: Chris <39135018+Chri6s@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Aditya Raj Singh <github@adityaraj.com>
Co-authored-by: tbsagdic <86909795+tbsagdic@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ethan Lee Qi Yang <pluscompanyhqDEPT@gmail.com>
Co-authored-by: Aditya Raj Singh <aditya@bncw.in>
Co-authored-by: MichaIng <micha@dietpi.com>
Co-authored-by: FandacqkaCorp <david@davidyloreto.com>
Co-authored-by: FandacqkaCorp <275647464+FandacqkaCorp@users.noreply.github.com>
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: connorjfarrell <connorjfarrell@users.noreply.github.com>
2026-09-30 13:04:20 -05:00
431 changed files with 35882 additions and 8727 deletions

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
root = true
[*]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
[*.go]
indent_size = 4
[{Makefile,makefile}]
indent_style = tab
+41
View File
@@ -35,3 +35,44 @@ jobs:
context: .
file: ./_docker/Dockerfile.playwright-general
push: false
create_beta_release:
needs: [test_playwright]
if: startsWith(github.event.head_commit.message, 'Beta/v')
name: Create Beta Release
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.PAT }}
fetch-depth: 0
- name: Extract version from commit message
id: extract_version
shell: bash
run: |
tag_name=$(echo "${{ github.event.head_commit.message }}" | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+')-beta
if [ -z "$tag_name" ] || [ "$tag_name" = "-beta" ]; then
echo "Could not extract version from: ${{ github.event.head_commit.message }}"
exit 1
fi
echo "tag_name=$tag_name" >> $GITHUB_OUTPUT
- name: Tag release commit on main
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
TAG="${{ steps.extract_version.outputs.tag_name }}"
git tag -f "$TAG" "${{ github.sha }}"
git push origin "refs/tags/$TAG" --force
- name: Create Release
uses: softprops/action-gh-release@v2
with:
target_commitish: ${{ github.sha }}
token: ${{ secrets.PAT }}
tag_name: ${{ steps.extract_version.outputs.tag_name }}
prerelease: false
make_latest: false
draft: false
generate_release_notes: true
overwrite_existing: true
name: ${{ steps.extract_version.outputs.tag_name }}
+8 -2
View File
@@ -43,7 +43,13 @@ jobs:
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run lint
run: npm i
- name: Lint frontend
working-directory: frontend
run: npm run lint
- name: Lint frontend CSS
working-directory: frontend
run: npm run lint:css
test-frontend:
runs-on: ubuntu-24.04-arm
steps:
@@ -79,4 +85,4 @@ jobs:
with:
node-version-file: frontend/.nvmrc
- working-directory: frontend
run: npm i && npm run icons:check
run: npm i && npm run icons:check
+79 -9
View File
@@ -2,6 +2,74 @@
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).
## v2.1.0
**Security**:
- [Moderate] Conflicting upload responses no longer synchronously drain the request body before returning HTTP 409; the server now closes the body and marks the connection for closure, preventing denial-of-service from clients that never send EOF (CWE-400).
- Share download links no longer create links with token, instead they link to the UI prompting for password before download. If a direct download is required, the `/api/share/direct` api exists and documented by swagger. (#2888)
**New Features**:
- Added Storage Quotas
- configurable by folder, source, user, and share.
- Administrators can create, edit, view, and delete folder quotas through the interface.
- Quota information now appears in source views, share details, sidebar progress bars, and folder prompts.
- Enhancements to the editor (#2714):
- Added a floating button to open a resizable split view with live editing for markdown files, the scrolling is synced in both sides and scrolling in single-view modes (the viewer and editor in non-split) now also tries to sync with each other so you don't lose context easily.
- Added a toolbar: This toolbar is present on all the files.
- JSON files have a button to toggle between formatted and minified versions (#2854) (#2566).
- Markdown files have a richer toolbar with lots of quick actions, for example you can now browse, navigate and insert images more easily from your current source.
- Added a prompt to configure some editor settings in UI, that prompt is accessible via the toolbar in the three-dots menu, there you can configure things like:
- Word wrap, keybinds, autocompletion, scrollbar, etc.
- The markdown viewer now supports rendering LaTeX Math and Chemistry formulas.
- Improved and fixed some styles that weren't following the app light/dark theme (#2949).
- Proxy auth group support: `groupsClaim`, `adminGroup`, and `userGroups` now control role-based access when the proxy sends a group/role header (#2755). Admin is determined by `adminGroup` only.
- Moved `auth.adminUsername` and `auth.adminPassword` to `auth.methods.password` (legacy top-level keys are migrated automatically on load).
- Support for `.elrc` (word-by-word lyrics), `.vtt`, `.srt` sidecar files for lyrics in audio files in the media player. (#2838)
- Added setting to configure the placement of the prompts close button in profile settings (#2853) (#2595).
- Added "New file templates". You can now add and configure pre-defined filenames + extension for the creation of new files in the context menu on profile settings! (#2881) (#1239).
- sidebar links can be bulk edited as yaml (#1963) (settings > user management)
- admins can customize more defaults:
- share creation defaults and enforcements (#1692) (#2434) (#2279) (#2812) (settings > share management)
- sidebar link defaults and enforcements (#2561) (settings > user management)
- tool defaults and enforcement (settings > user management)
- Add cli init CLI command (#2957)
- Sort header in file listings inside prompts like move/copy/quick jump, is now sticky. Also added a button to show a search input and filter the items of the current listing (#3028).
- Quick jump now scrolls and highlights the current item you are previewing (#3028).
- When hovering `date` and `name` in the listings, will show up the formatted/raw date (the inverse of what you have configured to display) and the full filename (#3028) (#2623).
- User groups can now be managed from the WebUI under `User Management` -> `Groups` and when editing a user (#2983) (#3040).
**Notes**:
- An Admin can remove a `defautlEnabled` source for a user and it will remove removed until an admin adds it back.
- Enhancements to cross-source copy/move permissions and behavior.
- Improved resume upload behavior.
- Play/pause on videos in mobile now is toggled by the button in the middle rather than the whole container (#2828).
- improved video thumbnail generation speed and efficiency.
- Added icons based on extension to the upload prompt and new file/folder/rename prompts (#2881).
- Profile settings show minimal options by default. Full advanced profile settings can be shown by default by enabling `account.showAdvancedProfile`.
- sidebar links/navigation is button group toggle for clearer visibility
- Pop-up preview has 200ms debounce delay so it doesn't flash from moving the cursor across files quickly.
- Added risc-v to official releases
- Polished a bit some styles across various places of the UI (#3019) (#3028).
- Moved `Access Management` under `User Management` in the UI (#3040).
- improved developer experience with native vue webhook reloads.
- improved ffmpeg handling and concurrency via library upgrades
**Bug Fixes**:
- Undo in a fresh opened file on the editor was setting the file empty (#2714)
- Added some missing styles in the markdown viewer (#2714)
- Recaptcha not working (#1925) (#2861)
- Fix disk-usage overstatement on virtiofs bind mounts (#2894)
- Support non-ASCII share passwords (#2933)
- Fall back to buffered copies when FUSE rejects fast paths (#2938)
- Preserve deleted sidebar links across restarts (#2935)
- scope padding to listing view (#2934)
- Hide the Replace option on upload/create conflict prompts when the user lacks modify permission (or when a public share disallows replacements), so create-only users are not offered an action that the server rejects (#2837)
- Preserve Ctrl-click file selection when keyboard state is stale (#2958) (#2923)
- Avoid false stalls during parallel transfers (#2950) (#2948) thanks @gudcks0305
- Cap source usage-bar percentage at 100% when indexed size exceeds partition total (#2761) (#2238)
- On Linux, source partition totals sum distinct filesystems mounted under the source root (nested mounts) so usage bars match indexed content (#2761)
- Context menu stuck when opened from a listing in a prompt like move/copy or search (#3028).
## v2.0.9
**Security**:
@@ -26,6 +94,7 @@ All notable changes to this project will be documented in this file. For commit
- SQLite index and application databases configure `busy_timeout` on every pooled connection, and busy/locked detection now uses driver result codes; index batch writes and maintenance no longer report success when the DB stays busy, and index cache reads return busy errors instead of empty results so listing can fall back to the filesystem.
## v2.0.8
**Security**:
- [Critical] A forged JWT could authenticate as any known `belongsTo`. Auth signing keys are now persisted in the application database and JWT validation fails closed when no key is configured. (GHSA-8f9r-wg7w-pfw) (#2987) Thanks @d3do-23 and @whoamis3c.
- [Medium] Public upload shares with replacements disabled (`allowReplacements=false`) now reject overwrites when clients send `override=true`. (GHSA-3846-gh75-gp3m) Thanks @d3do-23
@@ -50,7 +119,7 @@ All notable changes to this project will be documented in this file. For commit
- Add cli init CLI command (#2957)
**Notes**:
- Pop-up preview has 200ms debounce delay so it doesn't flash from moving the cursor across files quickly.
- Pop-up preview has 200ms debounce delay so it doesn't flash from moving the cursor across files quickly.
- Added risc-v to official releases
**Bugfixes**:
@@ -130,6 +199,7 @@ All notable changes to this project will be documented in this file. For commit
- External subtitles fail to load on public video shares due to authenticated subtitle endpoint (#2822) (#2827)
- OnlyOffice is inaccessible on password-protected shares (#2811)
- FFmpeg 9.0 incorrectly detected as below minimum 5.0.0 on Windows (#2820) -- thanks @yzxcj797
- Queue buttons not clickable in the desktop panel in audio files.
## v2.0.1
@@ -203,7 +273,7 @@ This version represents the most significant change to date. It **requires** bot
- opt-in feature to send deployment analytics to filebrowser quantum developer servers
- anonymized with a viewer so users can see what info would be sent.
- if opt-in, every month a snapshot of your deployment config would be sent to developer servers
- this will help me know what features are being used and what versions everyone is on over time. I will also provide a public dashboard with this information in the future.
- this will help me know what features are being used and what versions everyone is on over time. I will also provide a public dashboard with this information in the future.
- WebDAV now supports set modification time via the `X-OC-Mtime` header for clients that support it (#2626). See [WebDAV docs](https://filebrowserquantum.com/en/docs/features/webdav/).
- Copy operations now preserve their original modification times (#2642) (#2647):
- WebUI preserves both, files and directories.
@@ -383,14 +453,14 @@ This version represents the most significant change to date. It **requires** bot
- Improved style of drag and drop into listing view (#2407)
- Edit Sidebar links has new "show tools in sidebar" toggle and all users have this enabled by default. can be disabled via for new users `userDefaults.sidebar.showTools: false`
- Update user defaults ordering (#1140)
- Save view modes and sizes into local storage instead of db (#2301)
- Save view modes and sizes into local storage instead of db (#2301)
**BugFixes**:
- Blue overlay when using gestures in video files on mobile (#2360)
- Playback queue wasn't updating when changing of folder (#2360)
- Added missing `UserDefaults.listing.showCopyPath` option to config file (#2364)
- Delete prompt thumbnail didn't worked when using it with the `delete` shortcut. (#2407)
- Searching icons in the Icon picker prompt wasn't working properly (#2407)
- Searching icons in the Icon picker prompt wasn't working properly (#2407)
- Drag and drop style state was stuck until page reload (#2407)
- Navigate close settings shows "something went wrong" (#2047)
@@ -536,7 +606,7 @@ This version represents the most significant change to date. It **requires** bot
- 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)
- 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)
@@ -570,7 +640,7 @@ Note: a potentially breaking change for docker users: \the default user is now "
- 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)
- 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`
@@ -733,7 +803,7 @@ Note: a potentially breaking change for docker users: \the default user is now "
## v1.2.1-beta
**New Features**:
- Global disable onlyoffice editor via "*" file option to disable all files for a specific user. (#1533)
- 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)
@@ -836,7 +906,7 @@ Sorry for the delay -- a lot of effort went into this release, specifically abou
- 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
- 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
@@ -871,7 +941,7 @@ Sorry for the delay -- a lot of effort went into this release, specifically abou
**BugFixes**:
- better index status updates, fixing delays #1649
- fixed long load times for listings with media info due sequential processing of files.
- 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
+9 -9
View File
@@ -86,7 +86,7 @@ See the [Official Docs](https://filebrowserquantum.com/). Contributions are welc
## Comparison Chart
Application Name | <img width="48" alt="" src="https://github.com/user-attachments/assets/c40b22c9-33da-47b7-bc4c-ce69bb5cc174" > Quantum | <img width="48" alt="" src="https://github.com/filebrowser/filebrowser/blob/master/frontend/public/img/logo.svg" > Filebrowser | <img width="48" alt="" src="https://github.com/mickael-kerjean/filestash/blob/master/public/assets/logo/app_icon.png?raw=true" > Filestash | <img width="48" alt="" src="https://avatars.githubusercontent.com/u/19211038?s=200&v=4" > Nextcloud | <img width="48" alt="" src="https://cdn.iconscout.com/icon/free/png-256/free-google-drive-logo-icon-svg-download-png-2476481.png" > Google_Drive | <img width="48" alt="" src="https://avatars.githubusercontent.com/u/6422152?v=4" > FileRun
--- | --- | --- | --- | --- | --- | --- |
Filesystem support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Filesystem support | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Linux | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Windows | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Mac | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
@@ -95,7 +95,7 @@ Has Stable Release? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
S3 support | ❌ | ❌ | ✅ | ✅ | ❌ | ❌ |
webdav support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
FTP support | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ |
Dedicated docs site? | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
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 |
@@ -109,25 +109,25 @@ Content-aware search? | ❌ | ❌ | ✅ | configurable | ✅ | ✅ |
Custom job support | :construction: | ✅ | ✅ | ✅ | ❌ | ✅ |
Multiple users | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Single sign-on support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
LDAP sign-on support | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
LDAP sign-on support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Long-live API key support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
API documentation page | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ |
Mobile App | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ |
API documentation page | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Mobile App | ❌ | ❌ | ❌ | ✅ | ✅ | ❌ |
open source? | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
tags support | :construction: | ❌ | ✅ | ✅ | ❌ | ✅ |
shareable web links? | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Event-based notifications | :construction: | ❌ | ✅ | ❌ | ❌ | ✅ |
Metrics | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
file space quotas | :construction: | ❌ | ✅ | ❌ | ✅ | ✅ |
Metrics | :construction: | ❌ | ✅ | ✅ | ❌ | ❌ |
file space quotas | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file previews | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
Themes | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Themes | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Branding support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
activity log | ✅ | ❌ | ✅ | ✅ | ✅ | ✅
Comments support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
trash support | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
Starred/pinned files | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
Starred/pinned files | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
Share collections of files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can archive selected files | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
+1 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/ffmpeg:9.0-decode AS ffmpeg
FROM gtstef/ffmpeg:9.0.1-decode AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
+1 -1
View File
@@ -4,4 +4,4 @@ WORKDIR /app/frontend
COPY frontend/package-lock.json /tmp/package-lock.json
RUN PW_VERSION=$(node -p "require('/tmp/package-lock.json').packages['node_modules/@playwright/test'].version") \
&& npm i "@playwright/test@${PW_VERSION}" oauth2-mock-server @types/node
RUN npx playwright install --with-deps chromium
RUN npx playwright install --with-deps chromium-headless-shell
@@ -4,10 +4,8 @@ http:
<<: *http-main
frontend:
<<: *frontend-main
auth:
<<: *auth-main
userDefaults:
preview:
image: true
@@ -13,3 +13,6 @@ server:
name: "playwright"
config:
defaultEnabled: true
userDefaults:
account:
showAdvancedProfile: true
+1
View File
@@ -0,0 +1 @@
FILEBROWSER_DEVMODE=true
+10 -4
View File
@@ -1,16 +1,22 @@
root = "."
tmp_dir = "tmp"
env_files = [".air.env"]
[build]
pre_cmd = ["go run ./scripts/setup-gofitz-cgo"]
cmd = "go build -o ./tmp/filebrowser --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ."
cmd = "bash ./scripts/air-build.sh"
entrypoint = "./tmp/filebrowser"
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -c test_config.yaml"
args_bin = ["-c", "test_config.yaml"]
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "internal/web/dist", "internal/web/embed", "internal/preview/testdata", "internal/preview/thumbnails", "internal/preview/heic", "swagger/docs"]
exclude_dir = ["tmp", "vendor", "internal/web/dist", "internal/web/embed", "internal/preview/testdata", "internal/preview/thumbnails", "internal/preview/heic", "swagger/docs", "internal/preview/gofitzinclude"]
poll = true
poll_interval = 1000
log = "air.log"
delay = 1000 # ms
[build.windows]
cmd = "bash ./scripts/air-build.sh"
entrypoint = "./tmp/filebrowser.exe"
[log]
time = true
-22
View File
@@ -1,22 +0,0 @@
root = "."
tmp_dir = "tmp"
[build]
pre_cmd = ["go run ./scripts/setup-gofitz-cgo"]
cmd = "go build -o ./tmp/filebrowser.exe --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ."
entrypoint = "./tmp/filebrowser.exe"
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser.exe -c test_config.yaml"
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "internal\\web\\dist", "internal\\web\\embed", "internal\\preview\\testdata", "internal\\preview\\thumbnails", "internal\\preview\\heic", "swagger\\docs"]
exclude_unchanged = true
follow_symlink = false
poll = true
poll_interval = 1000
log = "air.log"
delay = 1000 # ms
[log]
time = true
[misc]
clean_on_exit = false
+5 -5
View File
@@ -28,7 +28,7 @@ require (
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.67.4 // indirect
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // indirect
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.1.0 // indirect
github.com/alfatraining/structtag v1.0.0 // indirect
@@ -54,7 +54,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 // indirect
github.com/charmbracelet/x/ansi v0.11.8 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
@@ -105,7 +105,7 @@ require (
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 // indirect
github.com/golangci/golangci-lint/v2 v2.14.0 // indirect
github.com/golangci/golangci-lint/v2 v2.14.0 // indirect
github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
@@ -150,7 +150,7 @@ require (
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/mgechev/revive v1.17.0 // indirect
github.com/mgechev/revive v1.17.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
@@ -200,7 +200,7 @@ require (
github.com/swaggo/swag v1.16.6 // indirect
github.com/tdewolff/parse/v2 v2.8.16 // indirect
github.com/tetafro/godot v1.5.6 // indirect
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 // indirect
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 // indirect
github.com/timonwong/loggercheck v0.12.0 // indirect
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
+10
View File
@@ -56,6 +56,8 @@ github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8v
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
github.com/alecthomas/go-check-sumtype v0.4.0 h1:gd4mE4QsA62sXlnFIs8GUv8/0xH3DOUqTkYQR1l2v28=
github.com/alecthomas/go-check-sumtype v0.4.0/go.mod h1:aYYllI8IXNjo7ZYUzI2+wyLRECMg3zo+8CZj9JTBAWU=
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 h1:dXPJ+uOJLWb8rjlAuLX7JPiRJtZKw/53Ol+WxdDJ1Eo=
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606/go.mod h1:ZH31IJyNpxpStxP2ayCcPQz+3T2G/OnxIQqe4LWvSDw=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
@@ -138,6 +140,8 @@ github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1Di
github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16 h1:/hSCZO1puetbGd2JikHKN0nAcb2mFevzxJElFoduhaw=
github.com/charmbracelet/ultraviolet v0.0.0-20260910203606-6c9e17dc7a16/go.mod h1:JFvmQPZh3uAu7PpSZU91bcR0BrIy24HhW+0EsW1uGxE=
github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 h1:ZGHc3eaN2TIhkpwRibtr8N9jLQJOY1rh6GEByRoRmp4=
github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9/go.mod h1:D9SVXVpAsD1pP6xR6BiERbe4+dUU6EW9US1TFrNutGA=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
@@ -290,6 +294,8 @@ github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarog
github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792 h1:WL8YKrt3UbOBqSRU7GpP5BTtQTMWtVtj+mfPijgZeIg=
github.com/golangci/gofmt v0.0.0-20260820135601-e84e05053792/go.mod h1:te5hX0dW4C5r6YbXs+6ysNr8Q5UTmdIqGbb+mlFiYmA=
github.com/golangci/golangci-lint/v2 v2.13.2 h1:bCyq3E4vo9qwzifjpzJqYndEt7Ncva80qkk8G2B4TXU=
github.com/golangci/golangci-lint/v2 v2.13.2/go.mod h1:5xaMd1kAxV7GSBPEyngw8gnnjoRocqW9UVKdBR6627w=
github.com/golangci/golangci-lint/v2 v2.14.0 h1:ot8QffRa4LzAAEgtvNYrVs9esxQ7xoTwoAv6uhp4ngA=
github.com/golangci/golangci-lint/v2 v2.14.0/go.mod h1:amHimCzNDP1GfVno3hIiNssjvqXmuW+CPZAHOdR4cIc=
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
@@ -414,6 +420,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mgechev/revive v1.16.0 h1:9OZVO8xOPUwIYynplHXv7liMllPQ/BTNZLp07sM/Im0=
github.com/mgechev/revive v1.16.0/go.mod h1:nT854jMmvQsS/qqxEaMPM4pJSUpxhcPoHM788ZxDvdQ=
github.com/mgechev/revive v1.17.0 h1:hwTTVC3svSv9X2FA2hiYT17Uws+lfxDWhPxzBZZCREg=
github.com/mgechev/revive v1.17.0/go.mod h1:uiVNUF8ruVh4WHm/aPs7kKEeuo+m6oO3tuOfniyzP/g=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
@@ -567,6 +575,8 @@ github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA=
github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c h1:d/FUP8GD4QiY/rdFzZmBu3KqL9eBNVzMu7W/5pGkaMM=
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M=
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 h1:HECVafB2AKntWWRfsiQ4gdunLx5WOYQzFdCsxqOrzp8=
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2/go.mod h1:JLoPVtROtNgJ99yXTIxuGNttvmJa85tkJAO6lr05qBA=
github.com/timonwong/loggercheck v0.12.0 h1:7Cb9XGLJZSWYNjgL459DIhpSiEHupD+MLQ6vaN2Pl3g=
+2 -2
View File
@@ -251,8 +251,8 @@ func createConfig(configpath string, noInput bool) error {
fmt.Printf("Error: '%s' is not a valid path. Please enter a path to a file ending in .sqlite", config.Server.DatabaseV2.Path)
}
config.Frontend.Name = askQuestion(reader, "What should the application brand name be?", "FileBrowser Quantum")
config.Auth.AdminUsername = askQuestion(reader, "What should the default admin username be?", "admin")
config.Auth.AdminPassword = askQuestion(reader, "What should the default admin password be?", "admin")
config.Auth.Methods.PasswordAuth.AdminUsername = askQuestion(reader, "What should the default admin username be?", "admin")
config.Auth.Methods.PasswordAuth.AdminPassword = askQuestion(reader, "What should the default admin password be?", "admin")
modifyDefault := askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
config.Server.Sources[0].Config.DefaultPermissions = settings.NormalizeSourceFilePermissions(users.SourceFilePermissions{
+12
View File
@@ -0,0 +1,12 @@
package cmd
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
+2 -2
View File
@@ -200,9 +200,9 @@ func rootCMD(ctx context.Context, serverConfig *settings.Server, a *app.App, shu
if err != nil {
logger.Fatalf("Failed to create sub filesystem: %v", err)
}
fileutils.InitAssetFS(subAssets, true)
fileutils.InitAssetFS(subAssets, true, false)
} else {
fileutils.InitAssetFS(nil, false)
fileutils.InitAssetFS(nil, false, settings.Env.IsDevMode)
}
// Start preview service
+3 -6
View File
@@ -74,10 +74,7 @@ func validateUserInfo(newDB bool) {
if bumpToNewestVersion(user, tokenBackfillFailed) {
changedFields = append(changedFields, "version")
}
adminUser := settings.Config.Auth.AdminUsername
if adminUser == "" {
adminUser = "admin"
}
adminUser := settings.PasswordAdminUsername()
if user.Username == adminUser && user.Permissions.Admin {
adminPerms := settings.AdminPerms()
if user.Permissions.Share != adminPerms.Share || user.Permissions.Api != adminPerms.Api {
@@ -90,7 +87,7 @@ func validateUserInfo(newDB bool) {
if user.Username == adminUser && settings.ConfigTriggersAdminPasswordReset() && user.LoginMethod == users.LoginMethodPassword {
logger.Info("Resetting admin user to default username and password.")
user.Permissions = settings.AdminPerms()
user.Password = settings.Config.Auth.AdminPassword
user.Password = settings.PasswordAdminPassword()
user.LoginMethod = users.LoginMethodPassword
changedFields = append(changedFields, "permissions", "password", "loginMethod")
changePass = true
@@ -138,7 +135,7 @@ func dedupeFields(fields []string) []string {
}
func updateUserScopes(user *users.User) bool {
newScopes := settings.MergeDefaultEnabledBackendScopes(user.BackendScopes)
newScopes := settings.MergeDefaultEnabledBackendScopes(user.BackendScopes, user.DeclinedDefaultSources)
changed := !reflect.DeepEqual(user.BackendScopes, newScopes)
user.BackendScopes = newScopes
return changed
+1
View File
@@ -53,6 +53,7 @@ userDefaults:
lockPassword: false
disableSettings: false
disableUpdateNotifications: false
showAdvancedProfile: false
permissions:
api: false
admin: false
+8 -9
View File
@@ -19,7 +19,7 @@ require (
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-ffmpeg v0.5.2
github.com/gtsteffaniak/go-ffmpeg v1.2.0
github.com/gtsteffaniak/go-logger v1.1.0
github.com/kovidgoyal/imaging v1.8.23
github.com/mattn/go-sqlite3 v1.14.52
@@ -38,7 +38,7 @@ require (
golang.org/x/time v0.16.0
golang.org/x/tools v0.50.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.59.0
modernc.org/sqlite v1.60.1
)
require (
@@ -54,8 +54,8 @@ require (
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.5 // indirect
github.com/go-openapi/jsonpointer v1.0.1 // indirect
github.com/go-openapi/jsonreference v1.0.2 // indirect
github.com/go-openapi/jsonpointer v1.0.2 // indirect
github.com/go-openapi/jsonreference v1.0.3 // indirect
github.com/go-openapi/spec v1.0.1 // indirect
github.com/go-openapi/swag/conv v0.29.2 // indirect
github.com/go-openapi/swag/jsonutils v0.29.2 // indirect
@@ -64,12 +64,12 @@ require (
github.com/go-openapi/swag/stringutils v0.29.2 // indirect
github.com/go-openapi/swag/typeutils v0.29.2 // indirect
github.com/go-openapi/swag/yamlutils v0.29.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/locales v0.14.2 // indirect
github.com/go-playground/universal-translator v0.18.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.3.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/geo v0.0.0-20260818125358-b200a1149890 // indirect
github.com/golang/geo v0.0.0-20260928092222-7d12f68cfadb // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-tpm v0.9.8 // indirect
@@ -78,14 +78,14 @@ require (
github.com/kr/pretty v0.3.1 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.16.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/swaggo/files v1.0.1 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/tinylib/msgp v1.6.5 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.etcd.io/bbolt v1.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
@@ -103,5 +103,4 @@ require (
exclude (
github.com/alecthomas/go-check-sumtype v0.5.0
github.com/gtsteffaniak/go-ffmpeg v1.1.0
github.com/gtsteffaniak/go-ffmpeg v1.2.0
)
+18 -20
View File
@@ -69,10 +69,10 @@ github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU
github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
github.com/go-openapi/jsonpointer v1.0.1 h1:2KxywRmNwJkT/FMBa3iRNHEaAxSJvjqoufQZy3au1Mg=
github.com/go-openapi/jsonpointer v1.0.1/go.mod h1:wI7ZYsFmbIi9nBXOZqgDaS/bqOchRGZjqxFli7FBYxY=
github.com/go-openapi/jsonreference v1.0.2 h1:oS4et8FOf3p3UQxEo4Xt0esijmBUM+F259Xl72OSZsc=
github.com/go-openapi/jsonreference v1.0.2/go.mod h1:TbUNSOo+fcorZjFaNoiSDSoaNnnZtqJtLGR1PuvE/Cs=
github.com/go-openapi/jsonpointer v1.0.2 h1:NbW7xT2b+l7x+JefiPGDsZWsrLk1ogrz4AHK1TTzMt8=
github.com/go-openapi/jsonpointer v1.0.2/go.mod h1:N0rI4MbNKD7xAFTiHtB+5Nhrg2j/wwYg7m7Fmb4/jk8=
github.com/go-openapi/jsonreference v1.0.3 h1:5rDbq+JsjCv6/nUDDJt83nLZoADI4w03uvHXer+4aYQ=
github.com/go-openapi/jsonreference v1.0.3/go.mod h1:OJSlMXHysgH272D9z2tiy2MGe5A9vQLHaCPb6KbYdE4=
github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk=
github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
@@ -94,12 +94,12 @@ github.com/go-openapi/swag/yamlutils v0.29.2 h1:IFKFFeDnuIwzfsuWRQU+rI8iL3g4XyAY
github.com/go-openapi/swag/yamlutils v0.29.2/go.mod h1:7MGqtcrK73sxQ4ceiyIf8qiXS/s09JLMdR5esK5euYQ=
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0 h1:wPW6YRgx3+SID1yUy/Xwa17L8kFEaEKod2VRbJDZNUs=
github.com/go-openapi/testify/enable/yaml/v2 v2.7.0/go.mod h1:mI1M88etYbc3PhgHsWQK2kwvNwW5aGFqMPbmib+SGIs=
github.com/go-openapi/testify/v2 v2.7.0 h1:bycOreEj6wfBvijg3YFogZ/sFjTCDmQnwSodSzHa3X8=
github.com/go-openapi/testify/v2 v2.7.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-openapi/testify/v2 v2.8.0 h1:19QDx5b57p8KjO2E4sgs/woL4Akp+FGLRlVm2xn0Rho=
github.com/go-openapi/testify/v2 v2.8.0/go.mod h1:4gUN8jC+RqOE2qjW/vDmfyTvl5BCht4qL7oca0odrak=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/locales v0.14.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9B56YNCY=
github.com/go-playground/locales v0.14.2/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU=
github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
@@ -119,8 +119,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20260818125358-b200a1149890 h1:m+G0ip1+N4CF0ex34SeojAon6htIIBwvzsyXNx1fGWg=
github.com/golang/geo v0.0.0-20260818125358-b200a1149890/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/geo v0.0.0-20260928092222-7d12f68cfadb h1:z//sB+BhCYNvUrk19E8aWqNx+BBom8p/uD/nh95N/5U=
github.com/golang/geo v0.0.0-20260928092222-7d12f68cfadb/go.mod h1:Mymr9kRGDc64JPr03TSZmuIBODZ3KyswLzm1xL0HFA8=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
@@ -143,10 +143,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gtsteffaniak/go-cache v1.1.0 h1:ni/2cJpCxpGmaMZd92iTDIGOy+yUcMcxQuW7bSKhkCE=
github.com/gtsteffaniak/go-cache v1.1.0/go.mod h1:rHUZvf4huhMX/bnNK4/oQ3sNAE2fYC2jFpAWZiBPWac=
github.com/gtsteffaniak/go-ffmpeg v0.5.2 h1:KVnKM3zewY9yeZVd59Hb0/10gSbhYf01yRM5RftXGEU=
github.com/gtsteffaniak/go-ffmpeg v0.5.2/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
github.com/gtsteffaniak/go-ffmpeg v1.0.0 h1:dUWvlZmWnkiujDtIMLGwrpfSAOnM+IOtk93HXF6HR0U=
github.com/gtsteffaniak/go-ffmpeg v1.0.0/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
github.com/gtsteffaniak/go-ffmpeg v1.2.0 h1:WSFfRL5xVUpEXBNI4D3f2Uz8yxbc3OEqTtjp4Z9yPUo=
github.com/gtsteffaniak/go-ffmpeg v1.2.0/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
github.com/gtsteffaniak/go-logger v1.1.0 h1:baFds+iibuLLNpNLwygz32bDqdsunI6dGWcdxwMIVKI=
github.com/gtsteffaniak/go-logger v1.1.0/go.mod h1:rapapTju4D7bwt6h4JOdZNUXDjnp0WkTBd6TN5yWglI=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
@@ -189,8 +187,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-sqlite3 v1.14.52 h1:wVbm2Qnf4OXkqhBTSPuCRZDRnxfbVrrmiCEroVdog8U=
github.com/mattn/go-sqlite3 v1.14.52/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -219,8 +217,8 @@ github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tinylib/msgp v1.6.5 h1:iAH6XTP7BpzErjBZlujJQXxA+GmRi2YWs6M4KdLtNbE=
github.com/tinylib/msgp v1.6.5/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI=
github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -335,8 +333,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+1
View File
@@ -0,0 +1 @@
../../frontend/dist
+74
View File
@@ -7,6 +7,7 @@ import (
"time"
activitydb "github.com/gtsteffaniak/filebrowser/backend/internal/database/activity"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
@@ -287,6 +288,79 @@ func RecordAccessDelete(r *http.Request, actor *Actor, source, path string, chan
})
}
func RecordQuotaCreate(r *http.Request, actor *Actor, source, path string, changes []activitydb.FieldChange) {
RecordQuotaMutation(r, actor, activitydb.EventQuotaCreate, source, path, changes)
}
func RecordQuotaUpdate(r *http.Request, actor *Actor, source, path string, changes []activitydb.FieldChange) {
RecordQuotaMutation(r, actor, activitydb.EventQuotaUpdate, source, path, changes)
}
func RecordQuotaDelete(r *http.Request, actor *Actor, source, path string, changes []activitydb.FieldChange) {
RecordQuotaMutation(r, actor, activitydb.EventQuotaDelete, source, path, changes)
}
func RecordQuotaMutation(r *http.Request, actor *Actor, eventType activitydb.EventType, source, path string, changes []activitydb.FieldChange) {
RecordUser(r, actor, activitydb.Entry{
EventType: eventType,
Source: source,
Path: path,
Details: activitydb.Details{
Source: source,
Path: path,
Changes: changes,
},
})
}
func quotaFolderChanges(q quota.FolderQuota) []activitydb.FieldChange {
changes := []activitydb.FieldChange{
{Field: "limitBytes", To: strconv.FormatInt(q.LimitBytes, 10)},
{Field: "meter", To: q.Meter},
}
if q.UserID > 0 {
changes = append(changes, activitydb.FieldChange{
Field: "userId",
To: strconv.FormatUint(q.UserID, 10),
})
}
return changes
}
func QuotaFolderCreateChanges(q quota.FolderQuota) []activitydb.FieldChange {
return quotaFolderChanges(q)
}
func QuotaFolderDeleteChanges(q quota.FolderQuota) []activitydb.FieldChange {
return quotaFolderChanges(q)
}
func QuotaFolderUpdateChanges(before, after quota.FolderQuota) []activitydb.FieldChange {
var changes []activitydb.FieldChange
if before.LimitBytes != after.LimitBytes {
changes = append(changes, activitydb.FieldChange{
Field: "limitBytes",
From: strconv.FormatInt(before.LimitBytes, 10),
To: strconv.FormatInt(after.LimitBytes, 10),
})
}
if before.Meter != after.Meter {
changes = append(changes, activitydb.FieldChange{
Field: "meter",
From: before.Meter,
To: after.Meter,
})
}
if before.UserID != after.UserID {
changes = append(changes, activitydb.FieldChange{
Field: "userId",
From: strconv.FormatUint(before.UserID, 10),
To: strconv.FormatUint(after.UserID, 10),
})
}
return changes
}
func AccessRuleCreateChanges(allow bool, ruleCategory, value string) []activitydb.FieldChange {
ruleType := "deny"
if allow {
+80 -96
View File
@@ -1,13 +1,11 @@
package files
import (
"bufio"
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
@@ -25,8 +23,6 @@ import (
"github.com/gtsteffaniak/go-logger/logger"
)
var reDuration = regexp.MustCompile(`^\[(?:(\d{1,2}):)?(\d{1,2}):(\d{1,2})\.(\d+)\](.*)`)
// CheckPermissionsFunc allows tests to override CheckPermissions behavior
var CheckPermissionsFunc = func(opts utils.FileOptions, user *users.User) (string, string, error) {
return checkPermissionsImpl(opts, user, svc())
@@ -50,7 +46,7 @@ func addMetadataToChildren(response *iteminfo.ExtendedFileInfo, opts utils.FileO
wg.Go(func() {
fullPath := response.RealPath + "/" + fileItem.Name
if isAudio {
if err := extractAudioMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, opts.Metadata, false, sharedFFmpegService); err != nil {
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 {
@@ -393,7 +389,7 @@ func processContent(info *iteminfo.ExtendedFileInfo, idx *indexing.Index, opts u
extItem := &iteminfo.ExtendedItemInfo{
ItemInfo: info.ItemInfo,
}
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, false, nil)
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, nil)
if err != nil {
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
} else {
@@ -431,57 +427,10 @@ func generateOfficeId(realPath string) string {
return key
}
// Parses raw LRC text into a slice of Lyric.
// Lines with [hh:mm:ss.xx] (timestamps) will be parsed to ms for synced lyrics
// The ones without timestamp are kept as unsynced (timestamp 0).
func parseLRC(raw string) []iteminfo.Lyric {
var lines []iteminfo.Lyric
scanner := bufio.NewScanner(strings.NewReader(raw))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
matches := reDuration.FindStringSubmatch(line)
if len(matches) == 6 {
// Hours default to 0 (if not present in the timestamps)
h := 0
if matches[1] != "" {
h, _ = strconv.Atoi(matches[1])
}
min, _ := strconv.Atoi(matches[2])
sec, _ := strconv.Atoi(matches[3])
msStr := matches[4]
// Normalise to 3 digits, some songs have more than 3 digits in the milliseconds
switch {
case len(msStr) < 3:
msStr += strings.Repeat("0", 3-len(msStr))
case len(msStr) > 3:
msStr = msStr[:3]
}
ms, _ := strconv.Atoi(msStr)
timestamp := int64((h*3600+min*60+sec)*1000 + ms)
text := strings.TrimSpace(matches[5])
lines = append(lines, iteminfo.Lyric{
Text: text,
Timestamp: timestamp,
})
} else {
// Line without a valid timestamp – treat as unsynced (timestamp 0)
lines = append(lines, iteminfo.Lyric{
Text: line,
Timestamp: 0,
})
}
}
return lines
}
// 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, getLyrics bool, ffmpegService *ffmpeg.FFmpegService) error {
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, ffmpegService *ffmpeg.FFmpegService) error {
file, err := os.Open(realPath)
if err != nil {
return err
@@ -518,28 +467,7 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
item.Metadata.Track = track
// Check if lyrics are available without extract them
if rawLyrics := m.Lyrics(); rawLyrics != "" {
item.Metadata.HasLyrics = true
} else {
// Check for sidecar .lrc file
dir := filepath.Dir(realPath)
base := filepath.Base(realPath)
ext := filepath.Ext(base)
nameWithoutExt := strings.TrimSuffix(base, ext)
lrcPath := filepath.Join(dir, nameWithoutExt+".lrc")
if _, err := os.Stat(lrcPath); err == nil {
item.Metadata.HasLyrics = true
}
}
if getLyrics {
lyrics, err := ExtractLyrics(realPath)
if err != nil {
logger.Debugf("failed to extract lyrics for %s: %v", realPath, err)
} else if len(lyrics) > 0 {
item.Metadata.Lyrics = lyrics
}
}
item.Metadata.HasLyrics = hasLyricsSidecar(realPath) || m.Lyrics() != ""
// Extract duration ONLY if explicitly requested using the ffmpeg VideoService
// This respects concurrency limits and gracefully handles missing ffmpeg
@@ -572,35 +500,91 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
return nil
}
// Returns lyrics from an audio file (from embedded tags or from a .lrc file with the same name).
func ExtractLyrics(realPath string) ([]iteminfo.Lyric, error) {
var lyricsSidecarExts = []string{".elrc", ".lrc", ".srt", ".vtt"}
func lyricsSidecar(dir, nameWithoutExt, ext string) (path string, ok bool) {
sidecarPath := filepath.Join(dir, nameWithoutExt+ext)
info, err := os.Lstat(sidecarPath)
if err != nil {
return "", false
}
if !info.Mode().IsRegular() {
return "", false
}
if info.Size() > 15*1024*1024 { // 15MB
return "", false
}
return sidecarPath, true
}
func findLyricsSidecar(realPath string) (data []byte, ext string, ok bool) {
dir := filepath.Dir(realPath)
base := filepath.Base(realPath)
nameWithoutExt := strings.TrimSuffix(base, filepath.Ext(base))
for _, ext = range lyricsSidecarExts {
sidecarPath, candOk := lyricsSidecar(dir, nameWithoutExt, ext)
if !candOk {
continue
}
data, err := os.ReadFile(sidecarPath)
if err != nil {
continue
}
if strings.TrimSpace(string(data)) == "" {
continue
}
return data, strings.TrimPrefix(ext, "."), true
}
return nil, "", false
}
func hasLyricsSidecar(realPath string) bool {
dir := filepath.Dir(realPath)
base := filepath.Base(realPath)
nameWithoutExt := strings.TrimSuffix(base, filepath.Ext(base))
for _, ext := range lyricsSidecarExts {
sidecarPath, ok := lyricsSidecar(dir, nameWithoutExt, ext)
if !ok {
continue
}
f, err := os.Open(sidecarPath)
if err != nil {
continue
}
buf := make([]byte, 4096)
for {
n, readErr := f.Read(buf)
if n > 0 && strings.TrimSpace(string(buf[:n])) != "" {
f.Close()
return true
}
if readErr != nil {
break
}
}
f.Close()
}
return false
}
// Returns raw lyrics from an audio file (from embedded tags or from sidecar files with the same name).
func ExtractLyrics(realPath string) (string, string, error) {
if data, ext, ok := findLyricsSidecar(realPath); ok {
return string(data), ext, nil
}
file, err := os.Open(realPath)
if err != nil {
return nil, err
return "", "", err
}
defer file.Close()
m, err := tag.ReadFrom(file)
if err != nil {
return nil, err
return "", "", err
}
var lyrics []iteminfo.Lyric
if raw := m.Lyrics(); raw != "" {
lyrics = parseLRC(raw)
return raw, "embedded", nil
}
if len(lyrics) == 0 {
// Check for sidecar .lrc file
dir := filepath.Dir(realPath)
base := filepath.Base(realPath)
ext := filepath.Ext(base)
nameWithoutExt := strings.TrimSuffix(base, ext)
lrcPath := filepath.Join(dir, nameWithoutExt+".lrc")
if data, err := os.ReadFile(lrcPath); err == nil {
lyrics = parseLRC(string(data))
}
}
return lyrics, nil
return "", "", nil
}
// extractVideoMetadata extracts duration and codec info from video files using ffprobe.
@@ -447,6 +447,7 @@ var types = map[string]string{
".vtt": "text/subtitle-vtt",
".ssa": "text/subtitle-ssa",
".lrc": "text/lyrics",
".elrc": "text/lyrics",
".sbv": "text/plain",
".ssi": "text/x-server-parsed-html",
".ssm": "application/streamingmedia",
@@ -21,11 +21,13 @@ func (d dirFS) Open(name string) (fs.File, error) {
// 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) {
func InitAssetFS(embeddedAssets fs.FS, useEmbedded bool, devMode bool) {
if useEmbedded {
assetFs = embeddedAssets
} else if devMode {
// Dev mode: serve frontend source assets (templates, fonts, icons)
assetFs = dirFS{Dir: http.Dir("../frontend/public")}
} else {
// Dev mode: Serve files from internal/web/dist directory
assetFs = dirFS{Dir: http.Dir("internal/web/dist")}
}
}
+55 -27
View File
@@ -56,48 +56,62 @@ func unixModeToFileMode(u uint32) os.FileMode {
}
// MoveFile moves a file from src to dst.
// By default, the rename system call is used. If src and dst point to different volumes,
// the file copy is used as a fallback.
// By default, the rename system call is used. If src and dst are on different volumes,
// CopyFile is used as a fallback and the source is removed synchronously on success.
func MoveFile(src, dst string) error {
err := os.Rename(src, dst)
if err == nil {
return nil
}
// fallback
err = CopyFile(src, dst)
if err != nil {
logger.Errorf("CopyFile failed %v", err)
return err
}
go func() {
err = os.RemoveAll(src)
if err != nil {
logger.Errorf("os.Remove failed %v", err)
if err := os.Rename(src, dst); err != nil {
if !isCrossDeviceRenameError(err) {
return err
}
}()
if err := CopyFile(src, dst); err != nil {
return err
}
return os.RemoveAll(src)
}
return nil
}
func preserveFileMode(path string, mode os.FileMode) error {
return os.Chmod(path, mode.Perm())
}
// CopyFile copies a file or directory from source to dest and returns an error if any.
func CopyFile(source, dest string) error {
// Check if the source exists and whether it's a file or directory.
info, err := os.Stat(source)
info, err := os.Lstat(source)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
return copySymlink(source, dest)
}
if info.IsDir() {
// If the source is a directory, copy it recursively.
return copyDirectory(source, dest)
}
// If the source is a file, copy the file.
return copySingleFile(source, dest)
}
func copySymlink(source, dest string) error {
target, err := os.Readlink(source)
if err != nil {
return err
}
destDir := filepath.Dir(dest)
if err = os.MkdirAll(destDir, EffectiveDirPerm()); err != nil {
return err
}
tmpDir, err := os.MkdirTemp(destDir, ".fb-link-*")
if err != nil {
return err
}
defer func() { _ = os.RemoveAll(tmpDir) }()
tmpPath := filepath.Join(tmpDir, "link")
if err := os.Symlink(target, tmpPath); err != nil {
return err
}
return os.Rename(tmpPath, dest)
}
// copySingleFile handles copying a single file.
func copySingleFile(source, dest string) error {
// Get source file info to preserve permissions
@@ -161,7 +175,7 @@ func copyFileContents(dst io.Writer, src io.Reader) error {
// copyDirectory handles copying directories recursively.
func copyDirectory(source, dest string) error {
srcInfo, err := os.Stat(source)
srcInfo, err := os.Lstat(source)
if err != nil {
return err
}
@@ -170,6 +184,9 @@ func copyDirectory(source, dest string) error {
if err != nil {
return err
}
if err = preserveFileMode(dest, srcInfo.Mode()); err != nil {
logger.Debugf("Could not set directory permissions for %s: %v", dest, err)
}
// Read the contents of the source directory.
entries, err := os.ReadDir(source)
@@ -182,6 +199,17 @@ func copyDirectory(source, dest string) error {
srcPath := filepath.Join(source, entry.Name())
destPath := filepath.Join(dest, entry.Name())
if entry.Type()&os.ModeSymlink != 0 {
linkTarget := ""
linkTarget, err = os.Readlink(srcPath)
if err != nil {
return err
}
if err = os.Symlink(linkTarget, destPath); err != nil {
return err
}
continue
}
if entry.IsDir() {
// Recursively copy subdirectories.
err = copyDirectory(srcPath, destPath)
@@ -139,6 +139,223 @@ func TestCopyFilePreservesModTime(t *testing.T) {
}
}
// moveViaCrossDeviceFallback mirrors MoveFile's EXDEV fallback (CopyFile + RemoveAll).
func moveViaCrossDeviceFallback(t *testing.T, src, dst string) {
t.Helper()
if err := CopyFile(src, dst); err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(src); err != nil {
t.Fatal(err)
}
}
func TestMoveFileDoesNotFallbackOnNonCrossDeviceError(t *testing.T) {
srcDir := t.TempDir()
src := filepath.Join(srcDir, "file.txt")
if err := os.WriteFile(src, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
dst := filepath.Join(srcDir, "existing-dir")
if err := os.Mkdir(dst, 0o755); err != nil {
t.Fatal(err)
}
err := MoveFile(src, dst)
if err == nil {
t.Fatal("expected rename error")
}
if _, statErr := os.Stat(src); statErr != nil {
t.Fatalf("source file should remain after rename failure: %v", statErr)
}
entries, err := os.ReadDir(dst)
if err != nil {
t.Fatal(err)
}
if len(entries) != 0 {
t.Fatalf("destination directory should be unchanged, got %d entries", len(entries))
}
}
func TestMoveFileCrossDevicePreservesFilePermissions(t *testing.T) {
srcDir := t.TempDir()
dstDir := t.TempDir()
src := filepath.Join(srcDir, "secret.txt")
if err := os.WriteFile(src, []byte("data"), 0o640); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dstDir, "secret.txt")
moveViaCrossDeviceFallback(t, src, dst)
got, err := os.Stat(dst)
if err != nil {
t.Fatal(err)
}
if got.Mode().Perm() != 0o640 {
t.Fatalf("dst perm = %#o, want 0640", got.Mode().Perm())
}
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Fatalf("src should be removed, stat err = %v", err)
}
}
func TestMoveFileCrossDeviceMovesDirectory(t *testing.T) {
srcDir := t.TempDir()
dstParent := t.TempDir()
nested := filepath.Join(srcDir, "nested.txt")
if err := os.WriteFile(nested, []byte("nested"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chmod(srcDir, 0o755); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dstParent, "moved-dir")
moveViaCrossDeviceFallback(t, srcDir, dst)
movedNested := filepath.Join(dst, "nested.txt")
if _, err := os.Stat(movedNested); err != nil {
t.Fatalf("expected nested file at destination: %v", err)
}
got, err := os.Stat(dst)
if err != nil {
t.Fatal(err)
}
if got.Mode().Perm() != 0o755 {
t.Fatalf("dst dir perm = %#o, want 0755", got.Mode().Perm())
}
if _, err := os.Stat(srcDir); !os.IsNotExist(err) {
t.Fatalf("source directory should be removed, stat err = %v", err)
}
}
func TestMoveFileCrossDevicePreservesSymlinkToFile(t *testing.T) {
targetDir := t.TempDir()
target := filepath.Join(targetDir, "target.txt")
if err := os.WriteFile(target, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
srcDir := t.TempDir()
dstDir := t.TempDir()
link := filepath.Join(srcDir, "link")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dstDir, "link")
moveViaCrossDeviceFallback(t, link, dst)
gotTarget, err := os.Readlink(dst)
if err != nil {
t.Fatal(err)
}
if gotTarget != target {
t.Fatalf("link target = %q, want %q", gotTarget, target)
}
info, err := os.Lstat(dst)
if err != nil {
t.Fatal(err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Fatal("expected destination to remain a symlink")
}
if _, err := os.Stat(link); !os.IsNotExist(err) {
t.Fatalf("source symlink should be removed, stat err = %v", err)
}
}
func TestMoveFileCrossDevicePreservesSymlinkToDirectory(t *testing.T) {
targetDir := t.TempDir()
if err := os.Mkdir(filepath.Join(targetDir, "inside"), 0o755); err != nil {
t.Fatal(err)
}
srcDir := t.TempDir()
dstDir := t.TempDir()
link := filepath.Join(srcDir, "dirlink")
if err := os.Symlink(targetDir, link); err != nil {
t.Fatal(err)
}
dst := filepath.Join(dstDir, "dirlink")
moveViaCrossDeviceFallback(t, link, dst)
gotTarget, err := os.Readlink(dst)
if err != nil {
t.Fatal(err)
}
if gotTarget != targetDir {
t.Fatalf("link target = %q, want %q", gotTarget, targetDir)
}
info, err := os.Lstat(dst)
if err != nil {
t.Fatal(err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Fatal("expected destination to remain a symlink")
}
}
func TestMoveFileCrossDeviceMovesDirectoryWithNestedSymlink(t *testing.T) {
target := filepath.Join(t.TempDir(), "target.txt")
if err := os.WriteFile(target, []byte("linked"), 0o644); err != nil {
t.Fatal(err)
}
srcDir := t.TempDir()
if err := os.Symlink(target, filepath.Join(srcDir, "nested-link")); err != nil {
t.Fatal(err)
}
dstParent := t.TempDir()
dst := filepath.Join(dstParent, "moved-dir")
moveViaCrossDeviceFallback(t, srcDir, dst)
movedLink := filepath.Join(dst, "nested-link")
gotTarget, err := os.Readlink(movedLink)
if err != nil {
t.Fatal(err)
}
if gotTarget != target {
t.Fatalf("nested link target = %q, want %q", gotTarget, target)
}
info, err := os.Lstat(movedLink)
if err != nil {
t.Fatal(err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Fatal("expected nested entry to remain a symlink")
}
}
func TestCopySymlinkReplacesExistingDestination(t *testing.T) {
target := filepath.Join(t.TempDir(), "target.txt")
if err := os.WriteFile(target, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
workDir := t.TempDir()
src := filepath.Join(workDir, "link")
if err := os.Symlink(target, src); err != nil {
t.Fatal(err)
}
dst := filepath.Join(workDir, "existing")
if err := os.WriteFile(dst, []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
if err := copySymlink(src, dst); err != nil {
t.Fatal(err)
}
gotTarget, err := os.Readlink(dst)
if err != nil {
t.Fatal(err)
}
if gotTarget != target {
t.Fatalf("link target = %q, want %q", gotTarget, target)
}
info, err := os.Lstat(dst)
if err != nil {
t.Fatal(err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Fatal("expected destination to be a symlink")
}
}
func TestCopyFileContentsFallsBackAfterCopyFileRangeBadFileDescriptor(t *testing.T) {
const content = "complete file contents"
src := struct{ io.Reader }{bytes.NewBufferString(content)}
@@ -0,0 +1,17 @@
//go:build !windows
package fileutils
import (
"errors"
"os"
"syscall"
)
func isCrossDeviceRenameError(err error) bool {
if errors.Is(err, syscall.EXDEV) {
return true
}
var linkErr *os.LinkError
return errors.As(err, &linkErr) && errors.Is(linkErr.Err, syscall.EXDEV)
}
@@ -0,0 +1,18 @@
//go:build windows
package fileutils
import (
"errors"
"os"
"syscall"
)
func isCrossDeviceRenameError(err error) bool {
const errNotSameDevice = syscall.Errno(17)
if errors.Is(err, errNotSameDevice) {
return true
}
var linkErr *os.LinkError
return errors.As(err, &linkErr) && errors.Is(linkErr.Err, errNotSameDevice)
}
+12
View File
@@ -0,0 +1,12 @@
package analytics
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
+12
View File
@@ -0,0 +1,12 @@
package auth
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
+1 -4
View File
@@ -15,10 +15,7 @@ type NoAuth struct{}
// AuthenticateNoAuth authenticates as the configured admin user with no credentials required.
func AuthenticateNoAuth(r *http.Request, user *users.Storage) (*users.User, error) {
admin := settings.Config.Auth.AdminUsername
if admin == "" {
admin = "admin"
}
admin := settings.PasswordAdminUsername()
id, err := users.ResolveUsernameToID(admin)
if err != nil {
return nil, err
+31
View File
@@ -1,8 +1,10 @@
package auth
import (
"encoding/json"
"net/http"
"os"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
@@ -38,3 +40,32 @@ func AuthenticateProxy(r *http.Request, usr *users.Storage, headerName string) (
func (a ProxyAuth) Auth(r *http.Request, usr *users.Storage) (*users.User, error) {
return AuthenticateProxy(r, usr, a.Header)
}
// ExtractGroupsFromHeader reads group/role values from a proxy header named by groupsClaim.
// Returns present=false when the header name is empty or the header is missing/blank.
func ExtractGroupsFromHeader(r *http.Request, headerName string) ([]string, bool) {
if headerName == "" {
return nil, false
}
headerVal := strings.TrimSpace(r.Header.Get(headerName))
if headerVal == "" {
return nil, false
}
if strings.HasPrefix(headerVal, "[") {
var groups []string
if err := json.Unmarshal([]byte(headerVal), &groups); err == nil {
return groups, true
}
}
groups := ExtractGroupsFromClaims(map[string]interface{}{headerName: headerVal}, headerName)
if len(groups) == 1 && strings.Contains(groups[0], ",") {
parts := strings.Split(groups[0], ",")
groups = make([]string, 0, len(parts))
for _, part := range parts {
if part = strings.TrimSpace(part); part != "" {
groups = append(groups, part)
}
}
}
return groups, true
}
@@ -0,0 +1,81 @@
package auth_test
import (
"net/http"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/auth"
)
func TestExtractGroupsFromHeader_SingleValue(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Cosmos-Role", "2")
groups, present := auth.ExtractGroupsFromHeader(req, "x-cosmos-role")
if !present {
t.Fatal("expected groups to be present")
}
if len(groups) != 1 || groups[0] != "2" {
t.Fatalf("groups = %#v, want [\"2\"]", groups)
}
}
func TestExtractGroupsFromHeader_CommaSeparated(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Groups", "1, 2")
groups, present := auth.ExtractGroupsFromHeader(req, "X-Groups")
if !present {
t.Fatal("expected groups to be present")
}
if len(groups) != 2 || groups[0] != "1" || groups[1] != "2" {
t.Fatalf("groups = %#v, want [\"1\", \"2\"]", groups)
}
}
func TestExtractGroupsFromHeader_JSONArray(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Groups", `["admin","users"]`)
groups, present := auth.ExtractGroupsFromHeader(req, "X-Groups")
if !present {
t.Fatal("expected groups to be present")
}
if len(groups) != 2 || groups[0] != "admin" || groups[1] != "users" {
t.Fatalf("groups = %#v, want [\"admin\", \"users\"]", groups)
}
}
func TestExtractGroupsFromHeader_Missing(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
if err != nil {
t.Fatal(err)
}
groups, present := auth.ExtractGroupsFromHeader(req, "X-Groups")
if present {
t.Fatalf("expected groups to be absent, got %#v", groups)
}
}
func TestExtractGroupsFromHeader_EmptyHeaderName(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", http.NoBody)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-Groups", "admin")
groups, present := auth.ExtractGroupsFromHeader(req, "")
if present {
t.Fatalf("expected groups to be absent, got %#v", groups)
}
}
+234 -45
View File
@@ -1,6 +1,7 @@
package access
import (
"encoding/json"
"fmt"
"maps"
"path/filepath"
@@ -109,12 +110,25 @@ type Storage struct {
sqlStore SQLPersister // SQL store for persistence
}
// RuleKey identifies one access_rules row.
type RuleKey struct {
Source string
Path string
}
// RuleUpsert is an access_rules row to insert or replace.
type RuleUpsert struct {
RuleKey
Rule *AccessRule
}
// SQLPersister interface for SQL persistence operations
type SQLPersister interface {
SaveAccessRule(source, path string, rule *AccessRule) error
DeleteAccessRule(source, path string) error
SaveGroup(name string, members StringSet) error
DeleteGroup(name string) error
DeleteGroupWithRules(name string, save []RuleUpsert, remove []RuleKey) error
SaveRevokedToken(tokenHash string, revokedAt int64) error
PersistImmediateTokenRevocation(tokenHash string) error
PersistTokenRetirement(tokenHash string, revokedAt int64, pruned []string) error
@@ -136,30 +150,35 @@ func (s *Storage) Flush() error {
// persistGroupSQLNL upserts or deletes one groups row to match in-memory state.
// Caller must hold s.mux. If the group is absent from s.Groups, it is deleted from SQL.
func (s *Storage) persistGroupSQLNL(groupname string) {
func (s *Storage) persistGroupSQLNL(groupname string) error {
if s.sqlStore == nil {
return
return nil
}
members, ok := s.Groups[groupname]
if !ok {
if err := s.sqlStore.DeleteGroup(groupname); err != nil {
logger.Errorf("failed to delete group %q from sql: %v", groupname, err)
return fmt.Errorf("failed to delete group %q from sql: %w", groupname, err)
}
return
return nil
}
if err := s.sqlStore.SaveGroup(groupname, members); err != nil {
logger.Errorf("failed to save group %q: %v", groupname, err)
return fmt.Errorf("failed to save group %q: %w", groupname, err)
}
return nil
}
// ensureGroupExistsNL creates an empty in-memory group if missing and write-through persists it.
// Caller must hold s.mux.
func (s *Storage) ensureGroupExistsNL(groupname string) {
func (s *Storage) ensureGroupExistsNL(groupname string) error {
if _, ok := s.Groups[groupname]; ok {
return
return nil
}
s.Groups[groupname] = make(StringSet)
s.persistGroupSQLNL(groupname)
if err := s.persistGroupSQLNL(groupname); err != nil {
delete(s.Groups, groupname)
return err
}
return nil
}
// NewStorage creates a new Storage instance.
@@ -307,7 +326,9 @@ func (s *Storage) AllowUser(sourcePath string, indexPath utils.IndexPath, userna
func (s *Storage) DenyGroup(sourcePath string, indexPath utils.IndexPath, groupname string) error {
s.mux.Lock()
defer s.mux.Unlock()
s.ensureGroupExistsNL(groupname)
if err := s.ensureGroupExistsNL(groupname); err != nil {
return err
}
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Deny.Groups[groupname]; ok {
return errors.ErrExist
@@ -323,7 +344,9 @@ func (s *Storage) DenyGroup(sourcePath string, indexPath utils.IndexPath, groupn
func (s *Storage) AllowGroup(sourcePath string, indexPath utils.IndexPath, groupname string) error {
s.mux.Lock()
defer s.mux.Unlock()
s.ensureGroupExistsNL(groupname)
if err := s.ensureGroupExistsNL(groupname); err != nil {
return err
}
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Allow.Groups[groupname]; ok {
return errors.ErrExist
@@ -573,14 +596,22 @@ func (s *Storage) GetAllRules(sourcePath string) (map[string]FrontendAccessRule,
func (s *Storage) AddUserToGroup(group, username string) error {
s.mux.Lock()
defer s.mux.Unlock()
createdGroup := false
if _, ok := s.Groups[group]; !ok {
s.Groups[group] = make(StringSet)
createdGroup = true
}
if _, ok := s.Groups[group][username]; ok {
return nil
}
s.Groups[group][username] = struct{}{}
s.persistGroupSQLNL(group)
if err := s.persistGroupSQLNL(group); err != nil {
delete(s.Groups[group], username)
if createdGroup {
delete(s.Groups, group)
}
return err
}
s.clearAllCaches()
return nil
}
@@ -597,6 +628,103 @@ func (s *Storage) GetAllGroups() []string {
return groups
}
// GetGroupMembers returns every group with its sorted member usernames.
func (s *Storage) GetGroupMembers() map[string][]string {
s.mux.RLock()
defer s.mux.RUnlock()
result := make(map[string][]string, len(s.Groups))
for group, members := range s.Groups {
names := make([]string, 0, len(members))
for name := range members {
names = append(names, name)
}
sort.Strings(names)
result[group] = names
}
return result
}
// SetGroupMembers creates the group if needed and replaces its membership.
func (s *Storage) SetGroupMembers(group string, usernames []string) error {
s.mux.Lock()
defer s.mux.Unlock()
members := make(StringSet, len(usernames))
for _, name := range usernames {
if name != "" {
members[name] = struct{}{}
}
}
previous, existed := s.Groups[group]
s.Groups[group] = members
if err := s.persistGroupSQLNL(group); err != nil {
// Keep memory and SQL consistent: undo the in-memory change.
if existed {
s.Groups[group] = previous
} else {
delete(s.Groups, group)
}
return err
}
s.clearAllCaches()
return nil
}
// DeleteGroup removes a group and every access rule entry that references it.
// The group row and all affected rule rows are written in one SQL transaction, so a
// failure stores nothing. On failure the in-memory groups and rules are restored and the
// error is returned. The lock is held throughout, so no other change can interleave.
func (s *Storage) DeleteGroup(group string) error {
s.mux.Lock()
defer s.mux.Unlock()
previousMembers, existed := s.Groups[group]
// Snapshot the rules so they can be restored if the transaction fails.
rulesSnapshot, err := json.Marshal(s.AllRules)
if err != nil {
return fmt.Errorf("failed to snapshot access rules: %w", err)
}
delete(s.Groups, group)
touched := s.removeAllRulesForGroupNL(group)
if !existed && len(touched) == 0 {
return nil
}
if err := s.persistGroupDeleteNL(group, touched); err != nil {
if existed {
s.Groups[group] = previousMembers
}
restored := make(SourceRuleMap)
if jsonErr := json.Unmarshal(rulesSnapshot, &restored); jsonErr != nil {
logger.Errorf("failed to restore access rules after group delete error: %v", jsonErr)
} else {
s.AllRules = restored
}
return err
}
s.clearAllCaches()
return nil
}
// persistGroupDeleteNL writes a group deletion and the resulting rule row changes to SQL
// in one transaction. Caller must hold s.mux.
func (s *Storage) persistGroupDeleteNL(group string, touched []RuleKey) error {
if s.sqlStore == nil {
return nil
}
var save []RuleUpsert
var remove []RuleKey
for _, key := range touched {
var rule *AccessRule
if bySrc, ok := s.AllRules[key.Source]; ok {
rule = bySrc[key.Path]
}
if accessRuleHasPayload(rule) {
save = append(save, RuleUpsert{RuleKey: key, Rule: rule})
} else {
remove = append(remove, key)
}
}
return s.sqlStore.DeleteGroupWithRules(group, save, remove)
}
// GetUserGroups returns all groups for a specific user.
func (s *Storage) GetUserGroups(username string) []string {
s.mux.RLock()
@@ -616,7 +744,6 @@ func (s *Storage) GetUserGroups(username string) []string {
func (s *Storage) SyncUserGroups(username string, newGroups []string) error {
s.mux.Lock()
defer s.mux.Unlock()
affected := make(map[string]struct{})
// Create a set of new groups for efficient lookup
newGroupsSet := make(StringSet, len(newGroups))
@@ -627,30 +754,62 @@ func (s *Storage) SyncUserGroups(username string, newGroups []string) error {
newGroupsSet[g] = struct{}{}
}
// Iterate over all existing groups to find the user's current memberships
affected := make(map[string]struct{})
for group, members := range s.Groups {
_, userIsInGroup := members[username]
_, groupIsInNewSet := newGroupsSet[group]
// If user is in a group that is not in their new set of groups, remove them.
if userIsInGroup && !groupIsInNewSet {
delete(s.Groups[group], username)
affected[group] = struct{}{}
}
}
for group := range newGroupsSet {
if _, ok := s.Groups[group]; !ok {
affected[group] = struct{}{}
continue
}
if _, ok := s.Groups[group][username]; !ok {
affected[group] = struct{}{}
}
}
// Add user to new groups
before := make(map[string]StringSet, len(affected))
for group := range affected {
before[group] = cloneStringSet(s.Groups[group])
}
for group, members := range s.Groups {
_, userIsInGroup := members[username]
_, groupIsInNewSet := newGroupsSet[group]
if userIsInGroup && !groupIsInNewSet {
delete(s.Groups[group], username)
}
}
for group := range newGroupsSet {
if _, ok := s.Groups[group]; !ok {
s.Groups[group] = make(StringSet)
}
if _, ok := s.Groups[group][username]; !ok {
s.Groups[group][username] = struct{}{}
affected[group] = struct{}{}
}
s.Groups[group][username] = struct{}{}
}
for group := range affected {
s.persistGroupSQLNL(group)
affectedGroups := slices.Sorted(maps.Keys(affected))
var persisted []string
for _, group := range affectedGroups {
if err := s.persistGroupSQLNL(group); err != nil {
for _, g := range persisted {
if restoreErr := s.restoreGroupSQLNL(g, before[g]); restoreErr != nil {
logger.Errorf("failed to restore group %q in sql after sync failure: %v", g, restoreErr)
}
}
for g, snap := range before {
if len(snap) == 0 {
delete(s.Groups, g)
} else {
s.Groups[g] = snap
}
}
return err
}
persisted = append(persisted, group)
}
if len(affected) > 0 {
s.clearAllCaches()
@@ -658,6 +817,31 @@ func (s *Storage) SyncUserGroups(username string, newGroups []string) error {
return nil
}
func (s *Storage) restoreGroupSQLNL(groupname string, members StringSet) error {
if s.sqlStore == nil {
return nil
}
if len(members) == 0 {
if err := s.sqlStore.DeleteGroup(groupname); err != nil {
return fmt.Errorf("failed to delete group %q from sql: %w", groupname, err)
}
return nil
}
if err := s.sqlStore.SaveGroup(groupname, members); err != nil {
return fmt.Errorf("failed to save group %q: %w", groupname, err)
}
return nil
}
func cloneStringSet(src StringSet) StringSet {
if len(src) == 0 {
return nil
}
dst := make(StringSet, len(src))
maps.Copy(dst, src)
return dst
}
// RemoveUserFromGroup removes a username from a group.
func (s *Storage) RemoveUserFromGroup(group, username string) error {
s.mux.Lock()
@@ -670,7 +854,10 @@ func (s *Storage) RemoveUserFromGroup(group, username string) error {
return nil
}
delete(members, username)
s.persistGroupSQLNL(group)
if err := s.persistGroupSQLNL(group); err != nil {
s.Groups[group][username] = struct{}{}
return err
}
s.clearAllCaches()
return nil
}
@@ -868,44 +1055,46 @@ func (s *Storage) RemoveAllRulesForUser(username string) error {
func (s *Storage) RemoveAllRulesForGroup(groupname string) error {
s.mux.Lock()
defer s.mux.Unlock()
changed := false
dirty := make(map[string]map[string]struct{})
touch := func(sp, ip string) {
if dirty[sp] == nil {
dirty[sp] = make(map[string]struct{})
touched := s.removeAllRulesForGroupNL(groupname)
if len(touched) > 0 {
s.clearAllCaches()
for _, key := range touched {
s.persistRuleSQLNL(key.Source, key.Path)
}
dirty[sp][ip] = struct{}{}
}
return nil
}
// removeAllRulesForGroupNL removes a group from every allow and deny list in memory and
// returns the rules it touched (rules left empty are deleted). It does not persist; the
// caller must hold s.mux and save the returned rules.
func (s *Storage) removeAllRulesForGroupNL(groupname string) []RuleKey {
var touched []RuleKey
for sourcePath, rulesBySource := range s.AllRules {
for indexPath, rule := range rulesBySource {
touchedRule := false
if _, exists := rule.Allow.Groups[groupname]; exists {
delete(rule.Allow.Groups, groupname)
touch(sourcePath, indexPath)
changed = true
touchedRule = true
}
if _, exists := rule.Deny.Groups[groupname]; exists {
delete(rule.Deny.Groups, groupname)
touch(sourcePath, indexPath)
changed = true
touchedRule = true
}
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
// A rule left with only DenyAll must stay, otherwise removing the group would open the path.
if !accessRuleHasPayload(rule) {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
touch(sourcePath, indexPath)
touchedRule = true
}
if touchedRule {
touched = append(touched, RuleKey{Source: sourcePath, Path: indexPath})
}
}
}
if changed {
s.clearAllCaches()
for sp, paths := range dirty {
for ip := range paths {
s.persistRuleSQLNL(sp, ip)
}
}
}
return nil
return touched
}
// GetRulesForUser returns all rules for a specific user for a given sourcePath.
@@ -1,6 +1,7 @@
package access_test
import (
stderrors "errors"
"os"
"path/filepath"
"strings"
@@ -1851,3 +1852,200 @@ func TestAddUserToGroup_WriteThrough(t *testing.T) {
t.Fatal("bob should be removed from editors in SQL")
}
}
func TestSetGroupMembers_ReplacesMembership(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("editors", []string{"alice", "bob", ""}); err != nil {
t.Fatalf("SetGroupMembers: %v", err)
}
if err := s.SetGroupMembers("editors", []string{"bob", "carol"}); err != nil {
t.Fatalf("SetGroupMembers replace: %v", err)
}
got := s.GetGroupMembers()["editors"]
if len(got) != 2 || got[0] != "bob" || got[1] != "carol" {
t.Fatalf("unexpected members: %v", got)
}
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatalf("GetAllGroups: %v", err)
}
if _, ok := sqlGroups["editors"]["alice"]; ok {
t.Fatal("alice should have been removed in SQL")
}
if _, ok := sqlGroups["editors"]["carol"]; !ok {
t.Fatal("carol should be persisted in SQL")
}
}
func TestDeleteGroup_RemovesGroupAndRules(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("acme", []string{"alice"}); err != nil {
t.Fatalf("SetGroupMembers: %v", err)
}
if err := s.AllowGroup("mnt/storage", idxPath("/tenant"), "acme"); err != nil {
t.Fatalf("AllowGroup: %v", err)
}
if err := s.DeleteGroup("acme"); err != nil {
t.Fatalf("DeleteGroup: %v", err)
}
if _, ok := s.GetGroupMembers()["acme"]; ok {
t.Fatal("acme should be gone from memory")
}
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatalf("GetAllGroups: %v", err)
}
if _, ok := sqlGroups["acme"]; ok {
t.Fatal("acme should be gone from SQL")
}
if rules := s.GetRulesForGroup("mnt/storage", "acme"); len(rules) != 0 {
t.Fatalf("expected no rules for deleted group, got %v", rules)
}
// Deleting a missing group is a no-op.
if err := s.DeleteGroup("acme"); err != nil {
t.Fatalf("DeleteGroup missing: %v", err)
}
}
// failingGroupDeletePersister wraps a real store but fails the transactional group delete.
type failingGroupDeletePersister struct {
access.SQLPersister
}
func (failingGroupDeletePersister) DeleteGroupWithRules(string, []access.RuleUpsert, []access.RuleKey) error {
return stderrors.New("simulated sql failure")
}
func TestDeleteGroup_FailureLeavesGroupAndRulesIntact(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("acme", []string{"alice"}); err != nil {
t.Fatalf("SetGroupMembers: %v", err)
}
if err := s.AllowGroup("mnt/storage", idxPath("/tenant"), "acme"); err != nil {
t.Fatalf("AllowGroup: %v", err)
}
if err := s.DenyGroup("mnt/storage", idxPath("/private"), "acme"); err != nil {
t.Fatalf("DenyGroup: %v", err)
}
rulesBefore := len(s.GetRulesForGroup("mnt/storage", "acme"))
if rulesBefore != 2 {
t.Fatalf("expected 2 rules before delete, got %d", rulesBefore)
}
s.SetSQLStore(failingGroupDeletePersister{sqlStore})
if err := s.DeleteGroup("acme"); err == nil {
t.Fatal("expected DeleteGroup to return the SQL error")
}
if got := s.GetGroupMembers()["acme"]; len(got) != 1 || got[0] != "alice" {
t.Fatalf("group should be restored after a failed delete, got %v", got)
}
if got := len(s.GetRulesForGroup("mnt/storage", "acme")); got != rulesBefore {
t.Fatalf("rules should be restored after a failed delete: want %d, got %d", rulesBefore, got)
}
// SQL was never changed.
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatal(err)
}
if _, ok := sqlGroups["acme"]; !ok {
t.Fatal("group row should still be in SQL")
}
sqlRules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
if len(sqlRules["mnt/storage"]) != 2 {
t.Fatalf("rule rows should still be in SQL, got %v", sqlRules["mnt/storage"])
}
}
func TestDeleteGroup_RemovesGroupAndRuleRowsFromSQL(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("acme", []string{"alice"}); err != nil {
t.Fatalf("SetGroupMembers: %v", err)
}
if err := s.AllowGroup("mnt/storage", idxPath("/tenant"), "acme"); err != nil {
t.Fatalf("AllowGroup: %v", err)
}
if err := s.AllowUser("mnt/storage", idxPath("/tenant"), "alice"); err != nil {
t.Fatalf("AllowUser: %v", err)
}
if err := s.DeleteGroup("acme"); err != nil {
t.Fatalf("DeleteGroup: %v", err)
}
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatal(err)
}
if _, ok := sqlGroups["acme"]; ok {
t.Fatal("group row should be gone from SQL")
}
sqlRules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
for _, rule := range sqlRules["mnt/storage"] {
if _, has := rule.Allow.Groups["acme"]; has {
t.Fatal("no stored rule should still reference the deleted group")
}
}
// The rule that also allowed a user must survive with only the group removed.
if len(sqlRules["mnt/storage"]) != 1 {
t.Fatalf("expected the user rule to remain, got %v", sqlRules["mnt/storage"])
}
}
func TestDeleteGroup_KeepsDenyAllRule(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
// "Deny everyone except group acme": a DenyAll rule whose only allow entry is the group.
if err := s.SetGroupMembers("acme", []string{"alice"}); err != nil {
t.Fatalf("SetGroupMembers: %v", err)
}
if err := s.DenyAll("mnt/storage", idxPath("/tenant")); err != nil {
t.Fatalf("DenyAll: %v", err)
}
if err := s.AllowGroup("mnt/storage", idxPath("/tenant"), "acme"); err != nil {
t.Fatalf("AllowGroup: %v", err)
}
if !s.Permitted("mnt/storage", idxPath("/tenant"), "alice") {
t.Fatal("alice should be permitted through the group before it is deleted")
}
if err := s.DeleteGroup("acme"); err != nil {
t.Fatalf("DeleteGroup: %v", err)
}
// Deleting the group must not remove the deny-all rule and open the path to everyone.
if s.Permitted("mnt/storage", idxPath("/tenant"), "alice") || s.Permitted("mnt/storage", idxPath("/tenant"), "bob") {
t.Fatal("path must stay denied for everyone after the allowed group is deleted")
}
rules := s.GetRulesForGroup("mnt/storage", "acme")
if len(rules) != 0 {
t.Fatalf("no rule should reference the deleted group, got %v", rules)
}
sqlRules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
stored := 0
for _, r := range sqlRules["mnt/storage"] {
if r.DenyAll {
stored++
}
}
if stored != 1 {
t.Fatalf("expected the deny-all rule to remain stored in SQL, found %d", stored)
}
}
@@ -0,0 +1,174 @@
package access_test
import (
"errors"
"maps"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
type stubGroupSQL struct {
saveErr error
deleteErr error
}
func (s *stubGroupSQL) SaveAccessRule(string, string, *access.AccessRule) error { return nil }
func (s *stubGroupSQL) DeleteAccessRule(string, string) error { return nil }
func (s *stubGroupSQL) SaveGroup(string, access.StringSet) error {
return s.saveErr
}
func (s *stubGroupSQL) DeleteGroup(string) error { return s.deleteErr }
func (s *stubGroupSQL) DeleteGroupWithRules(string, []access.RuleUpsert, []access.RuleKey) error {
return s.deleteErr
}
func (s *stubGroupSQL) SaveRevokedToken(string, int64) error {
return nil
}
func (s *stubGroupSQL) PersistImmediateTokenRevocation(string) error { return nil }
func (s *stubGroupSQL) PersistTokenRetirement(string, int64, []string) error { return nil }
func (s *stubGroupSQL) DeleteRevokedToken(string) error { return nil }
func (s *stubGroupSQL) SaveHashedToken(string, uint64, bool, int64) error { return nil }
func (s *stubGroupSQL) DeleteHashedToken(string) error { return nil }
func (s *stubGroupSQL) DeleteHashedTokensByUserID(uint64) error {
return nil
}
type recordingGroupSQL struct {
groups map[string]access.StringSet
saveCalls int
failOn int
}
func (s *recordingGroupSQL) SaveAccessRule(string, string, *access.AccessRule) error { return nil }
func (s *recordingGroupSQL) DeleteAccessRule(string, string) error { return nil }
func (s *recordingGroupSQL) SaveGroup(name string, members access.StringSet) error {
if s.groups == nil {
s.groups = make(map[string]access.StringSet)
}
s.saveCalls++
if s.failOn > 0 && s.saveCalls == s.failOn {
return errors.New("save failed")
}
s.groups[name] = cloneStringSet(members)
return nil
}
func (s *recordingGroupSQL) DeleteGroup(name string) error {
if s.groups == nil {
s.groups = make(map[string]access.StringSet)
}
delete(s.groups, name)
return nil
}
func (s *recordingGroupSQL) DeleteGroupWithRules(name string, _ []access.RuleUpsert, _ []access.RuleKey) error {
return s.DeleteGroup(name)
}
func (s *recordingGroupSQL) SaveRevokedToken(string, int64) error { return nil }
func (s *recordingGroupSQL) PersistImmediateTokenRevocation(string) error { return nil }
func (s *recordingGroupSQL) PersistTokenRetirement(string, int64, []string) error { return nil }
func (s *recordingGroupSQL) DeleteRevokedToken(string) error { return nil }
func (s *recordingGroupSQL) SaveHashedToken(string, uint64, bool, int64) error { return nil }
func (s *recordingGroupSQL) DeleteHashedToken(string) error { return nil }
func (s *recordingGroupSQL) DeleteHashedTokensByUserID(uint64) error { return nil }
func cloneStringSet(src access.StringSet) access.StringSet {
if len(src) == 0 {
return nil
}
dst := make(access.StringSet, len(src))
maps.Copy(dst, src)
return dst
}
func TestAddUserToGroup_RollsBackOnSQLFailure(t *testing.T) {
userStore := users.NewStorage(nil)
store := access.NewStorage(userStore)
store.SetSQLStore(&stubGroupSQL{saveErr: errors.New("save failed")})
err := store.AddUserToGroup("editors", "alice")
if err == nil {
t.Fatal("expected SQL save error")
}
groups := store.GetUserGroups("alice")
if len(groups) != 0 {
t.Fatalf("expected in-memory rollback, alice groups = %#v", groups)
}
}
func TestRemoveUserFromGroup_RollsBackOnSQLFailure(t *testing.T) {
userStore := users.NewStorage(nil)
store := access.NewStorage(userStore)
if err := store.AddUserToGroup("editors", "alice"); err != nil {
t.Fatalf("seed group: %v", err)
}
store.SetSQLStore(&stubGroupSQL{saveErr: errors.New("save failed")})
err := store.RemoveUserFromGroup("editors", "alice")
if err == nil {
t.Fatal("expected SQL save error")
}
groups := store.GetUserGroups("alice")
if len(groups) != 1 || groups[0] != "editors" {
t.Fatalf("expected in-memory rollback, alice groups = %#v", groups)
}
}
func TestAllowGroup_ReturnsSQLSaveError(t *testing.T) {
setupTestSources()
store := access.NewStorage(users.NewStorage(nil))
store.SetSQLStore(&stubGroupSQL{saveErr: errors.New("save failed")})
err := store.AllowGroup("mnt/storage", idxPath("/tenant"), "acme")
if err == nil {
t.Fatal("expected SQL save error from AllowGroup")
}
}
func TestEnsureGroupExistsNL_RollsBackOnSQLFailure(t *testing.T) {
setupTestSources()
store := access.NewStorage(users.NewStorage(nil))
store.SetSQLStore(&stubGroupSQL{saveErr: errors.New("save failed")})
err := store.AllowGroup("mnt/storage", idxPath("/tenant"), "newgroup")
if err == nil {
t.Fatal("expected SQL save error")
}
if groups := store.GetAllGroups(); len(groups) != 0 {
t.Fatalf("expected no in-memory group after failed ensure, got %#v", groups)
}
}
func TestSyncUserGroups_RollsBackOnPartialPersistFailure(t *testing.T) {
userStore := users.NewStorage(nil)
store := access.NewStorage(userStore)
store.Groups["groupA"] = access.StringSet{"alice": {}}
rec := &recordingGroupSQL{
failOn: 2,
groups: map[string]access.StringSet{
"groupA": {"alice": {}},
},
}
store.SetSQLStore(rec)
err := store.SyncUserGroups("alice", []string{"groupB"})
if err == nil {
t.Fatal("expected partial persist failure")
}
groups := store.GetUserGroups("alice")
if len(groups) != 1 || groups[0] != "groupA" {
t.Fatalf("expected in-memory rollback to groupA only, got %#v", groups)
}
if _, ok := store.Groups["groupB"]; ok {
t.Fatal("groupB should not remain in memory after rollback")
}
if rec.saveCalls != 3 {
t.Fatalf("saveCalls = %d, want 3 (groupA persist, groupB fail, groupA restore)", rec.saveCalls)
}
if _, ok := rec.groups["groupA"]["alice"]; !ok {
t.Fatalf("expected SQL rollback for groupA, got %#v", rec.groups["groupA"])
}
if _, ok := rec.groups["groupB"]; ok {
t.Fatalf("expected SQL groupB to be absent after rollback, got %#v", rec.groups["groupB"])
}
}
@@ -0,0 +1,12 @@
package access
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
@@ -16,18 +16,19 @@ func (failingRevokePersister) SaveAccessRule(string, string, *access.AccessRule)
func (failingRevokePersister) DeleteAccessRule(string, string) error { return nil }
func (failingRevokePersister) SaveGroup(string, access.StringSet) error { return nil }
func (failingRevokePersister) DeleteGroup(string) error { return nil }
func (failingRevokePersister) SaveRevokedToken(string, int64) error { return nil }
func (failingRevokePersister) DeleteGroupWithRules(string, []access.RuleUpsert, []access.RuleKey) error {
return nil
}
func (failingRevokePersister) SaveRevokedToken(string, int64) error { return nil }
func (failingRevokePersister) PersistImmediateTokenRevocation(string) error {
return errors.New("simulated revocation persistence failure")
}
func (failingRevokePersister) PersistTokenRetirement(string, int64, []string) error {
return nil
return errors.New("simulated retirement persistence failure")
}
func (failingRevokePersister) DeleteRevokedToken(string) error { return nil }
func (failingRevokePersister) SaveHashedToken(string, uint64, bool, int64) error {
return nil
}
func (failingRevokePersister) DeleteHashedToken(string) error { return nil }
func (failingRevokePersister) DeleteRevokedToken(string) error { return nil }
func (failingRevokePersister) SaveHashedToken(string, uint64, bool, int64) error { return nil }
func (failingRevokePersister) DeleteHashedToken(string) error { return nil }
func (failingRevokePersister) DeleteHashedTokensByUserID(uint64) error { return nil }
func TestSessionAndApiTokenMetadata(t *testing.T) {
@@ -74,6 +75,24 @@ func TestRevokeTokenRollsBackMemoryOnPersistenceFailure(t *testing.T) {
}
}
func TestRetireTokenRollsBackMemoryOnPersistenceFailure(t *testing.T) {
store, _ := createTestStorage(t)
store.SetSQLStore(failingRevokePersister{})
if err := store.AddSessionToken("tok", 1); err != nil {
t.Fatal(err)
}
if err := store.RetireToken("tok"); err == nil {
t.Fatal("expected RetireToken to propagate persistence failure")
}
if store.IsTokenRevoked("tok") {
t.Fatal("failed retirement must not leave in-memory revoked state")
}
if _, ok := store.GetHashedTokenInfo("tok"); !ok {
t.Fatal("failed retirement must keep owner mapping")
}
}
func TestRevokeTokenIsImmediate(t *testing.T) {
store, _ := createTestStorage(t)
@@ -24,6 +24,9 @@ const (
EventAccessUpdate EventType = "accessUpdate"
EventAccessCreate EventType = "accessCreate"
EventAccessDelete EventType = "accessDelete"
EventQuotaCreate EventType = "quotaCreate"
EventQuotaUpdate EventType = "quotaUpdate"
EventQuotaDelete EventType = "quotaDelete"
EventLogin EventType = "login"
EventLogout EventType = "logout"
EventSignup EventType = "signup"
@@ -54,6 +57,9 @@ var AllEventTypes = []EventType{
EventAccessUpdate,
EventAccessCreate,
EventAccessDelete,
EventQuotaCreate,
EventQuotaUpdate,
EventQuotaDelete,
EventLogin,
EventLogout,
EventSignup,
@@ -84,6 +90,13 @@ var AccessEventTypes = []EventType{
EventAccessDelete,
}
// QuotaEventTypes are folder quota mutations (scope=quotas).
var QuotaEventTypes = []EventType{
EventQuotaCreate,
EventQuotaUpdate,
EventQuotaDelete,
}
// ShareEventTypes are share lifecycle events (scope=shares). Share downloads are
// EventDownload rows with a non-empty details.shareHash.
var ShareEventTypes = []EventType{
@@ -108,13 +121,15 @@ func ResolveScopeEventTypes(scope string, explicit []EventType) ([]EventType, er
allowed = FileEventTypes
case "access":
allowed = AccessEventTypes
case "quotas":
allowed = QuotaEventTypes
case "shares":
if len(explicit) == 0 {
return nil, nil
}
allowed = ShareScopeEventTypes
default:
return nil, fmt.Errorf("scope must be all, files, access, or shares")
return nil, fmt.Errorf("scope must be all, files, access, quotas, or shares")
}
if len(explicit) == 0 {
return allowed, nil
@@ -144,6 +159,7 @@ func (e EventType) Valid() bool {
EventArchive, EventUnarchive,
EventShareCreate, EventShareUpdate, EventShareDelete,
EventUserCreate, EventUserUpdate, EventUserDelete, EventAccessUpdate, EventAccessCreate, EventAccessDelete,
EventQuotaCreate, EventQuotaUpdate, EventQuotaDelete,
EventLogin, EventLogout, EventSignup,
EventPasskeyRegister, EventPasskeyDelete,
EventTokenCreate, EventTokenDelete,
@@ -44,6 +44,18 @@ func TestResolveScopeEventTypes(t *testing.T) {
t.Fatalf("access default missing %q in %v", et, got)
}
}
got, err = ResolveScopeEventTypes("quotas", nil)
if err != nil {
t.Fatal(err)
}
if len(got) != len(QuotaEventTypes) {
t.Fatalf("quotas default: got %d types, want %d", len(got), len(QuotaEventTypes))
}
for _, et := range QuotaEventTypes {
if !containsEventType(got, et) {
t.Fatalf("quotas default missing %q in %v", et, got)
}
}
}
func containsEventType(list []EventType, target EventType) bool {
+91
View File
@@ -0,0 +1,91 @@
package quota
import (
"fmt"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
)
// MeasurementStatusReady means enforcement uses the configured meter.
const MeasurementStatusReady = "ready"
// MeasurementStatusAccountedFallback means index meter configured but accounted counter used.
const MeasurementStatusAccountedFallback = "accounted_fallback"
// IsIndexMeter reports whether meter counts usage from the search index.
func IsIndexMeter(meter string) bool {
return meter == MeterIndexSize || meter == MeterIndexScope
}
// IndexingEnabled reports whether folder sizes are available from the index on this source.
func IndexingEnabled(sourceName string) bool {
idx := indexing.GetIndex(sourceName)
if idx == nil {
return false
}
return !idx.Config.ResolvedRules.IndexingDisabled
}
// IndexSizeAvailable reports whether GetFolderSize succeeds for the quota root path.
func IndexSizeAvailable(sourceName, indexPath string) bool {
idx := indexing.GetIndex(sourceName)
if idx == nil || idx.Config.ResolvedRules.IndexingDisabled {
return false
}
path := normalizeMeterPath(indexPath)
_, ok := idx.GetFolderSizeForIndexPath(path)
return ok
}
// EffectiveMeter resolves runtime meter: accounted when indexing off, configured index when size known, else accounted fallback.
func EffectiveMeter(configuredMeter, sourceName, quotaRootPath string) string {
if configuredMeter == MeterAccounted || !IsIndexMeter(configuredMeter) {
return MeterAccounted
}
if !IndexingEnabled(sourceName) {
return MeterAccounted
}
if IndexSizeAvailable(sourceName, quotaRootPath) {
return configuredMeter
}
return MeterAccounted
}
// MeasurementStatus compares configured vs effective meter for API snapshots.
func MeasurementStatus(configuredMeter, effectiveMeter string) string {
if IsIndexMeter(configuredMeter) && effectiveMeter == MeterAccounted {
return MeasurementStatusAccountedFallback
}
return MeasurementStatusReady
}
// ValidateConfiguredMeter rejects index meters when indexing is disabled on the source.
func ValidateConfiguredMeter(sourceName, meter string) error {
meter = strings.TrimSpace(meter)
if meter == "" {
return nil
}
if meter == MeterAccounted {
return nil
}
if IsIndexMeter(meter) {
if !IndexingEnabled(sourceName) {
return fmt.Errorf("index meter not allowed when indexing is disabled on source %q", sourceName)
}
return nil
}
return fmt.Errorf("invalid quota meter %q", meter)
}
func normalizeMeterPath(path string) string {
path = strings.TrimSpace(path)
path = strings.TrimSuffix(path, "/")
if path == "" {
return "/"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
@@ -0,0 +1,116 @@
package quota_test
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func setupMeterTestSource(t *testing.T, name, path string, indexingDisabled bool) {
t.Helper()
prevSourceMap := settings.Config.Server.SourceMap
prevNameToSource := settings.Config.Server.NameToSource
t.Cleanup(func() {
indexing.ClearTestIndices()
settings.Config.Server.SourceMap = prevSourceMap
settings.Config.Server.NameToSource = prevNameToSource
})
indexing.SetTestIndex(name, path)
idx := indexing.GetIndex(name)
if idx == nil {
t.Fatal("expected test index")
}
idx.Config.ResolvedRules.IndexingDisabled = indexingDisabled
settings.Config.Server.SourceMap = map[string]*settings.Source{
path: {Path: path, Name: name},
}
settings.Config.Server.NameToSource = map[string]*settings.Source{
name: settings.Config.Server.SourceMap[path],
}
}
func TestValidateConfiguredMeter_IndexingDisabled(t *testing.T) {
root := t.TempDir()
name := "meter-src"
setupMeterTestSource(t, name, root, true)
if err := quota.ValidateConfiguredMeter(name, quota.MeterIndexSize); err == nil {
t.Fatal("expected error for index_size when indexing disabled")
}
if err := quota.ValidateConfiguredMeter(name, quota.MeterIndexScope); err == nil {
t.Fatal("expected error for index_scope when indexing disabled")
}
if err := quota.ValidateConfiguredMeter(name, quota.MeterAccounted); err != nil {
t.Fatalf("accounted should be allowed: %v", err)
}
}
func TestEffectiveMeter_IndexingDisabledForcesAccounted(t *testing.T) {
root := t.TempDir()
name := "meter-src2"
setupMeterTestSource(t, name, root, true)
got := quota.EffectiveMeter(quota.MeterIndexSize, name, "/")
if got != quota.MeterAccounted {
t.Fatalf("expected accounted, got %q", got)
}
}
func TestEffectiveMeter_ExplicitAccountedWhenIndexOn(t *testing.T) {
root := t.TempDir()
name := "meter-src3"
setupMeterTestSource(t, name, root, false)
idx := indexing.GetIndex(name)
idx.SetFolderSize("/", 1024)
got := quota.EffectiveMeter(quota.MeterAccounted, name, "/")
if got != quota.MeterAccounted {
t.Fatalf("expected accounted, got %q", got)
}
}
func TestEffectiveMeter_IndexWhenSizeAvailable(t *testing.T) {
root := t.TempDir()
name := "meter-src4"
setupMeterTestSource(t, name, root, false)
idx := indexing.GetIndex(name)
idx.SetFolderSize("/", 2048)
got := quota.EffectiveMeter(quota.MeterIndexSize, name, "/")
if got != quota.MeterIndexSize {
t.Fatalf("expected index_size, got %q", got)
}
status := quota.MeasurementStatus(quota.MeterIndexSize, got)
if status != quota.MeasurementStatusReady {
t.Fatalf("expected ready, got %q", status)
}
}
func TestEffectiveMeter_IndexPathTrailingSlash(t *testing.T) {
root := t.TempDir()
name := "meter-src6"
setupMeterTestSource(t, name, root, false)
idx := indexing.GetIndex(name)
idx.SetFolderSize("/projects/", 4096)
got := quota.EffectiveMeter(quota.MeterIndexSize, name, "/projects")
if got != quota.MeterIndexSize {
t.Fatalf("expected index_size for path without trailing slash, got %q", got)
}
}
func TestEffectiveMeter_RuntimeFallback(t *testing.T) {
root := t.TempDir()
name := "meter-src5"
setupMeterTestSource(t, name, root, false)
got := quota.EffectiveMeter(quota.MeterIndexScope, name, "/missing")
if got != quota.MeterAccounted {
t.Fatalf("expected accounted fallback, got %q", got)
}
status := quota.MeasurementStatus(quota.MeterIndexScope, got)
if status != quota.MeasurementStatusAccountedFallback {
t.Fatalf("expected accounted_fallback, got %q", status)
}
}
+43
View File
@@ -0,0 +1,43 @@
package quota
// Meter names for scope and folder quotas.
const (
MeterIndexScope = "index_scope"
MeterIndexSize = "index_size"
MeterAccounted = "accounted"
)
// FolderQuota is a path-bound storage cap with configurable meter.
type FolderQuota struct {
ID string `json:"id"`
Source string `json:"source"` // backend source path
Path string `json:"path"` // index path within source
UserID uint64 `json:"userId,omitempty"` // 0 = all users
LimitBytes int64 `json:"limitBytes"`
Meter string `json:"meter,omitempty"` // index_size | index_scope (indexed size) | accounted (tracked usage)
}
// Counter holds accounted usage for a quota_id.
type Counter struct {
QuotaID string `json:"quotaId"`
UsedBytes int64 `json:"usedBytes"`
ReservedBytes int64 `json:"reservedBytes"`
}
// Snapshot is a read model for API and admission checks.
type Snapshot struct {
QuotaID string `json:"quotaId"`
Kind string `json:"quotaKind"` // folder, scope, share
LimitBytes int64 `json:"limitBytes"`
UsedBytes int64 `json:"usedBytes"`
ReservedBytes int64 `json:"reservedBytes"`
Meter string `json:"meter,omitempty"` // effective meter (backward compatible)
ConfiguredMeter string `json:"configuredMeter,omitempty"`
EffectiveMeter string `json:"effectiveMeter,omitempty"`
MeasurementStatus string `json:"measurementStatus,omitempty"` // ready, accounted_fallback
}
// ShareQuotaID returns the counter key for a share quota.
func ShareQuotaID(hash string) string {
return "share:" + hash
}
+19 -2
View File
@@ -48,6 +48,7 @@ type ShareLimits struct {
PerUserDownloadLimit bool `json:"perUserDownloadLimit,omitempty"`
ExtractEmbeddedSubtitles bool `json:"extractEmbeddedSubtitles,omitempty"`
DownloadsLimit int `json:"downloadsLimit,omitempty"`
QuotaLimitBytes int64 `json:"quotaLimitBytes,omitempty"`
HideFileExt string `json:"hideFileExt,omitempty"` // show hidden files based on extensions in shares
Banner string `json:"banner,omitempty"`
SourceName string `json:"source,omitempty"` // source display name for API; backend path is Share.SourcePath
@@ -85,8 +86,11 @@ type ShareEditable struct {
type ShareFrontend struct {
ShareEditable
ShareColumns
Username string `json:"username,omitempty"`
PathExists bool `json:"pathExists,omitempty"`
Username string `json:"username,omitempty"`
PathExists bool `json:"pathExists,omitempty"`
QuotaUsedBytes int64 `json:"quotaUsedBytes,omitempty"`
QuotaReservedBytes int64 `json:"quotaReservedBytes,omitempty"`
QuotaAvailableBytes int64 `json:"quotaAvailableBytes,omitempty"`
}
// SharePostBody is POST /api/share JSON. Plaintext password is hashed to Share.PasswordHash before persist.
@@ -98,6 +102,19 @@ type SharePostBody struct {
Path string `json:"path,omitempty"`
}
// EditableFromShare returns the client-editable subset of a persisted share.
// ShareExpiryInput is omitted because expiration is stored in ShareColumns.Expire;
// callers merging partial updates must preserve Expire when expires/unit are absent.
func EditableFromShare(s *Share) ShareEditable {
if s == nil {
return ShareEditable{}
}
return ShareEditable{
FrontendShareInfo: s.FrontendShareInfo,
ShareLimits: s.ShareLimits,
}
}
// ApplyPostBodyUpdate copies client-editable fields onto link.
// Caller must preserve path, sourcePath, pinnedItems, version, download counters, and secrets.
func ApplyPostBodyUpdate(link *Share, req *SharePostBody, expire int64) {
+37
View File
@@ -101,6 +101,43 @@ func (s *SQLStore) SaveGroup(name string, members access.StringSet) error {
return nil
}
// DeleteGroupWithRules deletes a group and applies access rule row changes in a single
// transaction. If any statement fails nothing is written. A group row that is already
// absent is not an error, so the call is safe to retry.
func (s *SQLStore) DeleteGroupWithRules(name string, save []access.RuleUpsert, remove []access.RuleKey) error {
tx, err := s.BeginTx()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
rollback := func(cause error) error {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("%w (rollback also failed: %v)", cause, rbErr)
}
return cause
}
for _, u := range save {
ruleDataJSON, err := json.Marshal(u.Rule)
if err != nil {
return rollback(fmt.Errorf("failed to marshal rule data: %w", err))
}
if _, err := tx.Exec(`INSERT OR REPLACE INTO access_rules (source, path, rule_data) VALUES (?, ?, ?)`, u.Source, u.Path, ruleDataJSON); err != nil {
return rollback(fmt.Errorf("failed to save access rule: %w", err))
}
}
for _, k := range remove {
if _, err := tx.Exec(`DELETE FROM access_rules WHERE source = ? AND path = ?`, k.Source, k.Path); err != nil {
return rollback(fmt.Errorf("failed to delete access rule: %w", err))
}
}
if _, err := tx.Exec(`DELETE FROM groups WHERE name = ?`, name); err != nil {
return rollback(fmt.Errorf("failed to delete group: %w", err))
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit group delete: %w", err)
}
return nil
}
// DeleteGroup deletes a group by name
func (s *SQLStore) DeleteGroup(name string) error {
query := `DELETE FROM groups WHERE name = ?`
@@ -0,0 +1,98 @@
package sqldb
import (
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
)
// newGroupTestStore opens a fresh SQLStore in a temp dir.
func newGroupTestStore(t *testing.T) *SQLStore {
t.Helper()
store, _, err := NewSQLStore(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("failed to create SQL store: %v", err)
}
t.Cleanup(func() { store.Close() })
return store
}
// groupRule returns a rule that allows the given group.
func groupRule(group string) *access.AccessRule {
return &access.AccessRule{Allow: access.RuleSet{
Users: access.StringSet{},
Groups: access.StringSet{group: {}},
}}
}
func TestDeleteGroupWithRules_CommitsAllChanges(t *testing.T) {
store := newGroupTestStore(t)
keep := access.RuleKey{Source: "src", Path: "/keep"}
drop := access.RuleKey{Source: "src", Path: "/drop"}
if err := store.SaveGroup("acme", access.StringSet{"alice": {}}); err != nil {
t.Fatal(err)
}
if err := store.SaveAccessRule(keep.Source, keep.Path, groupRule("acme")); err != nil {
t.Fatal(err)
}
if err := store.SaveAccessRule(drop.Source, drop.Path, groupRule("acme")); err != nil {
t.Fatal(err)
}
// keep is rewritten (now allows "other"), drop is removed, the group row is deleted.
err := store.DeleteGroupWithRules("acme", []access.RuleUpsert{{RuleKey: keep, Rule: groupRule("other")}}, []access.RuleKey{drop})
if err != nil {
t.Fatalf("DeleteGroupWithRules: %v", err)
}
groups, err := store.GetAllGroups()
if err != nil {
t.Fatal(err)
}
if _, ok := groups["acme"]; ok {
t.Fatal("group row should be deleted")
}
rules, err := store.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
if _, ok := rules["src"]["/drop"]; ok {
t.Fatal("dropped rule row should be deleted")
}
kept, ok := rules["src"]["/keep"]
if !ok {
t.Fatal("kept rule row should still exist")
}
if _, has := kept.Allow.Groups["other"]; !has {
t.Fatalf("kept rule should have been rewritten, got %+v", kept.Allow.Groups)
}
// Deleting again (group row already absent) is not an error.
if err := store.DeleteGroupWithRules("acme", nil, nil); err != nil {
t.Fatalf("repeat delete should succeed: %v", err)
}
}
func TestDeleteGroupWithRules_RollsBackOnFailure(t *testing.T) {
store := newGroupTestStore(t)
if err := store.SaveGroup("acme", access.StringSet{"alice": {}}); err != nil {
t.Fatal(err)
}
// Make the rule statements fail so the transaction has to roll back.
if _, err := store.db.Exec(`DROP TABLE access_rules`); err != nil {
t.Fatal(err)
}
err := store.DeleteGroupWithRules("acme", []access.RuleUpsert{{RuleKey: access.RuleKey{Source: "src", Path: "/x"}, Rule: groupRule("other")}}, nil)
if err == nil {
t.Fatal("expected an error when the rule write fails")
}
groups, gErr := store.GetAllGroups()
if gErr != nil {
t.Fatal(gErr)
}
if _, ok := groups["acme"]; !ok {
t.Fatal("group row must survive a failed transaction")
}
}
@@ -0,0 +1,12 @@
package sqldb
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
@@ -124,6 +124,28 @@ func createSchema(db *sql.DB) error {
CREATE INDEX IF NOT EXISTS idx_activity_user_created ON activity_log(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_activity_event_created ON activity_log(event_type, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_activity_user_event_created ON activity_log(user_id, event_type, created_at DESC);
-- Folder quotas (path-bound caps; meter: index_size | accounted)
CREATE TABLE IF NOT EXISTS quotas (
id TEXT PRIMARY KEY NOT NULL,
source TEXT NOT NULL,
path TEXT NOT NULL,
user_id TEXT,
limit_bytes INTEGER NOT NULL DEFAULT 0,
meter TEXT NOT NULL DEFAULT 'index_size',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_quotas_source ON quotas(source);
CREATE INDEX IF NOT EXISTS idx_quotas_source_path ON quotas(source, path);
-- Accounted usage counters (scope, share, optional folder accounted in future)
CREATE TABLE IF NOT EXISTS quota_counters (
quota_id TEXT PRIMARY KEY NOT NULL,
used_bytes INTEGER NOT NULL DEFAULT 0,
reserved_bytes INTEGER NOT NULL DEFAULT 0,
version INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
`
_, err := db.Exec(schema)
@@ -184,6 +206,9 @@ func runMigrations(db *sql.DB, fromVersion int) error {
case 1:
// Canonical schema is createSchema + Bolt import; no step migrations.
case 2:
if err := migrateV2(db); err != nil {
return err
}
if err := normalizeLegacyShareTokens(db); err != nil {
return err
}
@@ -209,6 +234,48 @@ func runMigrations(db *sql.DB, fromVersion int) error {
return nil
}
func migrateV2(db *sql.DB) error {
schema := `
CREATE TABLE IF NOT EXISTS quotas (
id TEXT PRIMARY KEY NOT NULL,
source TEXT NOT NULL,
path TEXT NOT NULL,
user_id TEXT,
limit_bytes INTEGER NOT NULL DEFAULT 0,
meter TEXT NOT NULL DEFAULT 'index_size',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_quotas_source ON quotas(source);
CREATE INDEX IF NOT EXISTS idx_quotas_source_path ON quotas(source, path);
CREATE TABLE IF NOT EXISTS quota_counters (
quota_id TEXT PRIMARY KEY NOT NULL,
used_bytes INTEGER NOT NULL DEFAULT 0,
reserved_bytes INTEGER NOT NULL DEFAULT 0,
version INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
`
if _, err := db.Exec(schema); err != nil {
return fmt.Errorf("migrate v2: %w", err)
}
return migrateV2AddMeterColumn(db)
}
func migrateV2AddMeterColumn(db *sql.DB) error {
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('quotas') WHERE name = 'meter'`).Scan(&count); err != nil {
return fmt.Errorf("migrate v2 meter column check: %w", err)
}
if count > 0 {
return nil
}
if _, err := db.Exec(`ALTER TABLE quotas ADD COLUMN meter TEXT NOT NULL DEFAULT 'index_size'`); err != nil {
return fmt.Errorf("migrate v2 add meter column: %w", err)
}
return nil
}
// addHashedTokenSessionColumn adds the is_session flag to pre-existing
// hashed_tokens tables. Existing rows are treated as non-session (API) tokens.
func addHashedTokenSessionColumn(db *sql.DB) error {
+221
View File
@@ -0,0 +1,221 @@
package sqldb
import (
"database/sql"
"fmt"
"strconv"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
)
func scanQuotaUserID(s string, dest *uint64) error {
if s == "" {
*dest = 0
return nil
}
u, err := strconv.ParseUint(s, 10, 64)
if err != nil {
return fmt.Errorf("parse quota user_id: %w", err)
}
*dest = u
return nil
}
func quotaUserIDDB(id uint64) string {
if id == 0 {
return ""
}
return strconv.FormatUint(id, 10)
}
// GetAllFolderQuotas loads every folder quota row.
func (s *SQLStore) GetAllFolderQuotas() ([]quota.FolderQuota, error) {
rows, err := s.db.Query(`SELECT id, source, path, user_id, limit_bytes, meter FROM quotas`)
if err != nil {
return nil, fmt.Errorf("query quotas: %w", err)
}
defer rows.Close()
var out []quota.FolderQuota
for rows.Next() {
var q quota.FolderQuota
var userIDStr string
if err := rows.Scan(&q.ID, &q.Source, &q.Path, &userIDStr, &q.LimitBytes, &q.Meter); err != nil {
return nil, fmt.Errorf("scan quota: %w", err)
}
if err := scanQuotaUserID(userIDStr, &q.UserID); err != nil {
return nil, err
}
out = append(out, q)
}
return out, rows.Err()
}
// GetFolderQuotaByID returns one folder quota.
func (s *SQLStore) GetFolderQuotaByID(id string) (*quota.FolderQuota, error) {
var q quota.FolderQuota
var userIDStr string
err := s.db.QueryRow(`SELECT id, source, path, user_id, limit_bytes, meter FROM quotas WHERE id = ?`, id).
Scan(&q.ID, &q.Source, &q.Path, &userIDStr, &q.LimitBytes, &q.Meter)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("quota not found")
}
if err != nil {
return nil, fmt.Errorf("get quota: %w", err)
}
if err := scanQuotaUserID(userIDStr, &q.UserID); err != nil {
return nil, err
}
return &q, nil
}
// GetFolderQuotasForSourcePath returns quotas for a source and optional path prefix.
func (s *SQLStore) GetFolderQuotasForSourcePath(source, path string) ([]quota.FolderQuota, error) {
path = strings.TrimSuffix(path, "/")
query := `SELECT id, source, path, user_id, limit_bytes, meter FROM quotas WHERE source = ?`
args := []interface{}{source}
if path != "" {
query += ` AND (path = ? OR path LIKE ?)`
args = append(args, path, path+"/%")
}
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("query quotas for source: %w", err)
}
defer rows.Close()
var out []quota.FolderQuota
for rows.Next() {
var q quota.FolderQuota
var userIDStr string
if err := rows.Scan(&q.ID, &q.Source, &q.Path, &userIDStr, &q.LimitBytes, &q.Meter); err != nil {
return nil, fmt.Errorf("scan quota: %w", err)
}
if err := scanQuotaUserID(userIDStr, &q.UserID); err != nil {
return nil, err
}
out = append(out, q)
}
return out, rows.Err()
}
// SaveFolderQuota inserts or updates a folder quota.
func (s *SQLStore) SaveFolderQuota(q *quota.FolderQuota) error {
meter := q.Meter
if meter == "" {
meter = quota.MeterIndexSize
}
_, err := s.db.Exec(
`INSERT INTO quotas (id, source, path, user_id, limit_bytes, meter, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET source=excluded.source, path=excluded.path, user_id=excluded.user_id, limit_bytes=excluded.limit_bytes, meter=excluded.meter`,
q.ID, q.Source, q.Path, quotaUserIDDB(q.UserID), q.LimitBytes, meter, currentTimestamp(),
)
if err != nil {
return fmt.Errorf("save quota: %w", err)
}
return nil
}
// DeleteFolderQuota removes a folder quota and its counter.
func (s *SQLStore) DeleteFolderQuota(id string) error {
if _, err := s.db.Exec(`DELETE FROM quotas WHERE id = ?`, id); err != nil {
return fmt.Errorf("delete quota: %w", err)
}
if _, err := s.db.Exec(`DELETE FROM quota_counters WHERE quota_id = ?`, id); err != nil {
return fmt.Errorf("delete quota counter: %w", err)
}
return nil
}
// GetAllQuotaCounters loads all quota counters.
func (s *SQLStore) GetAllQuotaCounters() ([]quota.Counter, error) {
rows, err := s.db.Query(`SELECT quota_id, used_bytes, reserved_bytes FROM quota_counters`)
if err != nil {
return nil, fmt.Errorf("query quota counters: %w", err)
}
defer rows.Close()
var out []quota.Counter
for rows.Next() {
var c quota.Counter
if err := rows.Scan(&c.QuotaID, &c.UsedBytes, &c.ReservedBytes); err != nil {
return nil, fmt.Errorf("scan quota counter: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
// GetQuotaCounter returns one counter row.
func (s *SQLStore) GetQuotaCounter(quotaID string) (*quota.Counter, error) {
var c quota.Counter
err := s.db.QueryRow(`SELECT quota_id, used_bytes, reserved_bytes FROM quota_counters WHERE quota_id = ?`, quotaID).
Scan(&c.QuotaID, &c.UsedBytes, &c.ReservedBytes)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("quota counter not found")
}
if err != nil {
return nil, fmt.Errorf("get quota counter: %w", err)
}
return &c, nil
}
// EnsureQuotaCounter creates a counter row if missing.
func (s *SQLStore) EnsureQuotaCounter(quotaID string) error {
_, err := s.db.Exec(
`INSERT INTO quota_counters (quota_id, used_bytes, reserved_bytes, version, updated_at)
VALUES (?, 0, 0, 0, ?)
ON CONFLICT(quota_id) DO NOTHING`,
quotaID, currentTimestamp(),
)
if err != nil {
return fmt.Errorf("ensure quota counter: %w", err)
}
return nil
}
// DeleteQuotaCounter removes a counter row.
func (s *SQLStore) DeleteQuotaCounter(quotaID string) error {
_, err := s.db.Exec(`DELETE FROM quota_counters WHERE quota_id = ?`, quotaID)
if err != nil {
return fmt.Errorf("delete quota counter: %w", err)
}
return nil
}
// BulkUpsertQuotaCounters persists dirty counters.
func (s *SQLStore) BulkUpsertQuotaCounters(counters []quota.Counter) error {
if len(counters) == 0 {
return nil
}
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin quota counter tx: %w", err)
}
defer func() {
_ = tx.Rollback()
}()
stmt, err := tx.Prepare(
`INSERT INTO quota_counters (quota_id, used_bytes, reserved_bytes, version, updated_at)
VALUES (?, ?, ?, 0, ?)
ON CONFLICT(quota_id) DO UPDATE SET
used_bytes = excluded.used_bytes,
reserved_bytes = excluded.reserved_bytes,
updated_at = excluded.updated_at`,
)
if err != nil {
return fmt.Errorf("prepare quota counter upsert: %w", err)
}
defer stmt.Close()
now := currentTimestamp()
for _, c := range counters {
if _, err := stmt.Exec(c.QuotaID, c.UsedBytes, c.ReservedBytes, now); err != nil {
return fmt.Errorf("upsert quota counter %s: %w", c.QuotaID, err)
}
}
return tx.Commit()
}
+22 -6
View File
@@ -32,20 +32,36 @@ func (s *SQLStore) GetSetting(key string) ([]byte, error) {
// SaveSetting inserts or updates a setting
func (s *SQLStore) SaveSetting(key string, value interface{}) error {
query := `INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)`
if err := s.saveSettingQuery(s.db.Exec, query, key, value); err != nil {
return fmt.Errorf("failed to save setting: %w", err)
}
return nil
}
type settingExec func(query string, args ...interface{}) (sql.Result, error)
func (s *SQLStore) saveSettingQuery(exec settingExec, query, key string, value interface{}) error {
valueJSON, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("failed to marshal setting value: %w", err)
}
query := `INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)`
_, err = s.db.Exec(query, key, valueJSON)
if err != nil {
return fmt.Errorf("failed to save setting: %w", err)
if _, err := exec(query, key, valueJSON); err != nil {
return err
}
return nil
}
func (s *SQLStore) saveSettingInTx(tx *sql.Tx, key string, value interface{}) error {
query := `INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)`
return s.saveSettingQuery(tx.Exec, query, key, value)
}
// SaveSettingTx inserts or updates a setting within an existing transaction.
func (s *SQLStore) SaveSettingTx(tx *sql.Tx, key string, value interface{}) error {
return s.saveSettingInTx(tx, key, value)
}
// DeleteSetting deletes a setting by key
func (s *SQLStore) DeleteSetting(key string) error {
query := `DELETE FROM settings WHERE key = ?`
@@ -64,6 +64,7 @@ func APIScopesToBackend(apiScopes []FrontendScope) ([]BackendScope, error) {
Path: source.Path,
Scope: scope.Scope,
Permissions: frontendScopePermissions(scope),
Quota: scope.Quota,
})
}
return newScopes, nil
@@ -86,6 +87,7 @@ func (u *User) GetFrontendScopes() []FrontendScope {
Name: source.Name,
Scope: scope.Scope,
Permissions: &perms,
Quota: scope.Quota,
})
}
return newScopes
@@ -0,0 +1,164 @@
package users
import (
"encoding/json"
"testing"
)
func assertBoolMapEntries(t *testing.T, name string, got, want map[string]bool) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%s length = %d, want %d (got=%v want=%v)", name, len(got), len(want), got, want)
}
for key, wantVal := range want {
gotVal, ok := got[key]
if !ok {
t.Fatalf("%s missing key %q", name, key)
}
if gotVal != wantVal {
t.Fatalf("%s[%q] = %v, want %v", name, key, gotVal, wantVal)
}
}
}
func TestFrontendUserEffectiveToolAccessJSON(t *testing.T) {
t.Parallel()
u := FrontendUser{
Username: "alice",
EffectiveToolAccess: map[string]bool{
"sizeViewer": true,
"fileWatcher": false,
"activityViewer": true,
"duplicateFinder": false,
},
}
encoded, err := json.Marshal(u)
if err != nil {
t.Fatal(err)
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(encoded, &payload); err != nil {
t.Fatal(err)
}
if _, ok := payload["effectiveToolAccess"]; !ok {
t.Fatalf("expected effectiveToolAccess key in JSON, got keys: %v", mapKeys(payload))
}
var decoded FrontendUser
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
assertBoolMapEntries(t, "effectiveToolAccess", decoded.EffectiveToolAccess, u.EffectiveToolAccess)
}
func TestToolAccessMapRoundTripValidIDs(t *testing.T) {
t.Parallel()
original := ToolAccessMap{
ToolSizeViewer: true,
ToolFileWatcher: false,
ToolActivityViewer: true,
ToolDuplicateFinder: false,
ToolAdvancedSearch: true,
ToolMaterialIconPicker: false,
}
encoded, err := json.Marshal(original)
if err != nil {
t.Fatal(err)
}
var payload map[string]bool
if err := json.Unmarshal(encoded, &payload); err != nil {
t.Fatal(err)
}
if len(payload) != len(original) {
t.Fatalf("toolAccess JSON length = %d, want %d", len(payload), len(original))
}
var decoded ToolAccessMap
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
if len(decoded) != len(original) {
t.Fatalf("toolAccess length = %d, want %d", len(decoded), len(original))
}
for id, want := range original {
got, ok := decoded[id]
if !ok {
t.Fatalf("toolAccess missing key %q", id)
}
if got != want {
t.Fatalf("toolAccess[%q] = %v, want %v", id, got, want)
}
}
}
func TestToolAccessMapRejectsUnknownToolID(t *testing.T) {
t.Parallel()
var m ToolAccessMap
err := json.Unmarshal([]byte(`{"sizeViewer":true,"notATool":false}`), &m)
if err == nil {
t.Fatal("expected unknown tool id to be rejected")
}
}
func TestUserToolAccessJSONRoundTrip(t *testing.T) {
t.Parallel()
wantToolAccess := ToolAccessMap{
ToolSizeViewer: true,
ToolFileWatcher: false,
}
u := User{
FrontendUser: FrontendUser{
Username: "bob",
ToolAccess: wantToolAccess,
},
}
encoded, err := json.Marshal(u)
if err != nil {
t.Fatal(err)
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(encoded, &payload); err != nil {
t.Fatal(err)
}
if _, ok := payload["toolAccess"]; !ok {
t.Fatalf("expected toolAccess key in JSON, got keys: %v", mapKeys(payload))
}
var decoded User
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
if len(decoded.ToolAccess) != len(wantToolAccess) {
t.Fatalf("toolAccess length = %d, want %d", len(decoded.ToolAccess), len(wantToolAccess))
}
for id, want := range wantToolAccess {
got, ok := decoded.ToolAccess[id]
if !ok {
t.Fatalf("toolAccess missing key %q", id)
}
if got != want {
t.Fatalf("toolAccess[%q] = %v, want %v", id, got, want)
}
}
}
func mapKeys(m map[string]json.RawMessage) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
return keys
}
@@ -0,0 +1,91 @@
package users
import (
"encoding/json"
"fmt"
)
// ToolID identifies a built-in application tool (matches frontend route segments).
type ToolID string
const (
ToolSizeViewer ToolID = "sizeViewer"
ToolDuplicateFinder ToolID = "duplicateFinder"
ToolAdvancedSearch ToolID = "advancedSearch"
ToolMaterialIconPicker ToolID = "materialIconPicker"
ToolFileWatcher ToolID = "fileWatcher"
ToolActivityViewer ToolID = "activityViewer"
)
// AllToolIDs lists every defined tool in stable catalog order.
var AllToolIDs = []ToolID{
ToolSizeViewer,
ToolDuplicateFinder,
ToolAdvancedSearch,
ToolMaterialIconPicker,
ToolFileWatcher,
ToolActivityViewer,
}
// Valid reports whether id is a known tool constant.
func (id ToolID) Valid() bool {
switch id {
case ToolSizeViewer, ToolDuplicateFinder, ToolAdvancedSearch,
ToolMaterialIconPicker, ToolFileWatcher, ToolActivityViewer:
return true
default:
return false
}
}
// ParseToolID parses s into a ToolID when it names a catalog tool.
func ParseToolID(s string) (ToolID, error) {
id := ToolID(s)
if !id.Valid() {
return "", fmt.Errorf("unknown tool id %q", s)
}
return id, nil
}
// ToolAccessMap stores per-user tool grants keyed by ToolID.
// JSON uses string keys for API compatibility.
type ToolAccessMap map[ToolID]bool
// MarshalJSON encodes the map with string keys.
func (m ToolAccessMap) MarshalJSON() ([]byte, error) {
if m == nil {
return []byte("null"), nil
}
out := make(map[string]bool, len(m))
for id, granted := range m {
if id.Valid() {
out[string(id)] = granted
}
}
return json.Marshal(out)
}
// UnmarshalJSON decodes string-keyed JSON into typed ToolID keys.
func (m *ToolAccessMap) UnmarshalJSON(data []byte) error {
if m == nil {
return fmt.Errorf("ToolAccessMap: UnmarshalJSON on nil pointer")
}
if string(data) == "null" {
*m = nil
return nil
}
var raw map[string]bool
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
out := make(ToolAccessMap, len(raw))
for key, granted := range raw {
id, err := ParseToolID(key)
if err != nil {
return err
}
out[id] = granted
}
*m = out
return nil
}
+41 -27
View File
@@ -106,11 +106,11 @@ type MinimalAuthToken struct {
}
type Permissions struct {
Api bool `json:"api"` // allow api access
Admin bool `json:"admin"` // allow admin access
Api bool `json:"api"` // allow api access
Admin bool `json:"admin"` // allow admin access
Modify bool `json:"modify,omitempty"` // deprecated: legacy user migration only; not used for API tokens
Share bool `json:"share"` // allow sharing files
Realtime bool `json:"realtime"` // allow realtime updates
Share bool `json:"share"` // allow sharing files
Realtime bool `json:"realtime"` // allow realtime updates
Delete bool `json:"delete,omitempty"` // deprecated: legacy user migration only; not used for API tokens
Create bool `json:"create,omitempty"` // deprecated: legacy user migration only; not used for API tokens
Download bool `json:"download,omitempty"` // deprecated: legacy user migration only; not used for API tokens
@@ -119,12 +119,12 @@ type Permissions struct {
// SourceFilePermissions holds per-source file operation permissions (v4+).
type SourceFilePermissions struct {
View bool `json:"view"`
Download bool `json:"download"`
Modify bool `json:"modify"`
Delete bool `json:"delete"`
Create bool `json:"create"`
Configured bool `json:"configured,omitempty"` // true when explicitly set (allows intentional deny-all)
View bool `json:"view"`
Download bool `json:"download"`
Modify bool `json:"modify"`
Delete bool `json:"delete"`
Create bool `json:"create"`
Configured bool `json:"configured,omitempty"` // true when explicitly set (allows intentional deny-all)
}
// MarkSourceFilePermissionsConfigured marks permissions as explicitly set (including deny-all).
@@ -173,16 +173,17 @@ type Preview struct {
// FrontendUser holds fields safe to return from user APIs (embedded on User).
type FrontendUser struct {
NonAdminEditable
DisableSettings bool `json:"disableSettings"`
Username string `json:"username"`
FrontendScopes []FrontendScope `json:"scopes"`
LockPassword bool `json:"lockPassword"`
Permissions Permissions `json:"permissions"` // global: admin, api, share, realtime
SourcePermissions map[string]SourceFilePermissions `json:"sourcePermissions,omitempty"` // deprecated: use scopes[].permissions
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"`
ShowFirstLogin bool `json:"showFirstLogin"`
Perm Permissions `json:"perm,omitzero"`
DisableSettings bool `json:"disableSettings"`
Username string `json:"username"`
FrontendScopes []FrontendScope `json:"scopes"`
LockPassword bool `json:"lockPassword"`
Permissions Permissions `json:"permissions"` // global: admin, api, share, realtime
SourcePermissions map[string]SourceFilePermissions `json:"sourcePermissions,omitempty"` // deprecated: use scopes[].permissions
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"`
ShowFirstLogin bool `json:"showFirstLogin"`
EffectiveToolAccess map[string]bool `json:"effectiveToolAccess,omitempty"`
Perm Permissions `json:"perm,omitzero"`
}
// PinnedItems maps source filesystem path -> index directory path -> pinned item names.
@@ -200,6 +201,7 @@ type User struct {
// BackendScopes is the authoritative, persisted access list (SourceScope.Name = backend source path).
// SQLite stores this inside user_data JSON under the key "scopes" (see sqldb.UserData).
BackendScopes []BackendScope `json:"backendScopes,omitempty"`
DeclinedDefaultSources []string `json:"declinedDefaultSources,omitempty"`
BackendSourcePermissions map[string]SourceFilePermissions `json:"backendSourcePermissions,omitempty"` // key = source path
Tokens map[string]AuthToken `json:"tokens,omitempty"`
TOTPSecret string `json:"totpSecret,omitempty"`
@@ -210,16 +212,24 @@ type User struct {
UserLegacyFields `json:",inline"`
}
type ScopeQuota struct {
ID string `json:"id"`
LimitBytes int64 `json:"limitBytes"` // 0 = unlimited
Meter string `json:"meter,omitempty"` // index_scope (indexed size) | accounted (tracked usage)
}
type FrontendScope struct {
Name string `json:"name"` // Bolt: filesystem path; JSON API: display name after prepForFrontend
Scope string `json:"scope"` // index path within that source
Name string `json:"name"` // Bolt: filesystem path; JSON API: display name after prepForFrontend
Scope string `json:"scope"` // index path within that source
Permissions *SourceFilePermissions `json:"permissions,omitempty"`
Quota *ScopeQuota `json:"quota,omitempty"`
}
type BackendScope struct {
Path string `json:"path"` // real path for the source
Scope string `json:"scope"` // index path within that source
Path string `json:"path"` // real path for the source
Scope string `json:"scope"` // index path within that source
Permissions SourceFilePermissions `json:"permissions,omitempty"`
Quota *ScopeQuota `json:"quota,omitempty"`
}
// json tags must match variable name with smaller case first letter
@@ -233,9 +243,10 @@ type NonAdminEditable struct {
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
DarkMode bool `json:"darkMode"` // should dark mode be enabled
Password string `json:"password,omitempty"`
Locale string `json:"locale"` // language to use: eg. de, en, or fr
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
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
NewFileTemplate []string `json:"newFileTemplate"` // list of custom filenames that will be used as template for new files
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
Sorting Sorting `json:"sorting"`
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
HideFileExt string `json:"hideFileExt"` // space separated list of file extensions to hide in UI and API
@@ -255,11 +266,14 @@ type NonAdminEditable struct {
ShowToolsInSidebar bool `json:"showToolsInSidebar"` // when false, sidebar hides links with category "tool" (default: true)
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
ToolAccess ToolAccessMap `json:"toolAccess,omitempty"` // per-tool access overrides
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
PromptRightCloseButton bool `json:"promptRightCloseButton"` // show the prompts close button on the right
ShowFirstLogin bool `json:"showFirstLogin"`
PasskeyCredentials []WebAuthnCredential `json:"passkeyCredentials,omitempty"`
ShowAdvancedProfile bool `json:"showAdvancedProfile"`
}
type FileLoading struct {
+1
View File
@@ -35,4 +35,5 @@ var (
ErrPasskeyExists = errors.New("passkey credential already exists")
ErrPasskeyNotEnabled = errors.New("passkey authentication is not enabled")
ErrPasskeyInvalidSession = errors.New("invalid or expired passkey session")
ErrQuotaExceeded = errors.New("quota exceeded")
)
-10
View File
@@ -69,11 +69,6 @@ func (s *Service) ProbeFile(ctx context.Context, mediaPath string) (*FileProbeIn
return nil, err
}
if err = s.Acquire(ctx); err != nil {
return nil, err
}
defer s.Release()
info, err := s.inner.ProbeStream(ctx, goffmpeg.ProbeStreamOptions{
URL: localPath,
StreamType: goffmpeg.StreamFile,
@@ -106,11 +101,6 @@ func (s *Service) GetMediaDuration(ctx context.Context, mediaPath string) (float
return duration, nil
}
if err = s.Acquire(ctx); err != nil {
return 0, err
}
defer s.Release()
duration, err := s.inner.GetMediaDuration(ctx, localPath)
if err != nil {
return 0, err
+16 -23
View File
@@ -27,7 +27,8 @@ var global *Service
// InitOptions configures startup initialization.
type InitOptions struct {
FFmpegPath string
MaxConcurrent int
MaxConcurrent int // deprecated: use Concurrency
Concurrency goffmpeg.Concurrency
CacheDir string
SkipHWTests bool
HardwareAcceleration bool
@@ -35,9 +36,6 @@ type InitOptions struct {
// Initialize creates the global ffmpeg service and runs capability detection.
func Initialize(ctx context.Context, opts InitOptions) error {
if opts.MaxConcurrent < 1 {
opts.MaxConcurrent = 4
}
if opts.CacheDir == "" {
opts.CacheDir = os.TempDir()
}
@@ -46,12 +44,20 @@ func Initialize(ctx context.Context, opts InitOptions) error {
logger.Info("Detecting ffmpeg hardware codec support...")
}
svc, err := goffmpeg.New(ctx, goffmpeg.Config{
cfg := goffmpeg.Config{
FFmpegPath: opts.FFmpegPath,
MaxConcurrent: opts.MaxConcurrent,
Concurrency: opts.Concurrency,
Logger: goffmpeg.NopLogger(),
SkipHWTests: opts.SkipHWTests,
})
}
if opts.Concurrency.MaxProbe == 0 && opts.Concurrency.MaxDecode == 0 && opts.Concurrency.MaxEncode == 0 {
if opts.MaxConcurrent < 1 {
opts.MaxConcurrent = 4
}
cfg.MaxConcurrent = opts.MaxConcurrent
}
svc, err := goffmpeg.New(ctx, cfg)
if err != nil {
global = nil
return err
@@ -107,20 +113,12 @@ func (s *Service) Release() {
}
// VideoPreview extracts a JPEG preview frame to w.
func (s *Service) VideoPreview(ctx context.Context, w io.Writer, videoPath string, percentageSeek int) error {
// go-ffmpeg Service methods acquire concurrency slots automatically; do not Acquire before calling.
func (s *Service) VideoPreview(ctx context.Context, w io.Writer, opts ops.PreviewOptions) error {
if s == nil || s.inner == nil {
return fmt.Errorf("ffmpeg service not available")
}
if err := s.Acquire(ctx); err != nil {
return err
}
defer s.Release()
return s.inner.VideoPreview(ctx, w, ops.PreviewOptions{
Input: videoPath,
SeekPercent: float64(percentageSeek),
Quality: 10,
})
return s.inner.VideoPreview(ctx, w, opts)
}
// ExtractSubtitle returns embedded subtitle content as WebVTT.
@@ -138,11 +136,6 @@ func (s *Service) ExtractSubtitle(ctx context.Context, videoPath string, streamI
return content, nil
}
if err = s.Acquire(ctx); err != nil {
return "", err
}
defer s.Release()
content, err := s.inner.ExtractSubtitle(ctx, videoPath, streamIndex)
if err != nil {
return "", err
@@ -0,0 +1,106 @@
package ffmpeg
import (
"context"
"os"
"os/exec"
"path/filepath"
"sync"
"testing"
"time"
goffmpeg "github.com/gtsteffaniak/go-ffmpeg"
)
func requireFFmpegBinaries(t *testing.T) {
t.Helper()
if os.Getenv("GOFFMPEG_FFMPEG_PATH") != "" {
return
}
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not available")
}
if _, err := exec.LookPath("ffprobe"); err != nil {
t.Skip("ffprobe not available")
}
}
func testSampleMP4(t *testing.T) string {
t.Helper()
if p := os.Getenv("GOFFMPEG_SAMPLE_MP4"); p != "" {
return p
}
dir := t.TempDir()
out := filepath.Join(dir, "sample.mp4")
cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "color=c=blue:s=320x240:d=2",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-y", out)
if err := cmd.Run(); err != nil {
t.Skipf("could not generate sample mp4: %v", err)
}
return out
}
// TestGetMediaDurationConcurrentNoDeadlock ensures probe-tier concurrency does not deadlock.
// With MaxProbe=2, four concurrent GetMediaDuration calls queue and complete.
func TestGetMediaDurationConcurrentNoDeadlock(t *testing.T) {
requireFFmpegBinaries(t)
sample := testSampleMP4(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
err := Initialize(ctx, InitOptions{
Concurrency: goffmpeg.Concurrency{
MaxProbe: 2,
MaxDecode: 4,
MaxEncode: 2,
},
CacheDir: t.TempDir(),
SkipHWTests: true,
HardwareAcceleration: false,
})
if err != nil {
t.Fatalf("Initialize() error = %v", err)
}
t.Cleanup(func() { global = nil })
svc := Get()
if svc == nil {
t.Fatal("Get() returned nil after Initialize")
}
const workers = 4
var wg sync.WaitGroup
errCh := make(chan error, workers)
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, err := svc.GetMediaDuration(ctx, sample); err != nil {
errCh <- err
}
}()
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(25 * time.Second):
t.Fatal("concurrent GetMediaDuration calls deadlocked with MaxProbe=2")
}
close(errCh)
for err := range errCh {
if err != nil {
t.Fatalf("GetMediaDuration() error = %v", err)
}
}
}
+55 -20
View File
@@ -5,10 +5,15 @@ package imagemeta
import (
"encoding/binary"
"io"
"math"
"os"
)
const heicMetadataScanLimit = 32 * 1024 * 1024
const (
heicMetadataInitialScan = 256 * 1024
heicMetadataScanLimit = 32 * 1024 * 1024
maxBMFFRecursionDepth = 64
)
type heicTransform struct {
irot uint8
@@ -23,19 +28,37 @@ func parseHEICTransform(f *os.File) heicTransform {
if err != nil {
return heicTransform{}
}
size := info.Size()
if size > heicMetadataScanLimit {
size = heicMetadataScanLimit
}
if size < 12 {
fileSize := info.Size()
if fileSize < 12 {
return heicTransform{}
}
buf := make([]byte, size)
n, err := f.ReadAt(buf, 0)
if err != nil && err != io.EOF {
return heicTransform{}
scanLimit := int64(heicMetadataInitialScan)
if fileSize < scanLimit {
scanLimit = fileSize
}
for {
buf := make([]byte, scanLimit)
n, readErr := f.ReadAt(buf, 0)
if readErr != nil && readErr != io.EOF {
return heicTransform{}
}
data := buf[:n]
if result := parseHEICPrimaryIrot(data); result.found || findBMFFBox(data, "meta") != nil {
return result
}
if scanLimit >= fileSize || scanLimit >= heicMetadataScanLimit {
return parseHEICPrimaryIrot(data)
}
next := scanLimit * 2
if next > heicMetadataScanLimit {
next = heicMetadataScanLimit
}
if next > fileSize {
next = fileSize
}
scanLimit = next
}
return parseHEICPrimaryIrot(buf[:n])
}
func parseHEICPrimaryIrot(data []byte) heicTransform {
@@ -75,10 +98,13 @@ func parseHEICPrimaryIrot(data []byte) heicTransform {
}
func findBMFFBox(data []byte, typ string) []byte {
return findBMFFBoxRange(data, 0, len(data), typ)
return findBMFFBoxRange(data, 0, len(data), typ, 0)
}
func findBMFFBoxRange(data []byte, start, end int, typ string) []byte {
func findBMFFBoxRange(data []byte, start, end int, typ string, depth int) []byte {
if depth > maxBMFFRecursionDepth {
return nil
}
pos := start
for pos+8 <= end {
size, header, ok := bmffBoxHeader(data, pos, end)
@@ -86,14 +112,14 @@ func findBMFFBoxRange(data []byte, start, end int, typ string) []byte {
break
}
boxEnd := pos + size
if boxEnd > end {
if boxEnd > end || boxEnd < pos {
break
}
if string(data[pos+4:pos+8]) == typ {
return data[pos:boxEnd]
}
if isBMFFContainerType(data[pos+4 : pos+8]) {
if child := findBMFFBoxRange(data, pos+header, boxEnd, typ); child != nil {
if child := findBMFFBoxRange(data, pos+header, boxEnd, typ, depth+1); child != nil {
return child
}
}
@@ -107,7 +133,7 @@ func findBMFFChildBox(container []byte, typ string) []byte {
if payload == nil {
return nil
}
return findBMFFBoxRange(payload, 0, len(payload), typ)
return findBMFFBoxRange(payload, 0, len(payload), typ, 0)
}
func isBMFFContainerType(typ []byte) bool {
@@ -131,7 +157,14 @@ func bmffBoxHeader(data []byte, pos, end int) (size int, header int, ok bool) {
if pos+16 > end {
return 0, 0, false
}
return int(binary.BigEndian.Uint64(data[pos+8 : pos+16])), 16, true
size64 := binary.BigEndian.Uint64(data[pos+8 : pos+16])
if size64 < 16 || size64 > math.MaxInt32 {
return 0, 0, false
}
return int(size64), 16, true
}
if size32 < 8 {
return 0, 0, false
}
return int(size32), 8, true
}
@@ -156,7 +189,7 @@ func bmffBoxPayload(box []byte) []byte {
func isBMFFFullBoxType(typ string) bool {
switch typ {
case "meta", "pitm", "irot", "imir", "ispe", "ipma", "hdlr", "iloc", "iinf", "infe":
case "meta", "pitm", "ispe", "ipma", "hdlr", "iloc", "iinf", "infe":
return true
default:
return false
@@ -202,8 +235,10 @@ func parseIPCOProperties(ipco []byte) []ipcoProperty {
}
child := payload[pos : pos+size]
prop := ipcoProperty{typ: string(child[4:8])}
if prop.typ == "irot" && len(child) >= 13 {
prop.irot = child[12] & 0x03
if prop.typ == "irot" {
if payload := bmffBoxPayload(child); len(payload) >= 1 {
prop.irot = payload[0] & 0x03
}
}
props = append(props, prop)
pos += size
@@ -97,6 +97,33 @@ func TestGetOrientationSyntheticHEICRotate(t *testing.T) {
}
}
func TestFindBMFFBoxRangeRespectsDepthLimit(t *testing.T) {
target := bmffBox("targ", []byte{1, 2, 3})
nested := target
for i := 0; i < maxBMFFRecursionDepth+4; i++ {
nested = bmffBox("meta", nested)
}
if got := findBMFFBoxRange(nested, 0, len(nested), "targ", 0); got != nil {
t.Fatal("expected depth limit to prevent finding deeply nested box")
}
}
func TestParseIPCOPropertiesReadsPlainIrotBox(t *testing.T) {
ipco := bmffBox("ipco", bmffIrotBox(2))
props := parseIPCOProperties(ipco)
if len(props) != 1 || props[0].irot != 2 {
t.Fatalf("parseIPCOProperties() = %+v, want irot=2", props)
}
}
func TestParseIPCOPropertiesReadsExtendedSizeIrotBox(t *testing.T) {
ipco := bmffBox("ipco", bmffExtendedIrotBox(1))
props := parseIPCOProperties(ipco)
if len(props) != 1 || props[0].irot != 1 {
t.Fatalf("parseIPCOProperties() = %+v, want irot=1 from extended-size box", props)
}
}
type heicItemFixture struct {
itemID uint16
irot uint8
@@ -220,7 +247,18 @@ func bmffPITMBox(itemID uint16) []byte {
}
func bmffIrotBox(angle uint8) []byte {
return bmffFullBox("irot", 0, []byte{angle & 0x03})
return bmffBox("irot", []byte{angle & 0x03})
}
func bmffExtendedIrotBox(angle uint8) []byte {
payload := []byte{angle & 0x03}
size := 16 + len(payload)
out := make([]byte, size)
binary.BigEndian.PutUint32(out[0:4], 1)
copy(out[4:8], "irot")
binary.BigEndian.PutUint64(out[8:16], uint64(size))
copy(out[16:], payload)
return out
}
func bmffIPMABox(entries []ipmaEntryFixture) []byte {
+20 -8
View File
@@ -18,6 +18,7 @@ const (
tiffTypeASCII = 2
tiffTypeShort = 3
tiffTypeLong = 4
tiffTypeIFD = 13
tiffTypeUndefined = 7
maxTIFFIFDsVisited = 128
maxTIFFIFDQueueLen = 128
@@ -205,19 +206,23 @@ func tiffTagUint32s(data []byte, order binary.ByteOrder, typ uint16, count uint3
if count > maxTIFFSubIFDsPerEntry {
count = maxTIFFSubIFDsPerEntry
}
elemSize := tiffTagByteSize(typ)
if elemSize <= 0 {
return nil
}
var raw []byte
switch {
case typ == tiffTypeLong && count == 1:
var b [4]byte
order.PutUint32(b[:], valueOffset)
raw = b[:]
case tiffTagByteSize(typ)*int(count) <= 4:
case elemSize*int(count) <= 4:
var b [4]byte
order.PutUint32(b[:], valueOffset)
raw = b[:count*uint32(tiffTagByteSize(typ))]
raw = b[:count*uint32(elemSize)]
default:
off := int(valueOffset)
n := int(count) * tiffTagByteSize(typ)
n := int(count) * elemSize
if off < 0 || off+n > len(data) {
return nil
}
@@ -225,11 +230,18 @@ func tiffTagUint32s(data []byte, order binary.ByteOrder, typ uint16, count uint3
}
out := make([]uint32, 0, count)
for i := 0; i < int(count); i++ {
pos := i * 4
if pos+4 > len(raw) {
pos := i * elemSize
if pos+elemSize > len(raw) {
break
}
out = append(out, order.Uint32(raw[pos:pos+4]))
switch typ {
case tiffTypeShort:
out = append(out, uint32(order.Uint16(raw[pos:pos+2])))
case tiffTypeLong, tiffTypeIFD:
out = append(out, order.Uint32(raw[pos:pos+4]))
default:
return nil
}
}
return out
}
@@ -240,9 +252,9 @@ func tiffTagByteSize(typ uint16) int {
return 1
case tiffTypeShort:
return 2
case tiffTypeLong:
case tiffTypeLong, tiffTypeIFD:
return 4
default:
return 1
return 0
}
}
@@ -93,9 +93,31 @@ func TestCollectTIFFPreviewCandidatesBoundsIFDTraversal(t *testing.T) {
}
}
func TestTiffTagUint32sSupportsIFDType(t *testing.T) {
data := buildSyntheticTIFF(tiffFixture{
thumbnailJPEG: smallJPEG,
subIFDJPEG: largeJPEG,
subIFDType: tiffTypeIFD,
})
f := openBytesFile(t, data)
candidates, err := collectTIFFPreviewCandidates(f)
if err != nil {
t.Fatal(err)
}
preview, err := readLargestJPEGPreview(f, candidates)
if err != nil {
t.Fatal(err)
}
if len(preview) != len(largeJPEG) {
t.Fatalf("preview size = %d, want %d (SubIFD type IFD)", len(preview), len(largeJPEG))
}
}
type tiffFixture struct {
thumbnailJPEG []byte
subIFDJPEG []byte
subIFDType uint16
badJpgFromRaw bool
badJPEGInterchg bool
}
@@ -116,6 +138,9 @@ func openBytesFile(t *testing.T, data []byte) *os.File {
func buildSyntheticTIFF(fix tiffFixture) []byte {
order := binary.LittleEndian
if fix.subIFDType == 0 {
fix.subIFDType = tiffTypeLong
}
buf := make([]byte, 64*1024)
thumbOff := uint32(4096)
@@ -149,7 +174,7 @@ func buildSyntheticTIFF(fix tiffFixture) []byte {
writeIFD(ifd1Off, ifd1Entries, 0)
ifd0Entries := []tiffEntry{
{tag: tiffTagSubIFDs, typ: tiffTypeLong, count: 1, value: ifd1Off},
{tag: tiffTagSubIFDs, typ: fix.subIFDType, count: 1, value: ifd1Off},
{tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: thumbOff},
{tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(fix.thumbnailJPEG))},
}
@@ -8,6 +8,7 @@ package preview
#include <mupdf/fitz.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
static void fb_silence_warnings(void *user, const char *message) {
(void)user;
@@ -17,6 +18,25 @@ static void fb_silence_warnings(void *user, const char *message) {
static double fb_dmin(double a, double b) { return a < b ? a : b; }
static double fb_dmax(double a, double b) { return a > b ? a : b; }
static int fb_rect_usable(fz_rect r) {
if (fz_is_infinite_rect(r) || fz_is_empty_rect(r)) {
return 0;
}
if (r.x1 <= r.x0 || r.y1 <= r.y0) {
return 0;
}
if (!isfinite(r.x0) || !isfinite(r.x1) || !isfinite(r.y0) || !isfinite(r.y1)) {
return 0;
}
return 1;
}
static int fb_bbox_within(fz_irect bbox, int max_w, int max_h) {
int w = bbox.x1 - bbox.x0;
int h = bbox.y1 - bbox.y0;
return w > 0 && h > 0 && w <= max_w && h <= max_h;
}
// fb_render_document_page_jpeg renders one page to JPEG bytes.
// max_width/max_height <= 0 selects original mode (~150 DPI). Otherwise renders into that box.
// resize_mode_fill: 0 = fit (no upscale), 1 = fill (center crop to exact box).
@@ -85,6 +105,9 @@ static int fb_render_document_page_jpeg(
page = fz_load_page(ctx, doc, page_num);
fz_rect bounds = fz_bound_page(ctx, page);
if (!fb_rect_usable(bounds)) {
fz_throw(ctx, FZ_ERROR_GENERIC, "invalid page bounds");
}
fz_matrix ctm;
fz_irect bbox;
@@ -92,7 +115,32 @@ static int fb_render_document_page_jpeg(
double dpi = 150.0;
ctm = fz_scale(dpi / 72.0, dpi / 72.0);
bounds = fz_transform_rect(bounds, ctm);
if (!fb_rect_usable(bounds)) {
fz_throw(ctx, FZ_ERROR_GENERIC, "invalid transformed page bounds");
}
bbox = fz_round_rect(bounds);
{
const int max_orig_w = 4096;
const int max_orig_h = 4096;
if (!fb_bbox_within(bbox, max_orig_w, max_orig_h)) {
int w = bbox.x1 - bbox.x0;
int h = bbox.y1 - bbox.y0;
if (w <= 0 || h <= 0) {
fz_throw(ctx, FZ_ERROR_GENERIC, "invalid page bounds");
}
double scale = fb_dmin((double)max_orig_w / (double)w, (double)max_orig_h / (double)h);
ctm = fz_concat(ctm, fz_scale(scale, scale));
bounds = fz_bound_page(ctx, page);
bounds = fz_transform_rect(bounds, ctm);
if (!fb_rect_usable(bounds)) {
fz_throw(ctx, FZ_ERROR_GENERIC, "invalid transformed page bounds");
}
bbox = fz_round_rect(bounds);
if (!fb_bbox_within(bbox, max_orig_w, max_orig_h)) {
fz_throw(ctx, FZ_ERROR_GENERIC, "page exceeds maximum render size");
}
}
}
} else {
double page_w = bounds.x1 - bounds.x0;
double page_h = bounds.y1 - bounds.y0;
-10
View File
@@ -17,11 +17,6 @@ func (s *Service) convertHEICToJPEGWithFFmpeg(ctx context.Context, filePath stri
return nil, ctx.Err()
}
if err := s.ffmpegService.Acquire(ctx); err != nil {
return nil, err
}
defer s.ffmpegService.Release()
var quality string
switch previewSize {
case "large":
@@ -46,11 +41,6 @@ func (s *Service) convertImageWithFFmpeg(ctx context.Context, filePath string, p
return nil, ctx.Err()
}
if err := s.ffmpegService.Acquire(ctx); err != nil {
return nil, err
}
defer s.ffmpegService.Release()
var width, height int
var quality string
switch previewSize {
+5 -4
View File
@@ -286,7 +286,7 @@ func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.Extended
return s.generateImagePreview(ctx, file, previewSize)
case previewTypeVideo:
return s.generateVideoPreviewBytes(ctx, file, seekPercentage)
return s.generateVideoPreviewBytes(ctx, file, previewSize, seekPercentage)
case previewTypeAudio:
if file.Metadata != nil && len(file.Metadata.AlbumArt) > 0 {
@@ -331,7 +331,7 @@ func (s *Service) generateHEICPreview(ctx context.Context, file iteminfo.Extende
}
// generateVideoPreviewBytes generates preview frame from video file
func (s *Service) generateVideoPreviewBytes(ctx context.Context, file iteminfo.ExtendedFileInfo, seekPercentage int) ([]byte, error) {
func (s *Service) generateVideoPreviewBytes(ctx context.Context, file iteminfo.ExtendedFileInfo, previewSize string, seekPercentage int) ([]byte, error) {
// Check if this video format is enabled for preview generation
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
if !settings.CanConvertVideo(ext) {
@@ -343,7 +343,7 @@ func (s *Service) generateVideoPreviewBytes(ctx context.Context, file iteminfo.E
videoSeekPercentage = 10
}
imageBytes, err := s.GenerateVideoPreview(ctx, file.RealPath, videoSeekPercentage)
imageBytes, err := s.GenerateVideoPreview(ctx, file.RealPath, previewSize, videoSeekPercentage)
if err != nil {
// Don't log client cancellations as errors
if ctx.Err() != context.Canceled {
@@ -506,7 +506,8 @@ func GeneratePreviewWithMD5(ctx context.Context, file iteminfo.ExtendedFileInfo,
// When we got bytes from type-specific path, regular images are already resized; others need resize below.
previewType := determinePreviewType(file)
if !fromEmbeddedPreview && (previewType == previewTypeImage ||
(previewType == previewTypeDocument && settings.Env.MuPdfAvailable)) {
(previewType == previewTypeDocument && settings.Env.MuPdfAvailable) ||
(previewType == previewTypeVideo && settings.Env.FFmpegAvailable)) {
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache image: %v", err)
}
+16 -2
View File
@@ -6,14 +6,16 @@ import (
"fmt"
"os"
"github.com/gtsteffaniak/go-ffmpeg/ops"
"github.com/gtsteffaniak/go-logger/logger"
)
// GenerateVideoPreview generates a single preview image from a video using ffmpeg.
// videoPath: path to the input video file.
// previewSize: small, large, xlarge, or original.
// percentageSeek: percentage of video duration to seek to (0–100).
// Returns: JPEG image bytes.
func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath string, percentageSeek int) ([]byte, error) {
func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath, previewSize string, percentageSeek int) ([]byte, error) {
if s.ffmpegService == nil {
logger.Errorf("FFmpeg service not available for file '%s'", videoPath)
return nil, fmt.Errorf("FFmpeg service not available")
@@ -25,8 +27,20 @@ func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath string, pe
return nil, fmt.Errorf("video file not accessible: %w", err)
}
params, err := ffmpegPreviewParamsForSize(previewSize)
if err != nil {
return nil, err
}
var buf bytes.Buffer
err := s.ffmpegService.VideoPreview(ctx, &buf, videoPath, percentageSeek)
err = s.ffmpegService.VideoPreview(ctx, &buf, ops.PreviewOptions{
Input: videoPath,
SeekPercent: float64(percentageSeek),
Width: params.width,
Height: params.height,
ScaleMode: params.scaleMode,
Quality: params.quality,
})
if err != nil {
return nil, err
}
+35
View File
@@ -0,0 +1,35 @@
package preview
import (
"github.com/gtsteffaniak/go-ffmpeg/ops"
)
type ffmpegPreviewParams struct {
width int
height int
scaleMode ops.ScaleMode
quality int
}
func ffmpegPreviewParamsForSize(previewSize string) (ffmpegPreviewParams, error) {
if previewSize == "original" {
return ffmpegPreviewParams{quality: 10}, nil
}
opts, err := getPreviewOptions(previewSize)
if err != nil {
return ffmpegPreviewParams{}, err
}
params := ffmpegPreviewParams{
width: opts.Width,
height: opts.Height,
quality: 10, // fastest MJPEG encode (-q:v 10); visual quality comes from target dimensions
}
if opts.ResizeMode == ResizeModeFill {
params.scaleMode = ops.ScaleFill
} else {
params.scaleMode = ops.ScaleFit
}
return params, nil
}
@@ -0,0 +1,73 @@
package preview
import (
"testing"
"github.com/gtsteffaniak/go-ffmpeg/ops"
"github.com/stretchr/testify/require"
)
func TestFfmpegPreviewParamsForSize(t *testing.T) {
t.Parallel()
tests := []struct {
name string
size string
wantW int
wantH int
wantMode ops.ScaleMode
wantQual int
wantError bool
}{
{
name: "small fill",
size: "small",
wantW: 256,
wantH: 256,
wantMode: ops.ScaleFill,
wantQual: 10,
},
{
name: "large fit",
size: "large",
wantW: 640,
wantH: 640,
wantMode: ops.ScaleFit,
wantQual: 10,
},
{
name: "xlarge fit",
size: "xlarge",
wantW: 1024,
wantH: 1024,
wantMode: ops.ScaleFit,
wantQual: 10,
},
{
name: "original no scale",
size: "original",
wantQual: 10,
},
{
name: "unsupported",
size: "huge",
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := ffmpegPreviewParamsForSize(tt.size)
if tt.wantError {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantW, got.width)
require.Equal(t, tt.wantH, got.height)
require.Equal(t, tt.wantMode, got.scaleMode)
require.Equal(t, tt.wantQual, got.quality)
})
}
}
+72
View File
@@ -0,0 +1,72 @@
package quota
import (
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
)
// CopyMoveContext describes a copy/move/rename admission check.
type CopyMoveContext struct {
Principal *users.User
Action string
FromSourceName string
ToSourceName string
FromSourcePath string
ToSourcePath string
FromIndexPath string
ToIndexPath string
IsSrcDir bool
OverwriteBytes int64
ShareHash string
ShareLimitBytes int64
SessionID string
}
// ReserveCopyMove reserves quota before a copy/move/rename.
func ReserveCopyMove(ctx CopyMoveContext) error {
itemSize, ok := state.IndexedPathBytes(ctx.FromSourceName, ctx.FromIndexPath, ctx.IsSrcDir)
if !ok {
if state.HasApplicableQuotas(ctx.Principal, ctx.ToSourcePath, ctx.ToIndexPath, ctx.ShareHash, ctx.ShareLimitBytes) {
return newError(CodeUsageUnknown, "", "", 0, 0, 0, "indexed size unavailable for quota check")
}
return nil
}
checks := state.BuildCopyMoveReserveChecks(
ctx.Principal,
ctx.ToSourceName,
ctx.ToSourcePath,
ctx.FromIndexPath,
ctx.ToIndexPath,
ctx.Action,
itemSize,
ctx.OverwriteBytes,
ctx.ShareHash,
ctx.ShareLimitBytes,
)
if len(checks) == 0 {
return nil
}
if err := state.ReserveQuota(ctx.SessionID, checks); err != nil {
return mapReserveError(err, checks)
}
return nil
}
// CommitCopyMove commits reserved quota after a successful copy/move/rename.
func CommitCopyMove(sessionID string) error {
return state.CommitQuota(sessionID, 0)
}
// ReleaseCopyMove releases quota reservation on failure.
func ReleaseCopyMove(sessionID string) {
state.ReleaseQuota(sessionID)
}
// OverwriteBytesAtPath returns indexed bytes at dest when replacing an existing path.
func OverwriteBytesAtPath(sourceName, indexPath string, isDir bool) int64 {
size, ok := state.IndexedPathBytes(sourceName, indexPath, isDir)
if !ok {
return 0
}
return size
}
+95
View File
@@ -0,0 +1,95 @@
package quota
import (
"errors"
"fmt"
)
const (
CodeExceeded = "quota_exceeded"
CodeMeasurementUnavailable = "quota_measurement_unavailable"
CodePathNotCountable = "quota_path_not_countable"
CodeUsageUnknown = "quota_usage_unknown"
CodeReservedCapacity = "quota_reserved_capacity"
CodeLengthRequired = "quota_length_required"
CodeRootImmutable = "quota_root_immutable"
)
// Error is returned when a quota check fails.
type Error struct {
Code string
QuotaKind string
QuotaID string
LimitBytes int64
UsedBytes int64
ReservedBytes int64
Message string
}
func (e *Error) Error() string {
return e.DisplayMessage()
}
// DisplayMessage returns a user-facing error string (no internal IDs).
func (e *Error) DisplayMessage() string {
if e.Message != "" {
return e.Message
}
return e.defaultMessage()
}
func (e *Error) defaultMessage() string {
switch e.Code {
case CodeExceeded:
switch e.QuotaKind {
case "share":
return "Share storage quota exceeded"
case "scope":
return "Source storage quota exceeded"
case "folder":
return "Folder storage quota exceeded"
}
return "Storage quota exceeded"
case CodeMeasurementUnavailable:
return "Storage measurement is not available yet"
case CodePathNotCountable:
return "Storage for this path cannot be measured"
case CodeUsageUnknown:
return "Storage usage is not available for quota check"
case CodeReservedCapacity:
return "Storage quota reservation failed"
case CodeLengthRequired:
return "Upload size is required when storage quota applies"
case CodeRootImmutable:
return "Root storage quota cannot be changed"
default:
return fmt.Sprintf("quota error: %s", e.Code)
}
}
func newError(code, kind, id string, limit, used, reserved int64, msg string) *Error {
return &Error{
Code: code,
QuotaKind: kind,
QuotaID: id,
LimitBytes: limit,
UsedBytes: used,
ReservedBytes: reserved,
Message: msg,
}
}
// IsQuotaError reports whether err is a quota Error.
func IsQuotaError(err error) bool {
var qe *Error
return errors.As(err, &qe)
}
// AsError unwraps a quota Error.
func AsError(err error) (*Error, bool) {
var qe *Error
if errors.As(err, &qe) {
return qe, true
}
return nil, false
}
+18
View File
@@ -0,0 +1,18 @@
package quota
import "testing"
func TestErrorDisplayMessage(t *testing.T) {
exceeded := newError(CodeExceeded, "folder", "uuid-id", 100, 90, 5, "")
if exceeded.DisplayMessage() != "Folder storage quota exceeded" {
t.Fatalf("folder exceeded: got %q", exceeded.DisplayMessage())
}
if exceeded.Error() != "Folder storage quota exceeded" {
t.Fatalf("Error() should match DisplayMessage: got %q", exceeded.Error())
}
custom := newError(CodeExceeded, "folder", "uuid-id", 100, 90, 5, "Custom limit message")
if custom.DisplayMessage() != "Custom limit message" {
t.Fatalf("custom message: got %q", custom.DisplayMessage())
}
}
+122
View File
@@ -0,0 +1,122 @@
package quota
import (
"os"
quotadb "github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
// UploadContext describes an upload admission check.
type UploadContext struct {
Principal *users.User
SourceName string
SourcePath string
DestIndexPath string
DestRealPath string
ShareHash string
ShareLimitBytes int64
SessionID string
TotalSize int64
HasKnownSize bool
}
// CheckUploadLength verifies size is known when quotas apply.
func CheckUploadLength(ctx UploadContext) error {
shareLimit := ctx.ShareLimitBytes
if !state.HasApplicableQuotas(ctx.Principal, ctx.SourcePath, ctx.DestIndexPath, ctx.ShareHash, shareLimit) {
return nil
}
if !ctx.HasKnownSize || ctx.TotalSize < 0 {
return newError(CodeLengthRequired, "", "", 0, 0, 0, "upload size required when storage quota applies")
}
return nil
}
// ReserveUpload reserves quota for an upload session.
func ReserveUpload(ctx UploadContext) error {
delta := uploadReserveDelta(ctx)
if delta <= 0 {
return nil
}
checks := state.BuildReserveChecks(ctx.Principal, ctx.SourceName, ctx.SourcePath, ctx.DestIndexPath, ctx.ShareHash, ctx.ShareLimitBytes, delta)
if len(checks) == 0 {
return nil
}
if err := state.ReserveQuota(ctx.SessionID, checks); err != nil {
return mapReserveError(err, checks)
}
return nil
}
// CommitUpload commits quota after successful upload.
func CommitUpload(ctx UploadContext) error {
commitDelta := uploadCommitDelta(ctx)
if commitDelta > 0 {
return state.CommitQuota(ctx.SessionID, commitDelta)
}
state.ReleaseQuota(ctx.SessionID)
if commitDelta < 0 {
return state.ApplyAccountedUsageDelta(ctx.Principal, ctx.SourceName, ctx.SourcePath, ctx.DestIndexPath, ctx.ShareHash, commitDelta)
}
return nil
}
// ReleaseUpload releases quota reservation on failure.
func ReleaseUpload(sessionID string) {
state.ReleaseQuota(sessionID)
}
// PrincipalForUpload returns the user whose quota is charged.
func PrincipalForUpload(actor *users.User, shareOwnerID uint64, shareHash string) *users.User {
if shareHash != "" && shareOwnerID != 0 {
owner, err := state.GetUserByID(shareOwnerID)
if err == nil {
return &owner
}
}
return actor
}
// UploadCommitDelta returns signed byte change for an upload (negative when file shrinks).
func UploadCommitDelta(ctx UploadContext) int64 {
return uploadCommitDelta(ctx)
}
func uploadCommitDelta(ctx UploadContext) int64 {
if !ctx.HasKnownSize {
return 0
}
oldSize := int64(0)
if ctx.DestRealPath != "" {
if stat, err := os.Stat(ctx.DestRealPath); err == nil && !stat.IsDir() {
oldSize = stat.Size()
}
}
return ctx.TotalSize - oldSize
}
func uploadReserveDelta(ctx UploadContext) int64 {
delta := uploadCommitDelta(ctx)
if delta < 0 {
return 0
}
return delta
}
// ScopeQuotaForSource returns scope quota snapshot for sidebar.
func ScopeQuotaForSource(user *users.User, sourceName string) (quotadb.Snapshot, bool) {
sourceInfo, ok := settings.Config.Server.NameToSource[sourceName]
if !ok {
return quotadb.Snapshot{}, false
}
for _, bs := range user.BackendScopes {
if bs.Path != sourceInfo.Path || bs.Quota == nil || bs.Quota.LimitBytes <= 0 {
continue
}
return state.ScopeQuotaSnapshot(user, sourceName, bs.Quota), true
}
return quotadb.Snapshot{}, false
}
+49
View File
@@ -0,0 +1,49 @@
package quota
import (
"os"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
func TestUploadCommitDelta_shrinkReturnsNegative(t *testing.T) {
dir := t.TempDir()
dest := filepath.Join(dir, "file.bin")
if err := os.WriteFile(dest, make([]byte, 100), 0644); err != nil {
t.Fatal(err)
}
ctx := UploadContext{
Principal: &users.User{},
DestRealPath: dest,
TotalSize: 50,
HasKnownSize: true,
}
if got := UploadCommitDelta(ctx); got != -50 {
t.Fatalf("UploadCommitDelta shrink: got %d want -50", got)
}
if got := uploadReserveDelta(ctx); got != 0 {
t.Fatalf("uploadReserveDelta shrink: got %d want 0", got)
}
}
func TestUploadCommitDelta_growthPositive(t *testing.T) {
dir := t.TempDir()
dest := filepath.Join(dir, "file.bin")
if err := os.WriteFile(dest, make([]byte, 40), 0644); err != nil {
t.Fatal(err)
}
ctx := UploadContext{
Principal: &users.User{},
DestRealPath: dest,
TotalSize: 100,
HasKnownSize: true,
}
if got := UploadCommitDelta(ctx); got != 60 {
t.Fatalf("UploadCommitDelta growth: got %d want 60", got)
}
if got := uploadReserveDelta(ctx); got != 60 {
t.Fatalf("uploadReserveDelta growth: got %d want 60", got)
}
}
+28
View File
@@ -0,0 +1,28 @@
package quota
import (
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
)
// mapReserveError converts reserve failures to quota errors.
func mapReserveError(err error, checks []state.QuotaReserveCheck) error {
if err == nil {
return nil
}
for _, chk := range checks {
if chk.LimitBytes <= 0 || chk.DeltaBytes <= 0 {
continue
}
reserved := state.SessionReservedForQuota(chk.QuotaID)
if chk.UsedBytes+reserved+chk.DeltaBytes > chk.LimitBytes {
return newError(CodeExceeded, chk.Kind, chk.QuotaID, chk.LimitBytes, chk.UsedBytes, reserved, "")
}
}
for _, chk := range checks {
if chk.LimitBytes > 0 {
reserved := state.SessionReservedForQuota(chk.QuotaID)
return newError(CodeExceeded, chk.Kind, chk.QuotaID, chk.LimitBytes, chk.UsedBytes, reserved, "")
}
}
return newError(CodeExceeded, "", "", 0, 0, 0, "")
}
+142
View File
@@ -0,0 +1,142 @@
package sharedefaults
import (
"encoding/json"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
// NormalizeUploadShareEditable ensures upload shares always request create permission.
func NormalizeUploadShareEditable(editable *share.ShareEditable) {
if editable != nil && editable.ShareType == "upload" {
editable.AllowCreate = true
}
}
// ApplyDefaultsToEditable merges default template values into editable for new shares.
func ApplyDefaultsToEditable(editable *share.ShareEditable, defaults settings.ShareDefaults) {
if editable == nil {
return
}
current := EditableToDefaults(*editable)
currentBytes, err := json.Marshal(current)
if err != nil {
return
}
defaultBytes, err := json.Marshal(defaults)
if err != nil {
return
}
mergedBytes, err := mergeJSON(defaultBytes, currentBytes)
if err != nil {
return
}
var merged settings.ShareDefaults
if err := json.Unmarshal(mergedBytes, &merged); err != nil {
return
}
*editable = DefaultsToEditable(merged)
}
// ApplyEnforcedDefaults overwrites enforced fields on editable from the defaults template.
func ApplyEnforcedDefaults(editable *share.ShareEditable, defaults settings.ShareDefaults, enforced settings.ShareDefaultsEnforcement) {
if editable == nil {
return
}
paths := settings.ShareEnforcedPathSet(enforced)
if len(paths) == 0 {
return
}
currentBytes, err := json.Marshal(EditableToDefaults(*editable))
if err != nil {
return
}
patchBytes, err := patchJSONForPaths(defaults, paths)
if err != nil {
return
}
mergedBytes, err := mergeJSON(currentBytes, patchBytes)
if err != nil {
return
}
var merged settings.ShareDefaults
if err := json.Unmarshal(mergedBytes, &merged); err != nil {
return
}
*editable = DefaultsToEditable(merged)
}
// ValidateEditableNotEnforced rejects requests where enforced fields differ from the defaults template.
func ValidateEditableNotEnforced(after *share.ShareEditable, enforced settings.ShareDefaultsEnforcement, defaults settings.ShareDefaults) error {
if after == nil {
return nil
}
paths := settings.ShareEnforcedPathSet(enforced)
if len(paths) == 0 {
return nil
}
afterDefaults := EditableToDefaults(*after)
for path := range paths {
expected, ok := settings.ShareDefaultsValueAtPath(defaults, path)
if !ok {
continue
}
actual, ok := settings.ShareDefaultsValueAtPath(afterDefaults, path)
if !ok || !jsonValuesEqual(expected, actual) {
return settings.ErrEnforcedShareValueMismatch{Path: path}
}
}
return nil
}
func patchJSONForPaths(source settings.ShareDefaults, paths map[string]struct{}) ([]byte, error) {
patchMap := make(map[string]interface{}, len(paths))
for path := range paths {
val, ok := settings.ShareDefaultsValueAtPath(source, path)
if !ok {
continue
}
patchMap[path] = val
}
return json.Marshal(patchMap)
}
func mergeJSON(baseJSON, patchJSON []byte) ([]byte, error) {
var base map[string]interface{}
if err := json.Unmarshal(baseJSON, &base); err != nil {
return nil, err
}
var patch map[string]interface{}
if err := json.Unmarshal(patchJSON, &patch); err != nil {
return nil, err
}
deepMergeMaps(base, patch)
return json.Marshal(base)
}
func deepMergeMaps(base, patch map[string]interface{}) {
for key, patchVal := range patch {
baseVal, exists := base[key]
if !exists {
base[key] = patchVal
continue
}
baseMap, baseOK := baseVal.(map[string]interface{})
patchMap, patchOK := patchVal.(map[string]interface{})
if baseOK && patchOK {
deepMergeMaps(baseMap, patchMap)
continue
}
base[key] = patchVal
}
}
func jsonValuesEqual(a, b interface{}) bool {
ab, errA := json.Marshal(a)
bb, errB := json.Marshal(b)
if errA != nil || errB != nil {
return false
}
return string(ab) == string(bb)
}
@@ -0,0 +1,74 @@
package sharedefaults
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestApplyDefaultsToEditable_mergesClientOverDefaults(t *testing.T) {
defaults := settings.ShareDefaults{
ShareType: "normal",
AllowModify: true,
DisableDownload: true,
Title: "Default title",
}
editable := share.ShareEditable{
FrontendShareInfo: share.FrontendShareInfo{
ShareType: "upload",
Title: "Custom",
},
}
ApplyDefaultsToEditable(&editable, defaults)
if editable.ShareType != "upload" {
t.Fatalf("client shareType should win, got %q", editable.ShareType)
}
if editable.Title != "Custom" {
t.Fatalf("client title should win, got %q", editable.Title)
}
if !editable.AllowModify || !editable.DisableDownload {
t.Fatal("expected defaults applied for unset fields")
}
}
func TestValidateEditableNotEnforced_blocksMismatch(t *testing.T) {
defaults := settings.ShareDefaults{
AllowModify: true,
SidebarLinks: []users.SidebarLink{{Name: "Info", Category: "shareInfo", Target: "#"}},
}
enforced := settings.ShareDefaultsEnforcement{AllowModify: true, SidebarLinks: true}
editable := share.ShareEditable{
FrontendShareInfo: share.FrontendShareInfo{
AllowModify: false,
SidebarLinks: []users.SidebarLink{{Name: "Other", Category: "custom", Target: "/x"}},
},
}
if err := ValidateEditableNotEnforced(&editable, enforced, defaults); err == nil {
t.Fatal("expected enforcement error")
}
}
func TestValidateEditableNotEnforced_blocksZeroEnforcedValue(t *testing.T) {
defaults := settings.ShareDefaults{AllowModify: false}
enforced := settings.ShareDefaultsEnforcement{AllowModify: true}
editable := share.ShareEditable{
FrontendShareInfo: share.FrontendShareInfo{AllowModify: true},
}
if err := ValidateEditableNotEnforced(&editable, enforced, defaults); err == nil {
t.Fatal("expected enforcement error for zero-valued enforced default")
}
}
func TestApplyEnforcedDefaults_appliesZeroBool(t *testing.T) {
defaults := settings.ShareDefaults{AllowModify: false}
enforced := settings.ShareDefaultsEnforcement{AllowModify: true}
editable := share.ShareEditable{
FrontendShareInfo: share.FrontendShareInfo{AllowModify: true},
}
ApplyEnforcedDefaults(&editable, defaults, enforced)
if editable.AllowModify {
t.Fatal("expected enforced allowModify=false")
}
}
+89
View File
@@ -0,0 +1,89 @@
package sharedefaults
import (
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
// DefaultsToEditable converts defaults template to a share editable payload.
func DefaultsToEditable(d settings.ShareDefaults) share.ShareEditable {
return share.ShareEditable{
FrontendShareInfo: share.FrontendShareInfo{
ShareTheme: d.ShareTheme,
DisableAnonymous: d.DisableAnonymous,
DisableThumbnails: d.DisableThumbnails,
KeepAfterExpiration: d.KeepAfterExpiration,
ThemeColor: d.ThemeColor,
Title: d.Title,
Description: d.Description,
Favicon: d.Favicon,
QuickDownload: d.QuickDownload,
HideNavButtons: d.HideNavButtons,
DisableSidebar: d.DisableSidebar,
DisableShareCard: d.DisableShareCard,
EnforceDarkLightMode: d.EnforceDarkLightMode,
ViewMode: d.ViewMode,
EnableOnlyOffice: d.EnableOnlyOffice,
ShareType: d.ShareType,
AllowDelete: d.AllowDelete,
AllowCreate: d.AllowCreate,
AllowModify: d.AllowModify,
DisableFileViewer: d.DisableFileViewer,
DisableDownload: d.DisableDownload,
AllowReplacements: d.AllowReplacements,
SidebarLinks: append([]users.SidebarLink(nil), d.SidebarLinks...),
ShowHidden: d.ShowHidden,
DisableLoginOption: d.DisableLoginOption,
},
ShareLimits: share.ShareLimits{
MaxBandwidth: d.MaxBandwidth,
AllowedUsernames: append([]string(nil), d.AllowedUsernames...),
PerUserDownloadLimit: d.PerUserDownloadLimit,
ExtractEmbeddedSubtitles: d.ExtractEmbeddedSubtitles,
DownloadsLimit: d.DownloadsLimit,
QuotaLimitBytes: d.QuotaLimitBytes,
HideFileExt: d.HideFileExt,
Banner: d.Banner,
},
}
}
// EditableToDefaults extracts defaults-relevant fields from a share editable payload.
func EditableToDefaults(e share.ShareEditable) settings.ShareDefaults {
return settings.ShareDefaults{
ShareTheme: e.ShareTheme,
DisableAnonymous: e.DisableAnonymous,
DisableThumbnails: e.DisableThumbnails,
KeepAfterExpiration: e.KeepAfterExpiration,
ThemeColor: e.ThemeColor,
Title: e.Title,
Description: e.Description,
Favicon: e.Favicon,
QuickDownload: e.QuickDownload,
HideNavButtons: e.HideNavButtons,
DisableSidebar: e.DisableSidebar,
DisableShareCard: e.DisableShareCard,
EnforceDarkLightMode: e.EnforceDarkLightMode,
ViewMode: e.ViewMode,
EnableOnlyOffice: e.EnableOnlyOffice,
ShareType: e.ShareType,
AllowDelete: e.AllowDelete,
AllowCreate: e.AllowCreate,
AllowModify: e.AllowModify,
DisableFileViewer: e.DisableFileViewer,
DisableDownload: e.DisableDownload,
AllowReplacements: e.AllowReplacements,
SidebarLinks: append([]users.SidebarLink(nil), e.SidebarLinks...),
ShowHidden: e.ShowHidden,
DisableLoginOption: e.DisableLoginOption,
MaxBandwidth: e.MaxBandwidth,
AllowedUsernames: append([]string(nil), e.AllowedUsernames...),
PerUserDownloadLimit: e.PerUserDownloadLimit,
ExtractEmbeddedSubtitles: e.ExtractEmbeddedSubtitles,
DownloadsLimit: e.DownloadsLimit,
QuotaLimitBytes: e.QuotaLimitBytes,
HideFileExt: e.HideFileExt,
Banner: e.Banner,
}
}
@@ -0,0 +1,47 @@
package sharedefaults
import (
"encoding/json"
"fmt"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
var sharePostBodyMetaKeys = map[string]struct{}{
"hash": {},
"password": {},
"path": {},
}
// MergeEditableUpdate overlays a partial POST body onto existing share editable fields.
// Only keys present in patchJSON replace existing values.
func MergeEditableUpdate(existing share.ShareEditable, patchJSON []byte) (share.ShareEditable, error) {
var patchMap map[string]json.RawMessage
if err := json.Unmarshal(patchJSON, &patchMap); err != nil {
return share.ShareEditable{}, fmt.Errorf("unmarshal share update patch: %w", err)
}
for key := range sharePostBodyMetaKeys {
delete(patchMap, key)
}
if len(patchMap) == 0 {
return existing, nil
}
trimmedPatch, err := json.Marshal(patchMap)
if err != nil {
return share.ShareEditable{}, fmt.Errorf("marshal share update patch: %w", err)
}
baseBytes, err := json.Marshal(existing)
if err != nil {
return share.ShareEditable{}, fmt.Errorf("marshal existing share editable: %w", err)
}
mergedBytes, err := settings.MergeUserDefaultsPatchJSONBytes(baseBytes, trimmedPatch)
if err != nil {
return share.ShareEditable{}, err
}
var merged share.ShareEditable
if err := json.Unmarshal(mergedBytes, &merged); err != nil {
return share.ShareEditable{}, fmt.Errorf("unmarshal merged share editable: %w", err)
}
return merged, nil
}
@@ -0,0 +1,31 @@
package sharedefaults
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
)
func TestMergeEditableUpdate_preservesOmittedFields(t *testing.T) {
existing := share.ShareEditable{
FrontendShareInfo: share.FrontendShareInfo{
AllowModify: true,
AllowDelete: false,
ShareType: "normal",
},
}
patch := []byte(`{"hash":"abc","allowDelete":true}`)
merged, err := MergeEditableUpdate(existing, patch)
if err != nil {
t.Fatal(err)
}
if !merged.AllowModify {
t.Fatal("expected allowModify preserved from existing share")
}
if !merged.AllowDelete {
t.Fatal("expected allowDelete updated from patch")
}
if merged.ShareType != "normal" {
t.Fatalf("shareType=%q want normal", merged.ShareType)
}
}
+22
View File
@@ -102,6 +102,13 @@ func SyncUserGroups(username string, newGroups []string) error {
return accessDb.SyncUserGroups(username, newGroups)
}
// SetAccessSQLStoreForTest replaces the SQL persister used by access control (tests only).
func SetAccessSQLStoreForTest(store access.SQLPersister) {
if accessDb != nil {
accessDb.SetSQLStore(store)
}
}
func UpdateRulePath(sourcePath string, oldPath, newPath utils.IndexPath) error {
return accessDb.UpdateRulePath(sourcePath, oldPath, newPath)
}
@@ -181,3 +188,18 @@ func UpdateAccessRulesOnMove(sourcePath string, oldPath, newPath utils.IndexPath
}
return accessDb.UpdateRules(sourcePath, oldPath, newPath)
}
// GetGroupMembers returns every group with its sorted member usernames.
func GetGroupMembers() map[string][]string {
return accessDb.GetGroupMembers()
}
// SetGroupMembers creates the group if needed and replaces its member list.
func SetGroupMembers(group string, usernames []string) error {
return accessDb.SetGroupMembers(group, usernames)
}
// DeleteGroup removes a group and every access rule entry that references it.
func DeleteGroup(group string) error {
return accessDb.DeleteGroup(group)
}
@@ -11,9 +11,10 @@ import (
const bootstrapAdminPasswordHexBytes = 6 // 12 hex characters
// bootstrapDefaultAdminPassword returns the plaintext password for initial admin creation.
// When config adminPassword is blank or "admin", a random password is generated and logged once.
// Uses auth.methods.password.adminPassword (backfilled from auth.adminPassword when unset).
// Blank or "admin" is ignored and a random password is generated and logged once.
func bootstrapDefaultAdminPassword(username string) (plaintext string, generated bool, err error) {
cfg := settings.Config.Auth.AdminPassword
cfg := settings.PasswordAdminPassword()
if cfg != "" && cfg != "admin" {
return cfg, false, nil
}
@@ -23,7 +24,7 @@ func bootstrapDefaultAdminPassword(username string) (plaintext string, generated
return "", false, fmt.Errorf("generate bootstrap admin password: %w", err)
}
logger.Infof(
"Generated initial admin password for user %q (set auth.adminPassword in config to override on future resets): %s",
"Generated initial admin password for user %q (set auth.methods.password.adminPassword in config to override): %s",
username,
plaintext,
)
@@ -7,28 +7,60 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestBootstrapDefaultAdminPasswordUsesConfigWhenSet(t *testing.T) {
orig := settings.Config.Auth.AdminPassword
defer func() { settings.Config.Auth.AdminPassword = orig }()
func TestBootstrapDefaultAdminPasswordUsesNestedConfigWhenSet(t *testing.T) {
origNested := settings.Config.Auth.Methods.PasswordAuth.AdminPassword
origLegacy := settings.Config.Auth.AdminPassword
defer func() {
settings.Config.Auth.Methods.PasswordAuth.AdminPassword = origNested
settings.Config.Auth.AdminPassword = origLegacy
}()
settings.Config.Auth.AdminPassword = "from-config"
settings.Config.Auth.Methods.PasswordAuth.AdminPassword = "from-nested"
settings.Config.Auth.AdminPassword = "from-legacy"
plain, generated, err := bootstrapDefaultAdminPassword("admin")
if err != nil {
t.Fatal(err)
}
if generated {
t.Fatal("expected generated=false when config password is set")
t.Fatal("expected generated=false when nested config password is set")
}
if plain != "from-config" {
t.Fatalf("password = %q, want from-config", plain)
if plain != "from-nested" {
t.Fatalf("password = %q, want from-nested", plain)
}
}
func TestBootstrapDefaultAdminPasswordUsesTopLevelWhenNestedUnset(t *testing.T) {
origNested := settings.Config.Auth.Methods.PasswordAuth.AdminPassword
origLegacy := settings.Config.Auth.AdminPassword
defer func() {
settings.Config.Auth.Methods.PasswordAuth.AdminPassword = origNested
settings.Config.Auth.AdminPassword = origLegacy
}()
settings.Config.Auth.Methods.PasswordAuth.AdminPassword = ""
settings.Config.Auth.AdminPassword = "from-legacy"
plain, generated, err := bootstrapDefaultAdminPassword("admin")
if err != nil {
t.Fatal(err)
}
if generated {
t.Fatal("expected generated=false when top-level config password is set")
}
if plain != "from-legacy" {
t.Fatalf("password = %q, want from-legacy", plain)
}
}
func TestBootstrapDefaultAdminPasswordGeneratesWhenDefault(t *testing.T) {
orig := settings.Config.Auth.AdminPassword
defer func() { settings.Config.Auth.AdminPassword = orig }()
origNested := settings.Config.Auth.Methods.PasswordAuth.AdminPassword
origLegacy := settings.Config.Auth.AdminPassword
defer func() {
settings.Config.Auth.Methods.PasswordAuth.AdminPassword = origNested
settings.Config.Auth.AdminPassword = origLegacy
}()
for _, cfg := range []string{"", "admin"} {
settings.Config.Auth.Methods.PasswordAuth.AdminPassword = cfg
settings.Config.Auth.AdminPassword = cfg
plain, generated, err := bootstrapDefaultAdminPassword("bootstrap-user")
if err != nil {
@@ -4,11 +4,12 @@ import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"golang.org/x/crypto/bcrypt"
)
func TestMain(m *testing.M) {
utils.BcryptCost = bcrypt.MinCost
os.Exit(m.Run())
os.Exit(testutil.Main(m))
}
+5 -9
View File
@@ -19,15 +19,11 @@ func isUserLookupNotFound(err error) bool {
}
func noAuthAdminUsername() string {
admin := settings.Config.Auth.AdminUsername
if admin == "" {
return "admin"
}
return admin
return settings.PasswordAdminUsername()
}
// ResolveNoAuthUser returns the user noauth mode impersonates at runtime: auth.adminUsername when
// present, otherwise the sole user when exactly one account exists.
// ResolveNoAuthUser returns the user noauth mode impersonates at runtime: the password
// admin username when present, otherwise the sole user when exactly one account exists.
func ResolveNoAuthUser() (users.User, error) {
target := noAuthAdminUsername()
user, err := GetUserByUsername(target)
@@ -50,7 +46,7 @@ func ResolveNoAuthUser() (users.User, error) {
// EnsureNoAuthAdminUserAfterMigration runs once after Bolt→SQLite user import when noauth is enabled.
// It never renames or modifies existing users. A new admin account is created only when
// auth.adminUsername is missing and more than one user was migrated (a single migrated user is
// auth.methods.password.adminUsername is missing and more than one user was migrated (a single migrated user is
// used at runtime via ResolveNoAuthUser).
func EnsureNoAuthAdminUserAfterMigration(store *sqldb.SQLStore) error {
if store == nil || !settings.Config.Auth.Methods.NoAuth {
@@ -88,7 +84,7 @@ func createNoAuthAdminUser(store *sqldb.SQLStore) error {
ApplyUserDefaults(user)
user.Username = target
password := settings.Config.Auth.AdminPassword
password := settings.PasswordAdminPassword()
if password == "" {
password = "admin"
}
+1 -4
View File
@@ -27,10 +27,7 @@ func QuickSetup() error {
user := &users.User{}
ApplyUserDefaults(user)
user.Username = settings.Config.Auth.AdminUsername
if user.Username == "" {
user.Username = "admin"
}
user.Username = settings.PasswordAdminUsername()
user.Permissions.Admin = true
user.LoginMethod = users.LoginMethodPassword
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,18 @@
package state
import "testing"
func TestCopyMoveItemDelta(t *testing.T) {
if got := copyMoveItemDelta("move", "/weddingphotos/sub", "/weddingphotos", 1000, 0); got != 0 {
t.Fatalf("move within quota root: got %d want 0", got)
}
if got := copyMoveItemDelta("move", "/other", "/weddingphotos", 1000, 0); got != 1000 {
t.Fatalf("move into quota root: got %d want 1000", got)
}
if got := copyMoveItemDelta("copy", "/other", "/weddingphotos", 1000, 0); got != 1000 {
t.Fatalf("copy into quota root: got %d want 1000", got)
}
if got := copyMoveItemDelta("copy", "/other", "/weddingphotos", 1000, 200); got != 800 {
t.Fatalf("copy with overwrite: got %d want 800", got)
}
}
@@ -0,0 +1,167 @@
package state
import (
"sync"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestQuotaFlusherConcurrentFlushAndStop(t *testing.T) {
quotaFlusherMu.Lock()
oldFlusher := quotaFlusher
quotaFlusher = nil
quotaFlusherMu.Unlock()
oldCounters := quotaCounters
initQuotaMaps()
defer func() {
StopQuotaFlusher()
quotasMux.Lock()
quotaCounters = oldCounters
quotasMux.Unlock()
quotaFlusherMu.Lock()
quotaFlusher = oldFlusher
quotaFlusherMu.Unlock()
}()
quotaCounters["test-quota"] = &quotaCounterMem{UsedBytes: 100, Dirty: true}
flusher := &quotaCounterFlusher{
dirtyIDs: map[string]struct{}{"test-quota": {}},
flushCh: make(chan struct{}, 1),
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
flushInterval: time.Hour,
maxBuffers: 1,
}
quotaFlusherMu.Lock()
quotaFlusher = flusher
quotaFlusherMu.Unlock()
go flusher.loop()
holdQuotas := make(chan struct{})
releaseQuotas := make(chan struct{})
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(releaseQuotas) }) }
defer release()
go func() {
quotasMux.Lock()
close(holdQuotas)
<-releaseQuotas
quotasMux.Unlock()
}()
<-holdQuotas
stopDone := make(chan struct{})
go func() {
defer close(stopDone)
StopQuotaFlusher()
}()
const total = 200
var wg sync.WaitGroup
wg.Add(total)
for i := 0; i < total; i++ {
go func() {
defer wg.Done()
markQuotaCounterDirty("test-quota")
signalQuotaFlush()
}()
}
writersDone := make(chan struct{})
go func() {
wg.Wait()
close(writersDone)
}()
select {
case <-writersDone:
case <-time.After(2 * time.Second):
release()
t.Fatal("quota writers blocked on quotasMux while the flusher was stopping")
}
release()
select {
case <-stopDone:
case <-time.After(2 * time.Second):
t.Fatal("StopQuotaFlusher deadlocked with concurrent flush/mutation while quotasMux was held")
}
}
func TestQuotaFlusherReplacementWhileQuotasLocked(t *testing.T) {
quotaFlusherMu.Lock()
oldFlusher := quotaFlusher
quotaFlusher = nil
quotaFlusherMu.Unlock()
oldCounters := quotaCounters
initQuotaMaps()
defer func() {
StopQuotaFlusher()
quotasMux.Lock()
quotaCounters = oldCounters
quotasMux.Unlock()
quotaFlusherMu.Lock()
quotaFlusher = oldFlusher
quotaFlusherMu.Unlock()
}()
quotaCounters["test-quota"] = &quotaCounterMem{UsedBytes: 50, Dirty: true}
startQuotaFlusher(settings.QuotasConfig{
FlushIntervalSeconds: 3600,
FlushMaxBuffers: 1,
})
holdQuotas := make(chan struct{})
releaseQuotas := make(chan struct{})
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(releaseQuotas) }) }
defer release()
go func() {
quotasMux.Lock()
close(holdQuotas)
<-releaseQuotas
quotasMux.Unlock()
}()
<-holdQuotas
replaceDone := make(chan struct{})
go func() {
defer close(replaceDone)
startQuotaFlusher(settings.QuotasConfig{
FlushIntervalSeconds: 3600,
FlushMaxBuffers: 1,
})
}()
const total = 100
var wg sync.WaitGroup
wg.Add(total)
for i := 0; i < total; i++ {
go func() {
defer wg.Done()
markQuotaCounterDirty("test-quota")
}()
}
writersDone := make(chan struct{})
go func() {
wg.Wait()
close(writersDone)
}()
select {
case <-writersDone:
case <-time.After(2 * time.Second):
release()
t.Fatal("quota writers blocked on quotasMux while replacing the flusher")
}
release()
select {
case <-replaceDone:
case <-time.After(2 * time.Second):
t.Fatal("startQuotaFlusher deadlocked while replacing flusher with quotasMux held")
}
}
+187
View File
@@ -0,0 +1,187 @@
package state
import (
"encoding/json"
"fmt"
"strings"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
const (
shareDefaultsDefaultSettingKey = "shareDefaults.default"
shareDefaultsEnforcedDefaultKey = "shareDefaults.enforced.default"
)
var (
shareDefaultsMu sync.RWMutex
shareDefaultsDefault settings.ShareDefaults
shareDefaultsEnforcedDefault settings.ShareDefaultsEnforcement
// errInjectShareDefaultsEnforcedSave is test-only; simulates enforced save failure inside a transaction.
errInjectShareDefaultsEnforcedSave error
)
// InitShareDefaultsSettings loads persisted share defaults from SQLite and seeds from config when missing.
func InitShareDefaultsSettings() error {
seed := settings.Config.ShareDefaults
if _, err := sqlDb.GetSetting(shareDefaultsDefaultSettingKey); err != nil {
if saveErr := sqlDb.SaveSetting(shareDefaultsDefaultSettingKey, seed); saveErr != nil {
return fmt.Errorf("seed share defaults: %w", saveErr)
}
}
if _, err := sqlDb.GetSetting(shareDefaultsEnforcedDefaultKey); err != nil {
if saveErr := sqlDb.SaveSetting(shareDefaultsEnforcedDefaultKey, settings.ShareDefaultsEnforcement{}); saveErr != nil {
return fmt.Errorf("seed enforced share defaults: %w", saveErr)
}
}
defaults, err := loadShareDefaultsSetting(shareDefaultsDefaultSettingKey)
if err != nil {
return fmt.Errorf("load share defaults: %w", err)
}
enforcedDefault, err := loadShareDefaultsEnforcedSetting(shareDefaultsEnforcedDefaultKey)
if err != nil {
return fmt.Errorf("load enforced share defaults: %w", err)
}
shareDefaultsMu.Lock()
shareDefaultsDefault = defaults
shareDefaultsEnforcedDefault = enforcedDefault
settings.Config.ShareDefaults = defaults
shareDefaultsMu.Unlock()
return nil
}
func loadShareDefaultsSetting(key string) (settings.ShareDefaults, error) {
raw, err := sqlDb.GetSetting(key)
if err != nil {
return settings.ShareDefaults{}, err
}
var sd settings.ShareDefaults
if err := json.Unmarshal(raw, &sd); err != nil {
return settings.ShareDefaults{}, fmt.Errorf("parse %s: %w", key, err)
}
return sd, nil
}
func loadShareDefaultsEnforcedSetting(key string) (settings.ShareDefaultsEnforcement, error) {
raw, err := sqlDb.GetSetting(key)
if err != nil {
return settings.ShareDefaultsEnforcement{}, err
}
var enforced settings.ShareDefaultsEnforcement
if err := json.Unmarshal(raw, &enforced); err != nil {
return settings.ShareDefaultsEnforcement{}, fmt.Errorf("parse %s: %w", key, err)
}
return enforced, nil
}
// GetShareDefaults returns the share defaults template.
func GetShareDefaults() settings.ShareDefaults {
shareDefaultsMu.RLock()
defer shareDefaultsMu.RUnlock()
return shareDefaultsDefault
}
// GetEnforcedShareDefaults returns share enforcement flags.
func GetEnforcedShareDefaults() settings.ShareDefaultsEnforcement {
shareDefaultsMu.RLock()
defer shareDefaultsMu.RUnlock()
return shareDefaultsEnforcedDefault
}
// PatchShareDefaults merges patch JSON into share defaults and persists.
func PatchShareDefaults(patchJSON []byte) error {
return patchShareDefaultsLocked(patchJSON, nil)
}
// PatchShareDefaultsEnforced merges enforcement patch JSON and persists.
func PatchShareDefaultsEnforced(patchJSON []byte) error {
return patchShareDefaultsLocked(nil, patchJSON)
}
// PatchShareDefaultsCombined merges values and enforcement patches atomically.
func PatchShareDefaultsCombined(valuesPatch, enforcedPatch []byte) error {
return patchShareDefaultsLocked(valuesPatch, enforcedPatch)
}
// IsShareDefaultsPersistenceError reports whether a patch error came from storage.
func IsShareDefaultsPersistenceError(err error) bool {
return err != nil && strings.HasPrefix(err.Error(), "save ")
}
func patchShareDefaultsLocked(valuesPatch, enforcedPatch []byte) error {
shareDefaultsMu.Lock()
newValues := shareDefaultsDefault
newEnforced := shareDefaultsEnforcedDefault
if len(valuesPatch) > 0 {
merged, mergeErr := settings.MergeShareDefaultsPatchJSON(shareDefaultsDefault, valuesPatch)
if mergeErr != nil {
shareDefaultsMu.Unlock()
return mergeErr
}
newValues = merged
}
if len(enforcedPatch) > 0 {
merged, mergeErr := settings.MergeShareEnforcedPatchJSON(shareDefaultsEnforcedDefault, enforcedPatch)
if mergeErr != nil {
shareDefaultsMu.Unlock()
return mergeErr
}
newEnforced = merged
}
saveValues := len(valuesPatch) > 0
saveEnforced := len(enforcedPatch) > 0
if saveValues && saveEnforced {
if saveErr := saveShareDefaultsCombined(newValues, newEnforced); saveErr != nil {
shareDefaultsMu.Unlock()
return saveErr
}
} else if saveValues {
if saveErr := sqlDb.SaveSetting(shareDefaultsDefaultSettingKey, newValues); saveErr != nil {
shareDefaultsMu.Unlock()
return fmt.Errorf("save share defaults: %w", saveErr)
}
} else if saveEnforced {
if saveErr := sqlDb.SaveSetting(shareDefaultsEnforcedDefaultKey, newEnforced); saveErr != nil {
shareDefaultsMu.Unlock()
return fmt.Errorf("save enforced share defaults: %w", saveErr)
}
}
shareDefaultsDefault = newValues
shareDefaultsEnforcedDefault = newEnforced
if len(valuesPatch) > 0 {
settings.Config.ShareDefaults = newValues
}
shareDefaultsMu.Unlock()
return nil
}
func saveShareDefaultsCombined(newValues settings.ShareDefaults, newEnforced settings.ShareDefaultsEnforcement) error {
tx, err := sqlDb.BeginTx()
if err != nil {
return fmt.Errorf("save share defaults: %w", err)
}
if err := sqlDb.SaveSettingTx(tx, shareDefaultsDefaultSettingKey, newValues); err != nil {
_ = tx.Rollback()
return fmt.Errorf("save share defaults: %w", err)
}
if errInjectShareDefaultsEnforcedSave != nil {
_ = tx.Rollback()
return fmt.Errorf("save enforced share defaults: %w", errInjectShareDefaultsEnforcedSave)
}
if err := sqlDb.SaveSettingTx(tx, shareDefaultsEnforcedDefaultKey, newEnforced); err != nil {
_ = tx.Rollback()
return fmt.Errorf("save enforced share defaults: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("save share defaults: %w", err)
}
return nil
}
@@ -0,0 +1,115 @@
package state
import (
"errors"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestPatchShareDefaults_persistsValues(t *testing.T) {
initSidebarLinkTestDB(t)
if err := PatchShareDefaults([]byte(`{"shareType":"upload","allowCreate":true}`)); err != nil {
t.Fatal(err)
}
got := GetShareDefaults()
if got.ShareType != "upload" {
t.Fatalf("shareType=%q want upload", got.ShareType)
}
if !got.AllowCreate {
t.Fatal("expected allowCreate true")
}
}
func TestPatchShareDefaultsEnforced_persistsFlags(t *testing.T) {
initSidebarLinkTestDB(t)
if err := PatchShareDefaultsEnforced([]byte(`{"allowModify":true}`)); err != nil {
t.Fatal(err)
}
got := GetEnforcedShareDefaults()
if !got.AllowModify {
t.Fatal("expected allowModify enforced")
}
}
func TestPatchShareDefaultsCombined_persistsBoth(t *testing.T) {
initSidebarLinkTestDB(t)
if err := PatchShareDefaultsCombined(
[]byte(`{"shareType":"upload","allowCreate":true}`),
[]byte(`{"allowModify":true}`),
); err != nil {
t.Fatal(err)
}
values := GetShareDefaults()
if values.ShareType != "upload" {
t.Fatalf("shareType=%q want upload", values.ShareType)
}
enforced := GetEnforcedShareDefaults()
if !enforced.AllowModify {
t.Fatal("expected allowModify enforced")
}
}
func TestPatchShareDefaultsCombined_rollsBackBothOnEnforcedSaveFailure(t *testing.T) {
t.Setenv("FILEBROWSER_ONLYOFFICE_SECRET", "")
settings.Initialize("../../../_docker/src/noauth/backend/config.yaml")
settings.Env.IsPlaywright = true
dbPath := filepath.Join(t.TempDir(), "filebrowser.sqlite")
if _, err := Initialize(dbPath); err != nil {
t.Fatal(err)
}
if err := PatchShareDefaults([]byte(`{"shareType":"view","allowCreate":false}`)); err != nil {
t.Fatal(err)
}
if err := PatchShareDefaultsEnforced([]byte(`{"allowModify":false}`)); err != nil {
t.Fatal(err)
}
valuesBefore := GetShareDefaults()
enforcedBefore := GetEnforcedShareDefaults()
errInjectShareDefaultsEnforcedSave = errors.New("simulated enforced save failure")
t.Cleanup(func() {
errInjectShareDefaultsEnforcedSave = nil
})
err := PatchShareDefaultsCombined(
[]byte(`{"shareType":"upload","allowCreate":true}`),
[]byte(`{"allowModify":true}`),
)
if err == nil {
t.Fatal("expected combined patch failure")
}
if !IsShareDefaultsPersistenceError(err) {
t.Fatalf("expected persistence error, got %v", err)
}
if got := GetShareDefaults(); got.ShareType != valuesBefore.ShareType || got.AllowCreate != valuesBefore.AllowCreate {
t.Fatalf("in-memory values changed after failed patch: got shareType=%q allowCreate=%v want shareType=%q allowCreate=%v",
got.ShareType, got.AllowCreate, valuesBefore.ShareType, valuesBefore.AllowCreate)
}
if got := GetEnforcedShareDefaults(); got.AllowModify != enforcedBefore.AllowModify {
t.Fatalf("in-memory enforced changed after failed patch: got allowModify=%v want %v", got.AllowModify, enforcedBefore.AllowModify)
}
if err := Close(); err != nil {
t.Fatal(err)
}
if _, err := Initialize(dbPath); err != nil {
t.Fatal(err)
}
if got := GetShareDefaults(); got.ShareType != valuesBefore.ShareType || got.AllowCreate != valuesBefore.AllowCreate {
t.Fatalf("persisted values changed after failed patch: got shareType=%q allowCreate=%v want shareType=%q allowCreate=%v",
got.ShareType, got.AllowCreate, valuesBefore.ShareType, valuesBefore.AllowCreate)
}
if got := GetEnforcedShareDefaults(); got.AllowModify != enforcedBefore.AllowModify {
t.Fatalf("persisted enforced changed after failed patch: got allowModify=%v want %v", got.AllowModify, enforcedBefore.AllowModify)
}
}
+46 -1
View File
@@ -105,9 +105,18 @@ func CreateShare(link *share.Share) error {
return fmt.Errorf("share with hash %s already exists", link.Hash)
}
if link.QuotaLimitBytes > 0 {
if err := EnsureShareQuotaCounter(link.Hash); err != nil {
return err
}
}
// 2. Write to database
err := sqlDb.SaveShare(link)
if err != nil {
if link.QuotaLimitBytes > 0 {
_ = DeleteShareQuotaCounter(link.Hash)
}
return err
}
@@ -184,6 +193,24 @@ func DeleteShare(hash string) error {
return err
}
if err := DeleteShareQuotaCounter(hash); err != nil {
if saveErr := sqlDb.SaveShare(link); saveErr != nil {
return fmt.Errorf("delete share quota counter failed and rollback failed: %v (rollback: %v)", err, saveErr)
}
if restoreErr := EnsureShareQuotaCounter(hash); restoreErr != nil {
return fmt.Errorf("delete share quota counter failed and counter restore failed: %v (restore: %v)", err, restoreErr)
}
sourceName := link.GetSourceName()
indexPath := link.Path
if indexPath == "" {
indexPath = "/"
}
if rebuildErr := RebuildShareQuotaUsage(hash, sourceName, indexPath); rebuildErr != nil {
logger.Warningf("rebuild share quota usage after delete rollback failed: %v", rebuildErr)
}
return err
}
// 3. Remove from cache to match database
delete(sharesByHash, hash)
@@ -383,7 +410,25 @@ func PrepSharesForFrontend(viewer *users.User, r *http.Request, publicHost, publ
}
return u.Username
}
return share.PrepForFrontend(viewer, r, publicHost, publicScheme, ownerLookup, links...)
out := share.PrepForFrontend(viewer, r, publicHost, publicScheme, ownerLookup, links...)
enrichShareQuotaFields(out)
return out
}
func enrichShareQuotaFields(out []*share.ShareFrontend) {
for _, s := range out {
if s == nil || s.QuotaLimitBytes <= 0 {
continue
}
used, reserved := GetShareQuotaUsage(s.Hash)
s.QuotaUsedBytes = used
s.QuotaReservedBytes = reserved
avail := s.QuotaLimitBytes - used - reserved
if avail < 0 {
avail = 0
}
s.QuotaAvailableBytes = avail
}
}
// PrepShareValuesForFrontend builds API-safe ShareFrontend copies from immutable share values.
@@ -0,0 +1,227 @@
package state
import (
"encoding/json"
"fmt"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
)
const sidebarLinkDefaultsSettingKey = "sidebarLinkDefaults"
var (
sidebarLinkDefaultsMu sync.RWMutex
sidebarLinkDefaultsPatchMu sync.Mutex
sidebarLinkDefaults usersidebar.SidebarLinkDefaultsDocument
sidebarLinkDefaultsNeedResync bool
sidebarLinkDefaultsResyncPending bool
// errInjectResyncSidebarDefaults is set by tests in this package only.
errInjectResyncSidebarDefaults error
)
// InitSidebarLinkDefaults loads persisted sidebar link defaults into memory.
func InitSidebarLinkDefaults() error {
if sqlDb == nil {
return fmt.Errorf("sqlDb not initialized")
}
doc, found, err := loadSidebarLinkDefaultsDocument()
if err != nil {
return err
}
if !found {
doc = usersidebar.InitialSidebarLinkDefaultsDocument()
if saveErr := saveSidebarLinkDefaultsDocument(doc); saveErr != nil {
return saveErr
}
} else {
ensured, changed := usersidebar.EnsureAllSourcesInDefaults(doc)
if changed {
if saveErr := saveSidebarLinkDefaultsDocument(ensured); saveErr != nil {
return saveErr
}
doc = ensured
sidebarLinkDefaultsNeedResync = true
}
}
sidebarLinkDefaultsMu.Lock()
sidebarLinkDefaults = doc
sidebarLinkDefaultsMu.Unlock()
if !found {
sidebarLinkDefaultsNeedResync = true
}
return nil
}
func loadSidebarLinkDefaultsDocument() (usersidebar.SidebarLinkDefaultsDocument, bool, error) {
raw, err := sqlDb.GetSetting(sidebarLinkDefaultsSettingKey)
if err != nil {
if err.Error() == fmt.Sprintf("setting not found: %s", sidebarLinkDefaultsSettingKey) {
return usersidebar.SidebarLinkDefaultsDocument{}, false, nil
}
return usersidebar.SidebarLinkDefaultsDocument{}, false, err
}
var doc usersidebar.SidebarLinkDefaultsDocument
if err := json.Unmarshal(raw, &doc); err != nil {
return usersidebar.SidebarLinkDefaultsDocument{}, false, fmt.Errorf("parse %s: %w", sidebarLinkDefaultsSettingKey, err)
}
if doc.Items == nil {
doc.Items = []usersidebar.SidebarLinkDefaultItem{}
}
return doc, true, nil
}
func saveSidebarLinkDefaultsDocument(doc usersidebar.SidebarLinkDefaultsDocument) error {
if doc.Items == nil {
doc.Items = []usersidebar.SidebarLinkDefaultItem{}
}
return sqlDb.SaveSetting(sidebarLinkDefaultsSettingKey, doc)
}
// EffectiveSidebarLinkDefaults returns the in-memory sidebar link defaults document.
func EffectiveSidebarLinkDefaults() usersidebar.SidebarLinkDefaultsDocument {
sidebarLinkDefaultsMu.RLock()
defer sidebarLinkDefaultsMu.RUnlock()
return sidebarLinkDefaults
}
// SidebarLinkDefaultsSettings is the admin API response for GET /api/settings/sidebar-link-defaults.
type SidebarLinkDefaultsSettings struct {
Items []usersidebar.SidebarLinkDefaultItem `json:"items"`
Sources []string `json:"sources"`
}
// GetSidebarLinkDefaults returns admin settings merged with all configured sources (memory only).
func GetSidebarLinkDefaults() SidebarLinkDefaultsSettings {
doc := usersidebar.DocumentWithAllSources(EffectiveSidebarLinkDefaults())
doc = usersidebar.FrontendDefaultsDocument(doc)
return SidebarLinkDefaultsSettings{
Items: doc.Items,
Sources: usersidebar.ConfiguredSourceNames(),
}
}
// GetSidebarLinkDefaultsForUser returns sidebar link defaults visible to u.
// Admins receive the full admin view; other users only see items and sources they can access.
func GetSidebarLinkDefaultsForUser(u *users.User) SidebarLinkDefaultsSettings {
if u == nil || u.Username == "anonymous" || u.Permissions.Admin {
return GetSidebarLinkDefaults()
}
doc := usersidebar.FrontendDefaultsDocument(EffectiveSidebarLinkDefaults())
doc = usersidebar.FilterDefaultsDocumentForUser(doc, u.BackendScopes)
return SidebarLinkDefaultsSettings{
Items: doc.Items,
Sources: usersidebar.SourceDisplayNamesForScopes(u.BackendScopes),
}
}
// PatchSidebarLinkDefaults replaces the full sidebar link defaults document and resyncs users when needed.
func PatchSidebarLinkDefaults(doc usersidebar.SidebarLinkDefaultsDocument) error {
sidebarLinkDefaultsPatchMu.Lock()
defer sidebarLinkDefaultsPatchMu.Unlock()
if doc.Items == nil {
doc.Items = []usersidebar.SidebarLinkDefaultItem{}
}
doc = usersidebar.NormalizeDefaultsDocument(doc)
sidebarLinkDefaultsMu.Lock()
prev := sidebarLinkDefaults
docChanged := !sidebarLinkDefaultsEqual(prev, doc)
if docChanged {
if err := saveSidebarLinkDefaultsDocument(doc); err != nil {
sidebarLinkDefaultsMu.Unlock()
return fmt.Errorf("save sidebar link defaults: %w", err)
}
sidebarLinkDefaults = doc
}
syncDoc := sidebarLinkDefaults
needsResync := docChanged || sidebarLinkDefaultsResyncPending
sidebarLinkDefaultsMu.Unlock()
if !needsResync {
return nil
}
if err := resyncSidebarLinkDefaultsForAllUsers(syncDoc); err != nil {
sidebarLinkDefaultsResyncPending = true
return err
}
if err := resyncEnforcedSidebarLinksForAllUsers(syncDoc); err != nil {
sidebarLinkDefaultsResyncPending = true
return err
}
sidebarLinkDefaultsResyncPending = false
return nil
}
func sidebarLinkDefaultsEqual(a, b usersidebar.SidebarLinkDefaultsDocument) bool {
return sameDefaultFlags(a, b, func(item usersidebar.SidebarLinkDefaultItem) bool { return item.Enabled }) &&
sameDefaultFlags(a, b, func(item usersidebar.SidebarLinkDefaultItem) bool { return item.Enforced }) &&
sameDefaultLinkTemplates(a, b)
}
func sameDefaultLinkTemplates(a, b usersidebar.SidebarLinkDefaultsDocument) bool {
ma := defaultLinkTemplatesMap(a)
mb := defaultLinkTemplatesMap(b)
if len(ma) != len(mb) {
return false
}
for k, va := range ma {
vb, ok := mb[k]
if !ok || !sidebarLinksEquivalent(va, vb) {
return false
}
}
return true
}
func defaultLinkTemplatesMap(doc usersidebar.SidebarLinkDefaultsDocument) map[string]users.SidebarLink {
out := make(map[string]users.SidebarLink)
for _, item := range doc.Items {
prepared, ok := prepareSidebarDefaultLink(item.Link)
if !ok {
continue
}
out[usersidebar.LinkKey(prepared)] = prepared
}
return out
}
func sidebarLinksEquivalent(a, b users.SidebarLink) bool {
return a.Name == b.Name && a.Category == b.Category && a.Target == b.Target && a.Icon == b.Icon && a.SourceName == b.SourceName
}
func sameDefaultFlags(before, after usersidebar.SidebarLinkDefaultsDocument, flag func(usersidebar.SidebarLinkDefaultItem) bool) bool {
b := defaultFlagsMap(before, flag)
a := defaultFlagsMap(after, flag)
if len(b) != len(a) {
return false
}
for k, v := range b {
if a[k] != v {
return false
}
}
return true
}
func defaultFlagsMap(doc usersidebar.SidebarLinkDefaultsDocument, flag func(usersidebar.SidebarLinkDefaultItem) bool) map[string]bool {
out := make(map[string]bool)
for _, item := range doc.Items {
prepared, ok := prepareSidebarDefaultLink(item.Link)
if !ok {
continue
}
out[usersidebar.LinkKey(prepared)] = flag(item)
}
return out
}
func prepareSidebarDefaultLink(link users.SidebarLink) (users.SidebarLink, bool) {
normalized, _ := usersidebar.NormalizeSidebarLinks([]users.SidebarLink{link})
if len(normalized) == 0 {
return users.SidebarLink{}, false
}
return normalized[0], true
}
@@ -0,0 +1,98 @@
package state
import (
"fmt"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
"github.com/gtsteffaniak/go-logger/logger"
)
// ResyncSidebarLinkDefaultsForAllUsers merges enabled default sidebar links for every user.
func ResyncSidebarLinkDefaultsForAllUsers() error {
return resyncSidebarLinkDefaultsForAllUsers(EffectiveSidebarLinkDefaults())
}
func resyncSidebarLinkDefaultsForAllUsers(doc usersidebar.SidebarLinkDefaultsDocument) error {
if errInjectResyncSidebarDefaults != nil {
return errInjectResyncSidebarDefaults
}
usersMux.Lock()
defer usersMux.Unlock()
usersList, err := sqlDb.ListUsers()
if err != nil {
return fmt.Errorf("list users for sidebar defaults sync: %w", err)
}
var updated int
for _, row := range usersList {
if row == nil {
continue
}
u := cloneUserPtr(row)
links, changed := usersidebar.MergeDefaultLinks(u.SidebarLinks, u.BackendScopes, doc)
if !changed {
continue
}
u.SidebarLinks = links
if normalized, normChanged := usersidebar.NormalizeSidebarLinks(u.SidebarLinks); normChanged {
u.SidebarLinks = normalized
}
u.FrontendScopes = nil
u.SourcePermissions = nil
if err := sqlDb.UpdateUser(u); err != nil {
return fmt.Errorf("sync sidebar defaults for user %s: %w", u.Username, err)
}
putUserInCache(u)
updated++
}
if updated > 0 {
logger.Debugf("synced sidebar link defaults for %d users", updated)
}
return nil
}
// ResyncEnforcedSidebarLinksForAllUsers merges enforced sidebar links for non-admin users.
func ResyncEnforcedSidebarLinksForAllUsers() error {
return resyncEnforcedSidebarLinksForAllUsers(EffectiveSidebarLinkDefaults())
}
func resyncEnforcedSidebarLinksForAllUsers(doc usersidebar.SidebarLinkDefaultsDocument) error {
usersMux.Lock()
defer usersMux.Unlock()
usersList, err := sqlDb.ListUsers()
if err != nil {
return fmt.Errorf("list users for enforced sidebar sync: %w", err)
}
var updated int
for _, row := range usersList {
if row == nil {
continue
}
u := cloneUserPtr(row)
if u.Permissions.Admin {
continue
}
links, changed := usersidebar.MergeEnforcedLinks(u.SidebarLinks, u.BackendScopes, doc, u.Permissions.Admin)
if !changed {
continue
}
u.SidebarLinks = links
if normalized, normChanged := usersidebar.NormalizeSidebarLinks(u.SidebarLinks); normChanged {
u.SidebarLinks = normalized
}
u.FrontendScopes = nil
u.SourcePermissions = nil
if err := sqlDb.UpdateUser(u); err != nil {
return fmt.Errorf("sync enforced sidebar links for user %s: %w", u.Username, err)
}
putUserInCache(u)
updated++
}
if updated > 0 {
logger.Debugf("synced enforced sidebar links for %d users", updated)
}
return nil
}
@@ -0,0 +1,69 @@
package state
import (
"errors"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/usersidebar"
)
func TestPatchSidebarLinkDefaults_retriesPendingResync(t *testing.T) {
initSidebarLinkTestDB(t)
doc := usersidebar.InitialSidebarLinkDefaultsDocument()
doc.Items = append(doc.Items, usersidebar.SidebarLinkDefaultItem{
Enabled: true,
Link: users.SidebarLink{
Name: "Wiki",
Category: "custom",
Target: "/wiki",
Icon: "link",
},
})
if err := PatchSidebarLinkDefaults(doc); err != nil {
t.Fatal(err)
}
sidebarLinkDefaultsResyncPending = true
if err := PatchSidebarLinkDefaults(doc); err != nil {
t.Fatal(err)
}
if sidebarLinkDefaultsResyncPending {
t.Fatal("expected pending resync to clear after successful retry")
}
}
func TestPatchSidebarLinkDefaults_marksPendingOnResyncFailure(t *testing.T) {
initSidebarLinkTestDB(t)
errInjectResyncSidebarDefaults = errors.New("simulated resync failure")
t.Cleanup(func() {
errInjectResyncSidebarDefaults = nil
sidebarLinkDefaultsResyncPending = false
})
doc := usersidebar.InitialSidebarLinkDefaultsDocument()
doc.Items = append(doc.Items, usersidebar.SidebarLinkDefaultItem{
Enabled: true,
Link: users.SidebarLink{
Name: "Docs",
Category: "custom",
Target: "/docs",
},
})
if err := PatchSidebarLinkDefaults(doc); err == nil {
t.Fatal("expected resync failure")
}
if !sidebarLinkDefaultsResyncPending {
t.Fatal("expected pending resync after failure")
}
errInjectResyncSidebarDefaults = nil
if err := PatchSidebarLinkDefaults(doc); err != nil {
t.Fatal(err)
}
if sidebarLinkDefaultsResyncPending {
t.Fatal("expected pending resync to clear after successful retry")
}
}
+56 -31
View File
@@ -16,6 +16,9 @@ import (
)
var (
// Serializes Open/Close so tests and startup cannot interleave lifecycle or sqlDb swaps.
stateLifecycleMu sync.Mutex
// Separate mutexes for each resource type for better concurrency
usersMux sync.RWMutex
sharesMux sync.RWMutex
@@ -42,23 +45,15 @@ func Initialize(dbPath string) (bool, error) {
}
func initialize(dbPath string) (bool, error) {
// Lock all mutexes during initialization
usersMux.Lock()
sharesMux.Lock()
indexMux.Lock()
defer usersMux.Unlock()
defer sharesMux.Unlock()
defer indexMux.Unlock()
if !settings.Env.IsCLIMode {
logger.Info("Initializing state management system...")
}
var existingDb bool
var err error
sqlDb, existingDb, err = sqldb.NewSQLStore(dbPath)
store, existingDb, err := sqldb.NewSQLStore(dbPath)
if err != nil {
return false, fmt.Errorf("failed to initialize SQL database: %w", err)
}
sqlDb = store
if err = InitUserDefaultsSettings(); err != nil {
return existingDb, fmt.Errorf("failed to initialize user defaults settings: %w", err)
@@ -68,6 +63,18 @@ func initialize(dbPath string) (bool, error) {
return existingDb, fmt.Errorf("failed to initialize source access defaults: %w", err)
}
if err = InitSidebarLinkDefaults(); err != nil {
return existingDb, fmt.Errorf("failed to initialize sidebar link defaults: %w", err)
}
if err = InitToolAccessDefaults(); err != nil {
return existingDb, fmt.Errorf("failed to initialize tool access defaults: %w", err)
}
if err = InitShareDefaultsSettings(); err != nil {
return existingDb, fmt.Errorf("failed to initialize share defaults settings: %w", err)
}
if err = InitAuthSigningKey(); err != nil {
return existingDb, fmt.Errorf("failed to initialize auth signing key: %w", err)
}
@@ -82,39 +89,34 @@ func initialize(dbPath string) (bool, error) {
}
}
// Initialize caches
sharesByHash = make(map[string]*share.Share)
sharesByPath = make(map[string][]string)
indexInfoByPath = make(map[string]*dbindex.IndexInfo)
logger.Debugf("Loading shares and index data into memory...")
users.SetUsernameToID(UserIDForUsername)
// Load shares
newSharesByHash := make(map[string]*share.Share)
newSharesByPath := make(map[string][]string)
sharesList, err := sqlDb.ListAllShares()
if err != nil {
return existingDb, fmt.Errorf("failed to load shares: %w", err)
}
for _, link := range sharesList {
sharesByHash[link.Hash] = link
newSharesByHash[link.Hash] = link
pathKey := makePathKey(link.SourcePath, link.Path)
sharesByPath[pathKey] = append(sharesByPath[pathKey], link.Hash)
newSharesByPath[pathKey] = append(newSharesByPath[pathKey], link.Hash)
}
logger.Debugf("Loaded %d shares", len(sharesList))
// Load index info
newIndexInfoByPath := make(map[string]*dbindex.IndexInfo)
allIndexInfo, err := sqlDb.ListAllIndexInfo()
if err != nil {
return existingDb, fmt.Errorf("failed to load index info: %w", err)
}
for _, info := range allIndexInfo {
indexInfoByPath[info.Path] = info
newIndexInfoByPath[info.Path] = info
}
logger.Debugf("Loaded %d index info entries", len(allIndexInfo))
// Initialize access rules cache
accessDb = &access.Storage{
newAccessDb := &access.Storage{
AllRules: make(access.SourceRuleMap),
Groups: make(access.GroupMap),
RevokedTokens: make(map[string]int64),
@@ -125,28 +127,28 @@ func initialize(dbPath string) (bool, error) {
if err != nil {
return existingDb, fmt.Errorf("failed to load access rules: %w", err)
}
accessDb.AllRules = allRules
newAccessDb.AllRules = allRules
logger.Debugf("Loaded access rules for %d sources", len(allRules))
allGroups, err := sqlDb.GetAllGroups()
if err != nil {
return existingDb, fmt.Errorf("failed to load groups: %w", err)
}
accessDb.Groups = allGroups
newAccessDb.Groups = allGroups
logger.Debugf("Loaded %d groups", len(allGroups))
revokedTokens, err := sqlDb.GetAllRevokedTokens()
if err != nil {
return existingDb, fmt.Errorf("failed to load revoked tokens: %w", err)
}
accessDb.RevokedTokens = revokedTokens
newAccessDb.RevokedTokens = revokedTokens
logger.Debugf("Loaded %d revoked tokens", len(revokedTokens))
hashedTokens, err := sqlDb.GetAllHashedTokens()
if err != nil {
return existingDb, fmt.Errorf("failed to load hashed tokens: %w", err)
}
accessDb.HashedTokens = make(map[string]access.HashedTokenInfo, len(hashedTokens))
newAccessDb.HashedTokens = make(map[string]access.HashedTokenInfo, len(hashedTokens))
now := time.Now()
expired := 0
for hash, record := range hashedTokens {
@@ -157,7 +159,7 @@ func initialize(dbPath string) (bool, error) {
}
continue
}
accessDb.HashedTokens[hash] = access.HashedTokenInfo{
newAccessDb.HashedTokens[hash] = access.HashedTokenInfo{
UserID: record.UserID,
IsSession: record.IsSession,
ExpiresAt: record.ExpiresAt,
@@ -165,7 +167,18 @@ func initialize(dbPath string) (bool, error) {
}
logger.Debugf("Loaded %d hashed tokens (%d expired dropped)", len(hashedTokens)-expired, expired)
accessDb.SetSQLStore(sqlDb)
newAccessDb.SetSQLStore(sqlDb)
usersMux.Lock()
sharesMux.Lock()
indexMux.Lock()
sharesByHash = newSharesByHash
sharesByPath = newSharesByPath
indexInfoByPath = newIndexInfoByPath
accessDb = newAccessDb
usersMux.Unlock()
sharesMux.Unlock()
indexMux.Unlock()
if err = BackfillHashedTokensFromUserRecords(); err != nil {
return existingDb, fmt.Errorf("failed to backfill hashed tokens: %w", err)
@@ -184,11 +197,18 @@ func initialize(dbPath string) (bool, error) {
InitActivityRecorder(settings.Config.Server.DatabaseV2)
if err := InitQuotas(settings.Config.Server.DatabaseV2); err != nil {
return existingDb, fmt.Errorf("failed to initialize quotas: %w", err)
}
return existingDb, nil
}
// Close closes the underlying SQL database
func Close() error {
stateLifecycleMu.Lock()
defer stateLifecycleMu.Unlock()
usersMux.Lock()
sharesMux.Lock()
indexMux.Lock()
@@ -199,10 +219,15 @@ func Close() error {
users.SetUsernameToID(nil)
clearUserRecordCache()
StopActivityRecorder()
StopQuotaFlusher()
var err error
if sqlDb != nil {
return sqlDb.Close()
err = sqlDb.Close()
sqlDb = nil
}
return nil
defaultStore = nil
return err
}
func makePathKey(source, path string) string {
+15
View File
@@ -22,10 +22,25 @@ func Default() *Store {
// Open loads all persisted data and returns the store handle for dependency injection.
func Open(dbPath string) (*Store, bool, error) {
stateLifecycleMu.Lock()
defer stateLifecycleMu.Unlock()
existingDb, err := initialize(dbPath)
if err != nil {
return nil, existingDb, err
}
if sidebarLinkDefaultsNeedResync {
if err := ResyncSidebarLinkDefaultsForAllUsers(); err != nil {
return nil, existingDb, err
}
sidebarLinkDefaultsNeedResync = false
}
if toolAccessDefaultsNeedResync {
if err := ResyncToolAccessDefaultsForAllUsers(); err != nil {
return nil, existingDb, err
}
toolAccessDefaultsNeedResync = false
}
defaultStore = &Store{}
return defaultStore, existingDb, nil
}
@@ -0,0 +1,159 @@
package state
import (
"encoding/json"
"fmt"
"sync"
"github.com/gtsteffaniak/filebrowser/backend/internal/toolaccess"
)
const toolAccessDefaultsSettingKey = "toolAccessDefaults"
var (
toolAccessDefaultsMu sync.RWMutex
toolAccessDefaultsPatchMu sync.Mutex
toolAccessDefaults toolaccess.ToolAccessDefaultsDocument
toolAccessDefaultsNeedResync bool
toolAccessDefaultsResyncPending bool
// errInjectResyncToolAccessDefaults is set by tests in this package only.
errInjectResyncToolAccessDefaults error
)
// InitToolAccessDefaults loads persisted tool access defaults into memory.
func InitToolAccessDefaults() error {
if sqlDb == nil {
return fmt.Errorf("sqlDb not initialized")
}
doc, found, err := loadToolAccessDefaultsDocument()
if err != nil {
return err
}
if !found {
doc = toolaccess.InitialToolAccessDefaultsDocument()
if saveErr := saveToolAccessDefaultsDocument(doc); saveErr != nil {
return saveErr
}
} else {
ensured, changed := toolaccess.EnsureCatalogInDefaults(doc)
if changed {
if saveErr := saveToolAccessDefaultsDocument(ensured); saveErr != nil {
return saveErr
}
doc = ensured
toolAccessDefaultsNeedResync = true
}
}
toolAccessDefaultsMu.Lock()
toolAccessDefaults = doc
toolAccessDefaultsMu.Unlock()
if !found {
toolAccessDefaultsNeedResync = true
}
return nil
}
func loadToolAccessDefaultsDocument() (toolaccess.ToolAccessDefaultsDocument, bool, error) {
raw, err := sqlDb.GetSetting(toolAccessDefaultsSettingKey)
if err != nil {
if err.Error() == fmt.Sprintf("setting not found: %s", toolAccessDefaultsSettingKey) {
return toolaccess.ToolAccessDefaultsDocument{}, false, nil
}
return toolaccess.ToolAccessDefaultsDocument{}, false, err
}
var doc toolaccess.ToolAccessDefaultsDocument
if err := json.Unmarshal(raw, &doc); err != nil {
return toolaccess.ToolAccessDefaultsDocument{}, false, fmt.Errorf("parse %s: %w", toolAccessDefaultsSettingKey, err)
}
if doc.Items == nil {
doc.Items = []toolaccess.ToolAccessDefaultItem{}
}
return doc, true, nil
}
func saveToolAccessDefaultsDocument(doc toolaccess.ToolAccessDefaultsDocument) error {
if doc.Items == nil {
doc.Items = []toolaccess.ToolAccessDefaultItem{}
}
return sqlDb.SaveSetting(toolAccessDefaultsSettingKey, doc)
}
// EffectiveToolAccessDefaults returns the in-memory tool access defaults document.
func EffectiveToolAccessDefaults() toolaccess.ToolAccessDefaultsDocument {
toolAccessDefaultsMu.RLock()
defer toolAccessDefaultsMu.RUnlock()
doc := toolAccessDefaults
if doc.Items != nil {
doc.Items = append([]toolaccess.ToolAccessDefaultItem(nil), doc.Items...)
}
return doc
}
// GetToolAccessDefaults returns the admin API response for GET /api/settings/tool-access-defaults.
func GetToolAccessDefaults() toolaccess.ToolAccessDefaultsDocument {
doc := EffectiveToolAccessDefaults()
ensured, _ := toolaccess.EnsureCatalogInDefaults(doc)
return toolaccess.NormalizeDefaultsDocument(ensured)
}
// GetToolAccessDefaultsForUser returns tool access defaults visible to the user (same document for all users).
func GetToolAccessDefaultsForUser() toolaccess.ToolAccessDefaultsDocument {
return GetToolAccessDefaults()
}
// PatchToolAccessDefaults replaces the full tool access defaults document and resyncs users when needed.
func PatchToolAccessDefaults(doc toolaccess.ToolAccessDefaultsDocument) error {
toolAccessDefaultsPatchMu.Lock()
defer toolAccessDefaultsPatchMu.Unlock()
if doc.Items == nil {
doc.Items = []toolaccess.ToolAccessDefaultItem{}
}
doc = toolaccess.NormalizeDefaultsDocument(doc)
toolAccessDefaultsMu.Lock()
prev := toolAccessDefaults
docChanged := !toolAccessDefaultsEqual(prev, doc)
if docChanged {
if err := saveToolAccessDefaultsDocument(doc); err != nil {
toolAccessDefaultsMu.Unlock()
return fmt.Errorf("save tool access defaults: %w", err)
}
toolAccessDefaults = doc
}
syncDoc := toolAccessDefaults
needsResync := docChanged || toolAccessDefaultsResyncPending
toolAccessDefaultsMu.Unlock()
if !needsResync {
return nil
}
if err := resyncToolAccessForAllUsers(syncDoc); err != nil {
toolAccessDefaultsResyncPending = true
return err
}
toolAccessDefaultsResyncPending = false
return nil
}
func toolAccessDefaultsEqual(a, b toolaccess.ToolAccessDefaultsDocument) bool {
ma := toolAccessFlagsMap(a)
mb := toolAccessFlagsMap(b)
if len(ma) != len(mb) {
return false
}
for k, va := range ma {
if mb[k] != va {
return false
}
}
return true
}
func toolAccessFlagsMap(doc toolaccess.ToolAccessDefaultsDocument) map[string]string {
out := make(map[string]string)
for _, item := range doc.Items {
out[string(item.ToolID)] = fmt.Sprintf("%t:%t", item.Enabled, item.Enforced)
}
return out
}
@@ -0,0 +1,55 @@
package state
import (
"fmt"
"github.com/gtsteffaniak/filebrowser/backend/internal/toolaccess"
"github.com/gtsteffaniak/go-logger/logger"
)
func resyncToolAccessForAllUsers(doc toolaccess.ToolAccessDefaultsDocument) error {
if errInjectResyncToolAccessDefaults != nil {
return errInjectResyncToolAccessDefaults
}
usersMux.Lock()
defer usersMux.Unlock()
usersList, err := sqlDb.ListUsers()
if err != nil {
return fmt.Errorf("list users for tool access sync: %w", err)
}
var updated int
for _, row := range usersList {
if row == nil {
continue
}
u := cloneUserPtr(row)
changed := toolaccess.MergeDefaultToolAccess(u, doc)
if !u.Permissions.Admin {
if toolaccess.MergeEnforcedToolAccess(u, doc) {
changed = true
}
}
if !changed {
continue
}
u.FrontendScopes = nil
u.SourcePermissions = nil
if err := sqlDb.UpdateUser(u); err != nil {
return fmt.Errorf("sync tool access for user %s: %w", u.Username, err)
}
putUserInCache(u)
updated++
}
if updated > 0 {
logger.Debugf("synced tool access for %d users", updated)
}
return nil
}
// ResyncToolAccessDefaultsForAllUsers applies current defaults and enforced policy to all users.
func ResyncToolAccessDefaultsForAllUsers() error {
return resyncToolAccessForAllUsers(EffectiveToolAccessDefaults())
}
Loaded 100 of 431 files, more files were not shown because too many files have changed in this diff. Show more