mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-15 13:35:55 -05:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
+11 |
45a14bf2f2 |
No files matched your search
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
FILEBROWSER_DEVMODE=true
|
||||
+10
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -53,6 +53,7 @@ userDefaults:
|
||||
lockPassword: false
|
||||
disableSettings: false
|
||||
disableUpdateNotifications: false
|
||||
showAdvancedProfile: false
|
||||
permissions:
|
||||
api: false
|
||||
admin: false
|
||||
|
||||
+8
-9
@@ -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
@@ -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=
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../frontend/dist
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, "")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"] = "aCounterMem{UsedBytes: 100, Dirty: true}
|
||||
|
||||
flusher := "aCounterFlusher{
|
||||
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"] = "aCounterMem{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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user