Compare commits

..
Author SHA1 Message Date
Graham Steffaniak 3956ccd859 make sidebar tree view links native hyperlinks (#3080) 2026-10-04 16:19:09 -05:00
Graham Steffaniak 3f756de845 port fix over to dev/v2.0.10 2026-10-04 15:58:39 -05:00
Graham Steffaniak aa98563db7 Merge branch 'dev/v2.0.10' of github.com:gtsteffaniak/filebrowser into dev/v2.0.10 2026-10-04 15:57:57 -05:00
Graham Steffaniak fb7f25b0a8 Fix bugs halloween prompts (#3076) 2026-10-04 15:38:16 -05:00
Graham Steffaniak 7f56deb03b Merge branch 'dev/v2.0.10' of github.com:gtsteffaniak/filebrowser into dev/v2.0.10 2026-10-04 12:27:11 -05:00
Graham Steffaniak 592d8db506 Update version match workflow (#3072) 2026-10-04 12:26:59 -05:00
Graham Steffaniak 0b346bd8c7 Merge branch 'dev/v2.0.10' of github.com:gtsteffaniak/filebrowser into dev/v2.0.10 2026-10-04 12:04:49 -05:00
Graham Steffaniak 8f4e148cf3 updated cli setup command (#3071) 2026-10-04 12:04:37 -05:00
Graham Steffaniak 82ab5957dd updated cli setup command 2026-10-04 11:50:18 -05:00
Graham Steffaniak 033e1a4642 upgrade docker 2026-10-04 11:33:30 -05:00
Graham SteffaniakandCursor 78cb4ca214 Fix GHSA-6gr6-5qpq-888p: API token session renew (#3070)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 11:29:14 -05:00
Graham SteffaniakandCursor b40be3e25e Fix TOTP re-enrollment MFA bypass (GHSA-qx86-4v5r-26g5). (#3067)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 10:49:36 -05:00
Graham Steffaniak e4fc79287d added feature to reset user password on login (#3064) 2026-10-03 20:00:01 -05:00
Graham SteffaniakandCursor 9ae8cc07e6 fix: GHSA-p7x3-p5jj-9xfh - Enforce public share limits on lyrics and subtitle routes. (#3061)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 18:20:01 -05:00
Graham Steffaniak b2936e5c80 Fix token expiration (#3056) 2026-10-03 16:54:11 -05:00
Kevin B. f501c765a6 fix sidebar behind header on iOS (#3060) 2026-10-03 16:18:01 -05:00
Graham SteffaniakandDevin b861660ce9 Fix inflated disk usage on ZFS datasets and btrfs subvolumes; add roo… (#3035)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-02 16:16:31 -05:00
Graham Steffaniak 98c96c3ec4 Case insensitive match ldap (#3048) 2026-10-01 22:05:58 -05:00
Graham Steffaniak b7e06fd333 added support for env vars in config (#3047) 2026-09-30 17:53:01 -05:00
598 changed files with 14359 additions and 49825 deletions

No files matched your search

-15
View File
@@ -1,15 +0,0 @@
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
-1
View File
@@ -46,7 +46,6 @@ frontend/.tmp-icons-*
/backend/internal/web/dist
/backend/internal/web/embed
/backend/debug
/backend/http
.DS_Store
node_modules
+9 -127
View File
@@ -2,122 +2,6 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v2.1.2
**Security**:
- [High] New accounts named with ".." breaks out of scoped folder on creation (GHSA-c84q-q5g6-r47f) -- reporter @dylanzonix
- [Medium] Fixed session token collision issue when multiple users login simultaneously. (GHSA-c9cr-w724-gqj3) (#3135) Thanks @dl-pt reporters: @Ishtiaque-Ahmed-Risat @AlexWhiplash @tonghuaroot
**Notes**:
- Removed "Editor quick save" setting (`userDefaults.fileViewer.quickSave`), the save button is now always present in the editor toolbar. (#3122)
- add Basque (eu) translation and fix Spanish (es) strings (#3118)
**Bug Fixes**:
- fix(webdav): OPTIONS now advertises only methods actually supported and permitted for the user; the DAV header reports class 1 only since LOCK/UNLOCK are not enabled (#3110)
- fix(download): revalidate single-file downloads so a changed file is not served stale (#3133)
- Language switcher not working (#3114) (#3117)
- Viewer showing a stale image after the file changed on disk: single-file downloads are now revalidated by the browser (#3132)
## v2.1.1
Note: this release also contains all changes from v2.0.10-beta changelog
**New Features**:
- User and group picker with partial, case-insensitive search when creating access rules (#3085), assigning groups on user edit, and choosing members on group edit.
- New `PUT /api/access/user-groups` endpoint that atomically replaces a user's group memberships.
- feat(search): configure the advanced search result limit (#3058) @beemines
- feat(listing): add sortable Date added and Type columns (#3052) @dweebzxx
- feat(archive): suggest and preview ZIP filename encodings (#3024) @AnaTofuZ
**Notes**:
- Group API breaking changes: `POST /api/access` returns 400 when `ruleCategory=group` names a nonexistent group or `ruleCategory=user` names a nonexistent user, group endpoints return 400/409 instead of 500 for client errors, and `PUT /api/access/group` accepts a `create` flag and returns `unknownMembers`. Existing database records are unaffected; no migration is required.
- update halloween theme (#3055)
- Add soft wrap option to the editor config (#3063)
- add better screenshot view handling (#3059)
- Update nl.json (#3089) @Stephan-P
- update german translations (#3062) @fehnomenal
- Update dev dependencies, add biome, fix types and linting (#3075) @Kurami32
**Bug Fixes**:
- Groups assigned in the user edit prompt now refresh the Groups settings list.
- Creating a group with an existing name now returns a conflict instead of silently overwriting its members.
- Access rules can no longer create empty groups from typos; group rules must reference an existing group.
- Empty group names are rejected and new group names must be 2-128 characters without control characters (existing records are unaffected); deleting or renaming a user now updates group memberships.
- fix(auth): use login endpoint for existing TOTP authentication (#3074)
- fix: apply theme toggles on anonymous public shares (#3049) @mvanhorn
- fix(frontend): keep source named files in extractSourceFromPath (#3099) (#3095)
- fix(auth): identify login fields for password autofill (#3077)
- fix(backend): skip denied paths in multi-file downloads, 404 when none remain (#3100) @hippi345
- fix: persist the user scope path chosen in the edit dialog (#3065) @mvanhorn
## 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.10
**Security**:
@@ -167,7 +51,6 @@ Note: this release also contains all changes from v2.0.10-beta changelog
- 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
@@ -192,7 +75,7 @@ Note: this release also contains all changes from v2.0.10-beta changelog
- 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**:
@@ -272,7 +155,6 @@ Note: this release also contains all changes from v2.0.10-beta changelog
- 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
@@ -346,7 +228,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.
@@ -526,14 +408,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)
@@ -679,7 +561,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)
@@ -713,7 +595,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`
@@ -876,7 +758,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)
@@ -979,7 +861,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
@@ -1014,7 +896,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
+10 -10
View File
@@ -18,7 +18,7 @@
:pushpin: [Read The Official Docs](https://filebrowserquantum.com/) (currently english-only)
:pushpin: [v2.0.0 is now stable!](https://filebrowserquantum.com/en/docs/getting-started/v2/about/) --> See [migration support discussion](https://github.com/gtsteffaniak/filebrowser/discussions/3105)
:pushpin: [v2.0.0 is now in beta!](https://filebrowserquantum.com/en/docs/getting-started/v2/about/)
## About
@@ -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 | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
Metrics | :construction: | ❌ | ✅ | ❌ | ❌ | ❌ |
file space quotas | :construction: | ❌ | ✅ | ❌ | ✅ | ✅ |
text-based files editor | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Office file support | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Office file previews | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ |
Themes | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
Themes | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
Branding support | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
activity log | ✅ | ❌ | ✅ | ✅ | ✅ | ✅
Comments support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
trash support | :construction: | ❌ | ✅ | ✅ | ✅ | ✅ |
Starred/pinned files | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ |
Starred/pinned files | ✅ | ❌ | ✅ | ❌ | ✅ | ✅ |
Chromecast support | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ |
Share collections of files | :construction: | ❌ | ❌ | ❌ | ❌ | ✅ |
Can archive selected files | ✅ | ❌ | ❌ | ❌ | ❌ | ✅ |
+1 -1
View File
@@ -1,4 +1,4 @@
FROM gtstef/ffmpeg:9.0.2-decode AS ffmpeg
FROM ghcr.io/gtsteffaniak/ffmpeg:9.0.2-decode AS ffmpeg
FROM golang:alpine AS base
ARG VERSION
ARG REVISION
+1 -1
View File
@@ -4,4 +4,4 @@ WORKDIR /app/frontend
COPY frontend/package-lock.json /tmp/package-lock.json
RUN PW_VERSION=$(node -p "require('/tmp/package-lock.json').packages['node_modules/@playwright/test'].version") \
&& npm i "@playwright/test@${PW_VERSION}" oauth2-mock-server @types/node
RUN npx playwright install --with-deps chromium-headless-shell
RUN npx playwright install --with-deps chromium
@@ -4,8 +4,10 @@ http:
<<: *http-main
frontend:
<<: *frontend-main
auth:
<<: *auth-main
userDefaults:
preview:
image: true
+3 -7
View File
@@ -1,6 +1,9 @@
http:
port: 8080
baseURL: "/files/"
frontend:
styling:
disableEventThemes: true
auth:
adminPassword: "playwright-password"
@@ -13,10 +16,3 @@ server:
name: "playwright"
config:
defaultEnabled: true
userDefaults:
listing:
viewMode: "normal"
# Size 4: switch-view "gallery" family resolves to icons (not gallery); list family → list.
gallerySize: 3
account:
showAdvancedProfile: true
@@ -12,7 +12,7 @@ import type { PlaywrightFixtureOptions } from "./tests/playwright/test-setup";
*/
export default defineConfig<PlaywrightFixtureOptions>({
globalSetup: "./tests/playwright/screenshots-setup.ts",
timeout: 60_000,
timeout: 5000,
testDir: "./tests/playwright/screenshots",
/* Run tests in files in parallel */
fullyParallel: false,
-1
View File
@@ -1 +0,0 @@
FILEBROWSER_DEVMODE=true
+4 -10
View File
@@ -1,22 +1,16 @@
root = "."
tmp_dir = "tmp"
env_files = [".air.env"]
[build]
cmd = "bash ./scripts/air-build.sh"
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'\" ."
entrypoint = "./tmp/filebrowser"
args_bin = ["-c", "test_config.yaml"]
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser -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", "internal/preview/gofitzinclude"]
poll = true
poll_interval = 1000
exclude_dir = ["tmp", "vendor", "internal/web/dist", "internal/web/embed", "internal/preview/testdata", "internal/preview/thumbnails", "internal/preview/heic", "swagger/docs"]
log = "air.log"
delay = 1000 # ms
[build.windows]
cmd = "bash ./scripts/air-build.sh"
entrypoint = "./tmp/filebrowser.exe"
[log]
time = true
+22
View File
@@ -0,0 +1,22 @@
root = "."
tmp_dir = "tmp"
[build]
pre_cmd = ["go run ./scripts/setup-gofitz-cgo"]
cmd = "go build -o ./tmp/filebrowser.exe --tags=mupdf --ldflags=\"-w -s -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.CommitSHA=commitSHA' -X 'github.com/gtsteffaniak/filebrowser/backend/internal/version.Version=version'\" ."
entrypoint = "./tmp/filebrowser.exe"
full_bin = "FILEBROWSER_DEVMODE=true CGO_ENABLED=1 ./tmp/filebrowser.exe -c test_config.yaml"
include_ext = ["go", "tpl", "tmpl", "html"]
exclude_dir = ["tmp", "vendor", "internal\\web\\dist", "internal\\web\\embed", "internal\\preview\\testdata", "internal\\preview\\thumbnails", "internal\\preview\\heic", "swagger\\docs"]
exclude_unchanged = true
follow_symlink = false
poll = true
poll_interval = 1000
log = "air.log"
delay = 1000 # ms
[log]
time = true
[misc]
clean_on_exit = false
+5 -5
View File
@@ -28,7 +28,7 @@ require (
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/air-verse/air v1.67.4 // indirect
github.com/alecthomas/chroma/v2 v2.27.0 // indirect
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // indirect
github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.1.0 // indirect
github.com/alfatraining/structtag v1.0.0 // indirect
@@ -54,7 +54,7 @@ github.com/alecthomas/go-check-sumtype v0.5.1-0.20260828200218-ae6904d28606 // i
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 @@ github.com/charmbracelet/ultraviolet v0.0.0-20260922123528-4e49372c11f9 // indir
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 @@ github.com/golangci/golangci-lint/v2 v2.14.0 // indirect
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 @@ github.com/mgechev/revive v1.17.0 // indirect
github.com/swaggo/swag v1.16.6 // indirect
github.com/tdewolff/parse/v2 v2.8.16 // indirect
github.com/tetafro/godot v1.5.6 // indirect
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 // indirect
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 // indirect
github.com/timonwong/loggercheck v0.12.0 // indirect
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
-10
View File
@@ -56,8 +56,6 @@ 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=
@@ -140,8 +138,6 @@ 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=
@@ -294,8 +290,6 @@ 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=
@@ -420,8 +414,6 @@ 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=
@@ -575,8 +567,6 @@ github.com/tetafro/godot v1.5.6 h1:IEkrFCwXaYHlOn4mGzGS3F3dkP6m9t0jpwqBFPIkKiA=
github.com/tetafro/godot v1.5.6/go.mod h1:eOkMrVQurDui411nBY2FA05EYH01r14LuWY/NrVDVcU=
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c h1:d/FUP8GD4QiY/rdFzZmBu3KqL9eBNVzMu7W/5pGkaMM=
github.com/timakin/bodyclose v0.0.0-20260723120731-857993a2939c/go.mod h1:sDHLK7rb/59v/ZxZ7KtymgcoxuUMxjXq8gtu9VMOK8M=
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2 h1:HECVafB2AKntWWRfsiQ4gdunLx5WOYQzFdCsxqOrzp8=
github.com/timakin/bodyclose v0.0.0-20260923065923-98ded9ab4ef2/go.mod h1:JLoPVtROtNgJ99yXTIxuGNttvmJa85tkJAO6lr05qBA=
github.com/timonwong/loggercheck v0.12.0 h1:7Cb9XGLJZSWYNjgL459DIhpSiEHupD+MLQ6vaN2Pl3g=
+2 -2
View File
@@ -248,12 +248,12 @@ func createConfig(configpath string, noInput bool) error {
}
adminUsername := askQuestion(reader, "What should the default admin username be?", "admin")
if adminUsername != "admin" {
config.Auth.Methods.PasswordAuth.AdminUsername = adminUsername
config.Auth.AdminUsername = adminUsername
}
const adminPasswordGenerateDefault = "generate on startup"
adminPassword := askQuestion(reader, "What should the default admin password be?", adminPasswordGenerateDefault)
if adminPassword != "" && !strings.EqualFold(adminPassword, adminPasswordGenerateDefault) {
config.Auth.Methods.PasswordAuth.AdminPassword = adminPassword
config.Auth.AdminPassword = adminPassword
}
modifyDefault := askYesNoQuestion(reader, "Should a new user be able to modify content by default?", "no")
-12
View File
@@ -1,12 +0,0 @@
package cmd
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
+2 -2
View File
@@ -200,9 +200,9 @@ func rootCMD(ctx context.Context, serverConfig *settings.Server, a *app.App, shu
if err != nil {
logger.Fatalf("Failed to create sub filesystem: %v", err)
}
fileutils.InitAssetFS(subAssets, true, false)
fileutils.InitAssetFS(subAssets, true)
} else {
fileutils.InitAssetFS(nil, false, settings.Env.IsDevMode)
fileutils.InitAssetFS(nil, false)
}
// Start preview service
+6 -3
View File
@@ -74,7 +74,10 @@ func validateUserInfo(newDB bool) {
if bumpToNewestVersion(user, tokenBackfillFailed) {
changedFields = append(changedFields, "version")
}
adminUser := settings.PasswordAdminUsername()
adminUser := settings.Config.Auth.AdminUsername
if adminUser == "" {
adminUser = "admin"
}
if user.Username == adminUser && user.Permissions.Admin {
adminPerms := settings.AdminPerms()
if user.Permissions.Share != adminPerms.Share || user.Permissions.Api != adminPerms.Api {
@@ -87,7 +90,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.PasswordAdminPassword()
user.Password = settings.Config.Auth.AdminPassword
user.LoginMethod = users.LoginMethodPassword
changedFields = append(changedFields, "permissions", "password", "loginMethod")
changePass = true
@@ -135,7 +138,7 @@ func dedupeFields(fields []string) []string {
}
func updateUserScopes(user *users.User) bool {
newScopes := settings.MergeDefaultEnabledBackendScopes(user.BackendScopes, user.DeclinedDefaultSources)
newScopes := settings.MergeDefaultEnabledBackendScopes(user.BackendScopes)
changed := !reflect.DeepEqual(user.BackendScopes, newScopes)
user.BackendScopes = newScopes
return changed
@@ -1,6 +1,9 @@
http:
port: 8080
baseURL: "/"
frontend:
styling:
disableEventThemes: true
server:
logging:
- levels: "info|warning|error"
+1 -1
View File
@@ -18,6 +18,7 @@ server:
userDefaults:
fileViewer:
editorQuickSave: false
disableOnlyOfficeExt: ".md .txt .pdf"
disableViewingExt: ""
sidebar:
@@ -52,7 +53,6 @@ userDefaults:
lockPassword: false
disableSettings: false
disableUpdateNotifications: false
showAdvancedProfile: false
permissions:
api: false
admin: false
+10 -9
View File
@@ -19,7 +19,7 @@ require (
github.com/google/go-cmp v0.7.0
github.com/google/uuid v1.6.0
github.com/gtsteffaniak/go-cache v1.1.0
github.com/gtsteffaniak/go-ffmpeg v1.2.0
github.com/gtsteffaniak/go-ffmpeg v0.5.2
github.com/gtsteffaniak/go-logger v1.1.0
github.com/kovidgoyal/imaging v1.8.23
github.com/mattn/go-sqlite3 v1.14.52
@@ -35,11 +35,10 @@ require (
golang.org/x/oauth2 v0.37.0
golang.org/x/sys v0.48.0
golang.org/x/term v0.46.0
golang.org/x/text v0.42.0
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.60.1
modernc.org/sqlite v1.59.0
)
require (
@@ -55,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.2 // indirect
github.com/go-openapi/jsonreference v1.0.3 // indirect
github.com/go-openapi/jsonpointer v1.0.1 // indirect
github.com/go-openapi/jsonreference v1.0.2 // 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
@@ -65,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.2 // indirect
github.com/go-playground/locales v0.14.1 // 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-20260928092222-7d12f68cfadb // indirect
github.com/golang/geo v0.0.0-20260818125358-b200a1149890 // 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
@@ -79,18 +78,19 @@ 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.1.0 // indirect
github.com/ncruces/go-strftime v1.0.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.5 // indirect
github.com/tinylib/msgp v1.6.4 // 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
golang.org/x/sync v0.23.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
@@ -103,4 +103,5 @@ 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
)
+20 -18
View File
@@ -69,10 +69,10 @@ github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU
github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
github.com/go-openapi/jsonpointer v1.0.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/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/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.8.0 h1:19QDx5b57p8KjO2E4sgs/woL4Akp+FGLRlVm2xn0Rho=
github.com/go-openapi/testify/v2 v2.8.0/go.mod h1:4gUN8jC+RqOE2qjW/vDmfyTvl5BCht4qL7oca0odrak=
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-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.2 h1:d8UmcrM6Nip0hfGZKLGpAvZH37XB4TS0xzK9B56YNCY=
github.com/go-playground/locales v0.14.2/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.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-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/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/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,8 +143,10 @@ 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 v1.2.0 h1:WSFfRL5xVUpEXBNI4D3f2Uz8yxbc3OEqTtjp4Z9yPUo=
github.com/gtsteffaniak/go-ffmpeg v1.2.0/go.mod h1:FefhoD+KZzHWqBaUpntcKp6lXtUzdFW00F74axnENUQ=
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-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=
@@ -187,8 +189,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.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
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/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=
@@ -217,8 +219,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.5 h1:iAH6XTP7BpzErjBZlujJQXxA+GmRi2YWs6M4KdLtNbE=
github.com/tinylib/msgp v1.6.5/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/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=
@@ -333,8 +335,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.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
modernc.org/sqlite v1.59.0 h1:X1es1GpqBlS/5T+vbM4HLUdaa8OtQx468DF2vrx+38A=
modernc.org/sqlite v1.59.0/go.mod h1:+paeT2A3iPRHkQDwG7oA6Tk0zQd5woMEI8q7orfry8k=
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=
-74
View File
@@ -7,7 +7,6 @@ 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"
)
@@ -288,79 +287,6 @@ 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 {
+96 -80
View File
@@ -1,11 +1,13 @@
package files
import (
"bufio"
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
@@ -23,6 +25,8 @@ 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())
@@ -46,7 +50,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, sharedFFmpegService); err != nil {
if err := extractAudioMetadata(context.Background(), fileItem, fullPath, opts.AlbumArt, opts.Metadata, false, sharedFFmpegService); err != nil {
logger.Debugf("failed to extract metadata for file: %s, error: %v", fileItem.Name, err)
}
} else {
@@ -389,7 +393,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, nil)
err := extractAudioMetadata(context.Background(), extItem, info.RealPath, opts.AlbumArt || opts.Content, opts.Metadata || opts.Content, false, nil)
if err != nil {
logger.Debugf("failed to extract audio metadata for file: "+info.RealPath, info.Name, err)
} else {
@@ -427,10 +431,57 @@ 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, ffmpegService *ffmpeg.FFmpegService) error {
func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo, realPath string, getArt bool, getDuration bool, getLyrics bool, ffmpegService *ffmpeg.FFmpegService) error {
file, err := os.Open(realPath)
if err != nil {
return err
@@ -467,7 +518,28 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
item.Metadata.Track = track
// Check if lyrics are available without extract them
item.Metadata.HasLyrics = hasLyricsSidecar(realPath) || m.Lyrics() != ""
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
}
}
// Extract duration ONLY if explicitly requested using the ffmpeg VideoService
// This respects concurrency limits and gracefully handles missing ffmpeg
@@ -500,91 +572,35 @@ func extractAudioMetadata(ctx context.Context, item *iteminfo.ExtendedItemInfo,
return nil
}
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
}
// 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) {
file, err := os.Open(realPath)
if err != nil {
return "", "", err
return nil, err
}
defer file.Close()
m, err := tag.ReadFrom(file)
if err != nil {
return "", "", err
return nil, err
}
var lyrics []iteminfo.Lyric
if raw := m.Lyrics(); raw != "" {
return raw, "embedded", nil
lyrics = parseLRC(raw)
}
return "", "", 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
}
// extractVideoMetadata extracts duration and codec info from video files using ffprobe.
@@ -447,7 +447,6 @@ 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,13 +21,11 @@ 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, devMode bool) {
func InitAssetFS(embeddedAssets fs.FS, useEmbedded 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")}
}
}
+27 -55
View File
@@ -56,62 +56,48 @@ 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 are on different volumes,
// CopyFile is used as a fallback and the source is removed synchronously on success.
// By default, the rename system call is used. If src and dst point to different volumes,
// the file copy is used as a fallback.
func MoveFile(src, dst string) error {
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)
err := os.Rename(src, dst)
if err == nil {
return nil
}
return nil
}
func preserveFileMode(path string, mode os.FileMode) error {
return os.Chmod(path, mode.Perm())
// 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)
}
}()
return nil
}
// CopyFile copies a file or directory from source to dest and returns an error if any.
func CopyFile(source, dest string) error {
info, err := os.Lstat(source)
// Check if the source exists and whether it's a file or directory.
info, err := os.Stat(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
@@ -175,7 +161,7 @@ func copyFileContents(dst io.Writer, src io.Reader) error {
// copyDirectory handles copying directories recursively.
func copyDirectory(source, dest string) error {
srcInfo, err := os.Lstat(source)
srcInfo, err := os.Stat(source)
if err != nil {
return err
}
@@ -184,9 +170,6 @@ 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)
@@ -199,17 +182,6 @@ 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,223 +139,6 @@ 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)}
@@ -1,17 +0,0 @@
//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)
}
@@ -1,18 +0,0 @@
//go:build windows
package fileutils
import (
"errors"
"os"
"syscall"
)
func isCrossDeviceRenameError(err error) bool {
const errNotSameDevice = syscall.Errno(17)
if errors.Is(err, errNotSameDevice) {
return true
}
var linkErr *os.LinkError
return errors.As(err, &linkErr) && errors.Is(linkErr.Err, errNotSameDevice)
}
-12
View File
@@ -1,12 +0,0 @@
package analytics
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
-213
View File
@@ -1,213 +0,0 @@
// Package archiveencoding decodes ZIP entry names before path normalization.
package archiveencoding
import (
"archive/zip"
"encoding/binary"
"errors"
"fmt"
"hash/crc32"
"strings"
"unicode"
"unicode/utf8"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/charmap"
"golang.org/x/text/encoding/japanese"
"golang.org/x/text/encoding/korean"
"golang.org/x/text/encoding/simplifiedchinese"
"golang.org/x/text/encoding/traditionalchinese"
)
// ErrEncoding indicates a filename encoding that needs a different user selection.
var ErrEncoding = errors.New("invalid ZIP filename encoding")
type codec struct {
name string
encoding encoding.Encoding
}
var codecs = []codec{
{"utf-8", nil},
{"cp932", japanese.ShiftJIS},
{"gb18030", simplifiedchinese.GB18030},
{"big5", traditionalchinese.Big5},
{"euc-kr", korean.EUCKR},
{"cp437", charmap.CodePage437},
{"windows-1252", charmap.Windows1252},
}
// Candidate contains a bounded preview, decoded exactly as during extraction.
type Candidate struct {
Encoding string `json:"encoding"`
Names []string `json:"names"`
}
// Preview describes compatible encodings. An empty suggestion requires a choice;
// a heuristic suggestion is never a guarantee that the names are correct.
type Preview struct {
Suggested string `json:"suggested"`
Candidates []Candidate `json:"candidates"`
}
// unicodeName prefers the UTF-8 flag, then a verified Info-ZIP Unicode Path field.
// A Unicode Path field is usable only when its version and original-name CRC match.
func unicodeName(f *zip.File) (string, bool) {
if f.Flags&0x800 != 0 {
return f.Name, true
}
extra := f.Extra
for len(extra) >= 4 {
tag := binary.LittleEndian.Uint16(extra)
size := int(binary.LittleEndian.Uint16(extra[2:]))
extra = extra[4:]
if size > len(extra) {
break
}
field := extra[:size]
extra = extra[size:]
if tag == 0x7075 && len(field) > 5 && field[0] == 1 &&
binary.LittleEndian.Uint32(field[1:5]) == crc32.ChecksumIEEE([]byte(f.Name)) && utf8.Valid(field[5:]) {
return string(field[5:]), true
}
}
return "", false
}
func decode(f *zip.File, c codec) (string, error) {
name, declared := unicodeName(f)
if !declared {
name = f.Name
if c.encoding != nil {
var err error
name, err = c.encoding.NewDecoder().String(name)
if err != nil || strings.ContainsRune(name, utf8.RuneError) {
return "", fmt.Errorf("%w: cannot decode filename as %s", ErrEncoding, c.name)
}
}
}
if !utf8.ValidString(name) || strings.ContainsRune(name, 0) {
return "", fmt.Errorf("%w: invalid filename for %s", ErrEncoding, c.name)
}
return name, nil
}
// Inspect considers all entry names without decompressing file contents. Candidate
// previews prefer names lacking Unicode metadata so that users can compare them.
func Inspect(files []*zip.File) Preview {
result := Preview{Candidates: []Candidate{}}
bestScore, tied := 0, false
for _, c := range codecs {
candidate := Candidate{Encoding: c.name, Names: []string{}}
fallback := []string{}
score, valid := 0, true
for _, f := range files {
name, err := decode(f, c)
if err != nil {
valid = false
break
}
if len(fallback) < 5 {
fallback = append(fallback, name)
}
if _, declared := unicodeName(f); !declared && !isASCII(f.Name) {
if len(candidate.Names) < 5 {
candidate.Names = append(candidate.Names, name)
}
// The EUC-KR decoder also accepts CP949 extension bytes, including
// CP932 kana pairs. Do not use those bytes as Korean evidence.
if c.name != "euc-kr" || isEUCKR(f.Name) {
score += scriptEvidence(name, c.name)
}
}
}
if !valid {
continue
}
if len(candidate.Names) == 0 {
candidate.Names = fallback
}
result.Candidates = append(result.Candidates, candidate)
// Valid UTF-8 (including metadata-backed names) needs no legacy guess.
if c.name == "utf-8" {
result.Suggested = c.name
}
if score > bestScore {
bestScore, tied = score, false
if result.Suggested != "utf-8" {
result.Suggested = c.name
}
} else if score == bestScore && score > 0 {
tied = true
}
}
if result.Suggested != "utf-8" && (bestScore < 3 || tied) {
result.Suggested = ""
}
return result
}
func isASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] >= utf8.RuneSelf {
return false
}
}
return true
}
// isEUCKR excludes CP949 extension sequences from automatic Korean suggestions.
func isEUCKR(raw string) bool {
for i := 0; i < len(raw); i++ {
if raw[i] < utf8.RuneSelf {
continue
}
if raw[i] < 0xa1 || raw[i] > 0xfe || i+1 >= len(raw) || raw[i+1] < 0xa1 || raw[i+1] > 0xfe {
return false
}
i++
}
return true
}
// Kana and Hangul give useful script evidence; Han alone does not distinguish
// Japanese, simplified Chinese and traditional Chinese. Half-width kana are not
// evidence: unrelated legacy bytes frequently decode to half-width kana.
func scriptEvidence(name, charset string) int {
score := 0
for _, r := range name {
if charset == "cp932" && r < 0xff00 && (unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r)) {
score++
}
if charset == "euc-kr" && unicode.Is(unicode.Hangul, r) {
score++
}
}
return score
}
// DecodeNames resolves the selection once for the entire archive. Explicit
// Unicode metadata always wins over the selected fallback encoding.
func DecodeNames(files []*zip.File, charset string) ([]string, error) {
if charset == "" {
charset = Inspect(files).Suggested
if charset == "" {
return nil, fmt.Errorf("%w: choose an encoding before extracting", ErrEncoding)
}
}
for _, c := range codecs {
if c.name != charset {
continue
}
names := make([]string, 0, len(files))
for _, f := range files {
name, err := decode(f, c)
if err != nil {
return nil, err
}
names = append(names, name)
}
return names, nil
}
return nil, fmt.Errorf("%w: unsupported encoding %s", ErrEncoding, charset)
}
@@ -1,119 +0,0 @@
package archiveencoding
import (
"archive/zip"
"encoding/binary"
"golang.org/x/text/encoding/japanese"
"hash/crc32"
"reflect"
"testing"
)
func entry(name string) *zip.File { return &zip.File{FileHeader: zip.FileHeader{Name: name}} }
func cp932(t *testing.T, name string) string {
t.Helper()
s, err := japanese.ShiftJIS.NewEncoder().String(name)
if err != nil {
t.Fatal(err)
}
return s
}
func TestJapaneseSuggestionMatchesDecoding(t *testing.T) {
t.Parallel()
want := []string{"日本語/テストソ001.txt", "日本語/テストソ002.txt"}
files := []*zip.File{entry(cp932(t, want[0])), entry(cp932(t, want[1]))}
preview := Inspect(files)
if preview.Suggested != "cp932" {
t.Fatalf("suggestion: %+v", preview)
}
names, err := DecodeNames(files, preview.Suggested)
if err != nil || !reflect.DeepEqual(names, want) {
t.Fatalf("names=%q err=%v", names, err)
}
for _, c := range preview.Candidates {
if c.Encoding == preview.Suggested && !reflect.DeepEqual(c.Names, names) {
t.Fatalf("preview mismatch: %q", c.Names)
}
}
}
func TestAmbiguousNamesRequireSelection(t *testing.T) {
t.Parallel()
files := []*zip.File{entry("caf\x82.txt")}
if p := Inspect(files); p.Suggested != "" {
t.Fatalf("ambiguous name was guessed: %+v", p)
}
if _, err := DecodeNames(files, ""); err == nil {
t.Fatal("expected explicit choice")
}
names, err := DecodeNames(files, "cp437")
if err != nil || names[0] != "café.txt" {
t.Fatalf("names=%q err=%v", names, err)
}
for _, c := range []string{"utf-8", "unknown"} {
if _, err = DecodeNames(files, c); err == nil {
t.Fatalf("invalid encoding accepted: %s", c)
}
}
}
func unicodeExtra(raw, name string) []byte {
payload := append([]byte{1, 0, 0, 0, 0}, []byte(name)...)
binary.LittleEndian.PutUint32(payload[1:], crc32.ChecksumIEEE([]byte(raw)))
extra := []byte{0x75, 0x70, 0, 0}
binary.LittleEndian.PutUint16(extra[2:], uint16(len(payload)))
return append(extra, payload...)
}
func TestUnicodeMetadataTakesPrecedence(t *testing.T) {
t.Parallel()
flagged := entry("日本語.txt")
flagged.Flags = 0x800
extra := entry("legacy.txt")
extra.Extra = unicodeExtra(extra.Name, "別の名前.txt")
names, err := DecodeNames([]*zip.File{flagged, extra}, "cp437")
if err != nil || !reflect.DeepEqual(names, []string{"日本語.txt", "別の名前.txt"}) {
t.Fatalf("names=%q err=%v", names, err)
}
extra.Extra[5] ^= 1
names, err = DecodeNames([]*zip.File{extra}, "cp437")
if err != nil || names[0] != "legacy.txt" {
t.Fatalf("names=%q err=%v", names, err)
}
}
func TestInvalidUnicodeMetadata(t *testing.T) {
t.Parallel()
for _, raw := range []string{"bad\xff.txt", "bad\x00.txt"} {
f := entry(raw)
f.Flags = 0x800
if _, err := DecodeNames([]*zip.File{f}, "cp932"); err == nil {
t.Fatalf("invalid declared UTF-8 accepted: %q", raw)
}
}
f := entry("ok.txt")
f.Extra = []byte{0x75, 0x70, 0xff, 0xff}
if _, err := DecodeNames([]*zip.File{f}, "utf-8"); err != nil {
t.Fatal(err)
}
}
func TestUTF8AndASCIIArchives(t *testing.T) {
t.Parallel()
for _, files := range [][]*zip.File{nil, {entry("readme.txt")}, {entry("日本語.txt")}} {
if p := Inspect(files); p.Suggested != "utf-8" {
t.Fatalf("expected UTF-8: %+v", p)
}
}
}
func TestPreviewBoundAndAllNamesValidated(t *testing.T) {
t.Parallel()
files := make([]*zip.File, 20)
for i := range files {
files[i] = entry("readme.txt")
}
files = append(files, entry("invalid\xff.txt"))
for _, c := range Inspect(files).Candidates {
if len(c.Names) > 5 {
t.Fatal("unbounded preview")
}
if c.Encoding == "utf-8" {
t.Fatal("entry beyond preview was not validated")
}
}
}
+1 -7
View File
@@ -6,10 +6,9 @@ import (
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
// Auther is the authentication interface.
@@ -40,17 +39,12 @@ func MakeSignedTokenAPI(user *users.User, name string, duration time.Duration, p
if _, ok := user.Tokens[name]; ok {
return "", users.AuthToken{}, fmt.Errorf("key already exists with same name %v ", name)
}
id, err := utils.RandomHex(16)
if err != nil {
return "", users.AuthToken{}, fmt.Errorf("generate token id: %w", err)
}
now := time.Now()
expires := now.Add(duration)
// Create minimal token with only JWT standard claims
claim := users.AuthToken{
MinimalAuthToken: users.MinimalAuthToken{
RegisteredClaims: jwt.RegisteredClaims{
ID: id,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(expires),
Issuer: FB_ISSUER,
-12
View File
@@ -1,12 +0,0 @@
package auth
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
+4 -1
View File
@@ -15,7 +15,10 @@ 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.PasswordAdminUsername()
admin := settings.Config.Auth.AdminUsername
if admin == "" {
admin = "admin"
}
id, err := users.ResolveUsernameToID(admin)
if err != nil {
return nil, err
-31
View File
@@ -1,10 +1,8 @@
package auth
import (
"encoding/json"
"net/http"
"os"
"strings"
"github.com/gtsteffaniak/filebrowser/backend/internal/errors"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
@@ -40,32 +38,3 @@ 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
}
@@ -1,81 +0,0 @@
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,56 +0,0 @@
package auth
import (
"testing"
"testing/synctest"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestSignedTokensAreUniqueAtSameTime(t *testing.T) {
origKey := settings.Config.Auth.Key
settings.Config.Auth.Key = "test-signing-key-for-token-uniqueness"
t.Cleanup(func() { settings.Config.Auth.Key = origKey })
// Freeze the clock so every token has identical issue and expiry times.
synctest.Test(t, func(t *testing.T) {
now := time.Now()
tokens := make(map[string]users.AuthToken)
for _, userID := range []uint64{1, 2} {
user := &users.User{ID: userID}
for _, minimal := range []bool{true, false} {
for range 2 {
tokenString, claim, err := MakeSignedTokenAPI(user, "test-token", time.Hour, users.Permissions{Api: true}, minimal)
if err != nil {
t.Fatal(err)
}
if _, exists := tokens[tokenString]; exists {
t.Fatalf("token collision for user %d with minimal=%v", userID, minimal)
}
tokens[tokenString] = claim
}
}
}
ids := make(map[string]bool)
for tokenString, claim := range tokens {
var parsed users.MinimalAuthToken
if _, err := jwt.ParseWithClaims(tokenString, &parsed, JWTSigningKeyFunc()); err != nil {
t.Fatalf("parse signed token: %v", err)
}
if parsed.ID == "" || ids[parsed.ID] {
t.Fatal("each signed token must have a unique nonempty id")
}
ids[parsed.ID] = true
if parsed.ID != claim.ID {
t.Fatal("signed token id must match returned metadata")
}
if parsed.IssuedAt == nil || !parsed.IssuedAt.Equal(now) || parsed.ExpiresAt == nil || !parsed.ExpiresAt.Equal(now.Add(time.Hour)) {
t.Fatal("token uniqueness must not depend on changing timestamps")
}
assertMinimalJWTClaims(t, tokenString)
}
})
}
+96 -450
View File
@@ -109,25 +109,12 @@ 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
@@ -149,35 +136,30 @@ 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) error {
func (s *Storage) persistGroupSQLNL(groupname string) {
if s.sqlStore == nil {
return nil
return
}
members, ok := s.Groups[groupname]
if !ok {
if err := s.sqlStore.DeleteGroup(groupname); err != nil {
return fmt.Errorf("failed to delete group %q from sql: %w", groupname, err)
logger.Errorf("failed to delete group %q from sql: %v", groupname, err)
}
return nil
return
}
if err := s.sqlStore.SaveGroup(groupname, members); err != nil {
return fmt.Errorf("failed to save group %q: %w", groupname, err)
logger.Errorf("failed to save group %q: %v", 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) error {
func (s *Storage) ensureGroupExistsNL(groupname string) {
if _, ok := s.Groups[groupname]; ok {
return nil
return
}
s.Groups[groupname] = make(StringSet)
if err := s.persistGroupSQLNL(groupname); err != nil {
delete(s.Groups, groupname)
return err
}
return nil
s.persistGroupSQLNL(groupname)
}
// NewStorage creates a new Storage instance.
@@ -221,14 +203,8 @@ func accessRuleHasPayload(rule *AccessRule) bool {
// persistRuleSQLNL upserts or deletes one access_rules row to match in-memory state.
// Caller must hold s.mux (write lock). normalizedPath must match rule map keys (see getOrCreateRuleNL).
func (s *Storage) persistRuleSQLNL(sourcePath, normalizedPath string) {
if err := s.persistRuleSQLErrorNL(sourcePath, normalizedPath); err != nil {
logger.Warningf("%v", err)
}
}
func (s *Storage) persistRuleSQLErrorNL(sourcePath, normalizedPath string) error {
if s.sqlStore == nil {
return nil
return
}
var rule *AccessRule
if bySrc, ok := s.AllRules[sourcePath]; ok {
@@ -236,30 +212,13 @@ func (s *Storage) persistRuleSQLErrorNL(sourcePath, normalizedPath string) error
}
if !accessRuleHasPayload(rule) {
if err := s.sqlStore.DeleteAccessRule(sourcePath, normalizedPath); err != nil {
return fmt.Errorf("access rules SQL delete (source=%s path=%s): %w", sourcePath, normalizedPath, err)
logger.Warningf("access rules SQL delete (source=%s path=%s): %v", sourcePath, normalizedPath, err)
}
return nil
return
}
if err := s.sqlStore.SaveAccessRule(sourcePath, normalizedPath, rule); err != nil {
return fmt.Errorf("access rules SQL save (source=%s path=%s): %w", sourcePath, normalizedPath, err)
logger.Warningf("access rules SQL save (source=%s path=%s): %v", sourcePath, normalizedPath, err)
}
return nil
}
func (s *Storage) restoreRuleSQLNL(key RuleKey, rule *AccessRule) error {
if s.sqlStore == nil {
return nil
}
if !accessRuleHasPayload(rule) {
if err := s.sqlStore.DeleteAccessRule(key.Source, key.Path); err != nil {
return fmt.Errorf("access rules SQL delete (source=%s path=%s): %w", key.Source, key.Path, err)
}
return nil
}
if err := s.sqlStore.SaveAccessRule(key.Source, key.Path, rule); err != nil {
return fmt.Errorf("access rules SQL save (source=%s path=%s): %w", key.Source, key.Path, err)
}
return nil
}
// RemoveRuleByPath removes a rule by normalized index path.
@@ -348,9 +307,7 @@ 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()
if err := s.ensureGroupExistsNL(groupname); err != nil {
return err
}
s.ensureGroupExistsNL(groupname)
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Deny.Groups[groupname]; ok {
return errors.ErrExist
@@ -366,9 +323,7 @@ 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()
if err := s.ensureGroupExistsNL(groupname); err != nil {
return err
}
s.ensureGroupExistsNL(groupname)
rule := s.getOrCreateRuleNL(sourcePath, indexPath)
if _, ok := rule.Allow.Groups[groupname]; ok {
return errors.ErrExist
@@ -618,22 +573,14 @@ 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{}{}
if err := s.persistGroupSQLNL(group); err != nil {
delete(s.Groups[group], username)
if createdGroup {
delete(s.Groups, group)
}
return err
}
s.persistGroupSQLNL(group)
s.clearAllCaches()
return nil
}
@@ -650,98 +597,6 @@ 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]
delete(s.Groups, group)
touched, originals := s.removeAllRulesForGroupNL(group)
if !existed && len(touched) == 0 {
return nil
}
if err := s.persistGroupDeleteNL(group, touched); err != nil {
if existed {
s.Groups[group] = previousMembers
}
for key, rule := range originals {
if s.AllRules[key.Source] == nil {
s.AllRules[key.Source] = make(RuleMap)
}
s.AllRules[key.Source][key.Path] = rule
}
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()
@@ -761,72 +616,41 @@ 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))
for _, g := range newGroups {
if !IsUsableSyncedGroupName(g) {
if g == "" {
continue
}
newGroupsSet[g] = struct{}{}
}
affected := make(map[string]struct{})
// Iterate over all existing groups to find the user's current memberships
for group, members := range s.Groups {
_, userIsInGroup := members[username]
_, groupIsInNewSet := newGroupsSet[group]
if userIsInGroup && !groupIsInNewSet {
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{}{}
}
}
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 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{}{}
}
}
// Add user to new groups
for group := range newGroupsSet {
if _, ok := s.Groups[group]; !ok {
s.Groups[group] = make(StringSet)
}
s.Groups[group][username] = struct{}{}
}
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
if _, ok := s.Groups[group][username]; !ok {
s.Groups[group][username] = struct{}{}
affected[group] = struct{}{}
}
persisted = append(persisted, group)
}
for group := range affected {
s.persistGroupSQLNL(group)
}
if len(affected) > 0 {
s.clearAllCaches()
@@ -834,31 +658,6 @@ 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()
@@ -871,147 +670,7 @@ func (s *Storage) RemoveUserFromGroup(group, username string) error {
return nil
}
delete(members, username)
if err := s.persistGroupSQLNL(group); err != nil {
s.Groups[group][username] = struct{}{}
return err
}
s.clearAllCaches()
return nil
}
// RemoveUserFromAllGroups removes a username from every group's member list.
// Group rows themselves are kept, even when left empty. Used when a user is deleted.
func (s *Storage) RemoveUserFromAllGroups(username string) error {
s.mux.Lock()
defer s.mux.Unlock()
affected := make(map[string]StringSet)
for group, members := range s.Groups {
if _, ok := members[username]; ok {
affected[group] = cloneStringSet(members)
}
}
if len(affected) == 0 {
return nil
}
for group := range affected {
delete(s.Groups[group], username)
}
var persisted []string
for _, group := range slices.Sorted(maps.Keys(affected)) {
if err := s.persistGroupSQLNL(group); err != nil {
for _, g := range persisted {
if restoreErr := s.restoreGroupSQLNL(g, affected[g]); restoreErr != nil {
logger.Errorf("failed to restore group %q in sql after failure: %v", g, restoreErr)
}
}
for g, snap := range affected {
s.Groups[g] = snap
}
return err
}
persisted = append(persisted, group)
}
s.clearAllCaches()
return nil
}
// RenameUserInGroups replaces one username with another in every group member list.
// Used when a user's username changes so memberships follow the rename.
func (s *Storage) RenameUserInGroups(oldName, newName string) error {
s.mux.Lock()
defer s.mux.Unlock()
if oldName == newName {
return nil
}
affected := make(map[string]StringSet)
for group, members := range s.Groups {
if _, ok := members[oldName]; ok {
affected[group] = cloneStringSet(members)
}
}
if len(affected) == 0 {
return nil
}
for group := range affected {
delete(s.Groups[group], oldName)
s.Groups[group][newName] = struct{}{}
}
var persisted []string
for _, group := range slices.Sorted(maps.Keys(affected)) {
if err := s.persistGroupSQLNL(group); err != nil {
for _, g := range persisted {
if restoreErr := s.restoreGroupSQLNL(g, affected[g]); restoreErr != nil {
logger.Errorf("failed to restore group %q in sql after failure: %v", g, restoreErr)
}
}
for g, snap := range affected {
s.Groups[g] = snap
}
return err
}
persisted = append(persisted, group)
}
s.clearAllCaches()
return nil
}
// RenameUserInRules replaces one username with another in every allow and deny user list.
// Used when a user's username changes so path access rules follow the rename.
func (s *Storage) RenameUserInRules(oldName, newName string) error {
s.mux.Lock()
defer s.mux.Unlock()
if oldName == newName {
return nil
}
affected := make(map[RuleKey]*AccessRule)
var keys []RuleKey
for sourcePath, rulesBySource := range s.AllRules {
for indexPath, rule := range rulesBySource {
_, inAllow := rule.Allow.Users[oldName]
_, inDeny := rule.Deny.Users[oldName]
if !inAllow && !inDeny {
continue
}
key := RuleKey{Source: sourcePath, Path: indexPath}
affected[key] = cloneAccessRule(rule)
if inAllow {
delete(rule.Allow.Users, oldName)
rule.Allow.Users[newName] = struct{}{}
}
if inDeny {
delete(rule.Deny.Users, oldName)
rule.Deny.Users[newName] = struct{}{}
}
keys = append(keys, key)
}
}
if len(keys) == 0 {
return nil
}
slices.SortFunc(keys, func(a, b RuleKey) int {
if c := strings.Compare(a.Source, b.Source); c != 0 {
return c
}
return strings.Compare(a.Path, b.Path)
})
var persisted []RuleKey
for _, key := range keys {
if err := s.persistRuleSQLErrorNL(key.Source, key.Path); err != nil {
for _, pk := range persisted {
if restoreErr := s.restoreRuleSQLNL(pk, affected[pk]); restoreErr != nil {
logger.Errorf("failed to restore access rule %q/%q in sql after failure: %v", pk.Source, pk.Path, restoreErr)
}
}
for key, snap := range affected {
if s.AllRules[key.Source] == nil {
s.AllRules[key.Source] = make(RuleMap)
}
s.AllRules[key.Source][key.Path] = snap
}
return err
}
persisted = append(persisted, key)
}
s.persistGroupSQLNL(group)
s.clearAllCaches()
return nil
}
@@ -1162,104 +821,91 @@ func (s *Storage) RemoveDenyAll(sourcePath string, indexPath utils.IndexPath) (b
}
// RemoveAllRulesForUser removes a user from all allow and deny lists.
// On a persistence failure the in-memory and already-persisted rules are restored and the error is returned.
func (s *Storage) RemoveAllRulesForUser(username string) error {
s.mux.Lock()
defer s.mux.Unlock()
affected := make(map[RuleKey]*AccessRule)
changed := false
dirty := make(map[string]map[string]struct{})
touch := func(sp, ip string) {
if dirty[sp] == nil {
dirty[sp] = make(map[string]struct{})
}
dirty[sp][ip] = struct{}{}
}
for sourcePath, rulesBySource := range s.AllRules {
for indexPath, rule := range rulesBySource {
_, inAllow := rule.Allow.Users[username]
_, inDeny := rule.Deny.Users[username]
if !inAllow && !inDeny {
continue
if _, exists := rule.Allow.Users[username]; exists {
delete(rule.Allow.Users, username)
touch(sourcePath, indexPath)
changed = true
}
affected[RuleKey{Source: sourcePath, Path: indexPath}] = cloneAccessRule(rule)
delete(rule.Allow.Users, username)
delete(rule.Deny.Users, username)
}
}
if len(affected) == 0 {
return nil
}
keys := slices.SortedFunc(maps.Keys(affected), func(a, b RuleKey) int {
if c := strings.Compare(a.Source, b.Source); c != 0 {
return c
}
return strings.Compare(a.Path, b.Path)
})
for _, key := range keys {
rule := s.AllRules[key.Source][key.Path]
if rule.DenyAll || len(rule.Allow.Users) > 0 || len(rule.Allow.Groups) > 0 || len(rule.Deny.Users) > 0 || len(rule.Deny.Groups) > 0 {
continue
}
delete(s.AllRules[key.Source], key.Path)
if len(s.AllRules[key.Source]) == 0 {
delete(s.AllRules, key.Source)
}
}
var persisted []RuleKey
for _, key := range keys {
if err := s.persistRuleSQLErrorNL(key.Source, key.Path); err != nil {
for _, pk := range persisted {
if restoreErr := s.restoreRuleSQLNL(pk, affected[pk]); restoreErr != nil {
logger.Errorf("failed to restore access rule %q/%q in sql after failure: %v", pk.Source, pk.Path, restoreErr)
}
if _, exists := rule.Deny.Users[username]; exists {
delete(rule.Deny.Users, username)
touch(sourcePath, indexPath)
changed = true
}
for k, snap := range affected {
if s.AllRules[k.Source] == nil {
s.AllRules[k.Source] = make(RuleMap)
}
s.AllRules[k.Source][k.Path] = snap
}
return err
}
persisted = append(persisted, key)
}
s.clearAllCaches()
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) plus a deep copy of each
// touched rule's previous state for rollback. It does not persist; the caller must hold
// s.mux and save the returned rules.
func (s *Storage) removeAllRulesForGroupNL(groupname string) ([]RuleKey, map[RuleKey]*AccessRule) {
var touched []RuleKey
originals := make(map[RuleKey]*AccessRule)
for sourcePath, rulesBySource := range s.AllRules {
for indexPath, rule := range rulesBySource {
_, inAllow := rule.Allow.Groups[groupname]
_, inDeny := rule.Deny.Groups[groupname]
if !inAllow && !inDeny {
continue
}
key := RuleKey{Source: sourcePath, Path: indexPath}
originals[key] = cloneAccessRule(rule)
delete(rule.Allow.Groups, groupname)
delete(rule.Deny.Groups, groupname)
// A rule left with only DenyAll must stay, otherwise removing the group would open the path.
if !accessRuleHasPayload(rule) {
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
touch(sourcePath, indexPath)
}
touched = append(touched, key)
}
}
return touched, originals
if changed {
s.clearAllCaches()
for sp, paths := range dirty {
for ip := range paths {
s.persistRuleSQLNL(sp, ip)
}
}
}
return nil
}
func cloneAccessRule(rule *AccessRule) *AccessRule {
if rule == nil {
return nil
// RemoveAllRulesForGroup removes a group from all allow and deny lists.
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{})
}
dirty[sp][ip] = struct{}{}
}
return &AccessRule{
DenyAll: rule.DenyAll,
Deny: RuleSet{Users: cloneStringSet(rule.Deny.Users), Groups: cloneStringSet(rule.Deny.Groups)},
Allow: RuleSet{Users: cloneStringSet(rule.Allow.Users), Groups: cloneStringSet(rule.Allow.Groups)},
for sourcePath, rulesBySource := range s.AllRules {
for indexPath, rule := range rulesBySource {
if _, exists := rule.Allow.Groups[groupname]; exists {
delete(rule.Allow.Groups, groupname)
touch(sourcePath, indexPath)
changed = true
}
if _, exists := rule.Deny.Groups[groupname]; exists {
delete(rule.Deny.Groups, groupname)
touch(sourcePath, indexPath)
changed = true
}
if len(rule.Allow.Users) == 0 && len(rule.Allow.Groups) == 0 && len(rule.Deny.Users) == 0 && len(rule.Deny.Groups) == 0 {
delete(s.AllRules[sourcePath], indexPath)
if len(s.AllRules[sourcePath]) == 0 {
delete(s.AllRules, sourcePath)
}
touch(sourcePath, indexPath)
}
}
}
if changed {
s.clearAllCaches()
for sp, paths := range dirty {
for ip := range paths {
s.persistRuleSQLNL(sp, ip)
}
}
}
return nil
}
// GetRulesForUser returns all rules for a specific user for a given sourcePath.
@@ -1,7 +1,6 @@
package access_test
import (
stderrors "errors"
"os"
"path/filepath"
"strings"
@@ -1852,200 +1851,3 @@ 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)
}
}
@@ -1,174 +0,0 @@
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"])
}
}
@@ -1,79 +0,0 @@
package access
import (
"fmt"
"strings"
"unicode"
"unicode/utf8"
)
// MaxGroupNameLength bounds names for manually created groups. Existing names
// (e.g. LDAP DNs synced from an IdP) may exceed it and are never rejected on read.
const MaxGroupNameLength = 128
// MinGroupNameLength bounds manually created group names. Existing records are
// never rejected so legacy single-character names stay usable.
const MinGroupNameLength = 2
// NormalizeGroupName trims and validates a group name supplied by an admin API.
// It is only used when manually creating or referencing a group; names that
// already exist in the database are always accepted on read, list, edit and
// delete paths so legacy records stay usable.
func NormalizeGroupName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("group name is required")
}
if utf8.RuneCountInString(name) < MinGroupNameLength && !isSingleLetterName(name) {
return "", fmt.Errorf("group name must be at least %d characters", MinGroupNameLength)
}
if utf8.RuneCountInString(name) > MaxGroupNameLength {
return "", fmt.Errorf("group name exceeds %d characters", MaxGroupNameLength)
}
for _, r := range name {
if unicode.IsControl(r) {
return "", fmt.Errorf("group name must not contain control characters")
}
}
return name, nil
}
// isSingleLetterName reports whether name is one non-ASCII letter (e.g. a CJK ideograph),
// which is a meaningful group name on its own.
func isSingleLetterName(name string) bool {
r, size := utf8.DecodeRuneInString(name)
return size == len(name) && r > unicode.MaxASCII && unicode.IsLetter(r)
}
// NormalizeMembers trims, dedupes and drops empty usernames while preserving order.
func NormalizeMembers(usernames []string) []string {
seen := make(map[string]struct{}, len(usernames))
out := make([]string, 0, len(usernames))
for _, name := range usernames {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if _, ok := seen[name]; ok {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
return out
}
// IsUsableSyncedGroupName reports whether a group name coming from an identity
// provider can be stored. Only the minimum needed to keep rows usable is
// rejected; everything else is kept so IdP naming conventions keep working.
func IsUsableSyncedGroupName(name string) bool {
if strings.TrimSpace(name) == "" {
return false
}
for _, r := range name {
if unicode.IsControl(r) {
return false
}
}
return true
}
@@ -1,115 +0,0 @@
package access_test
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
)
func TestNormalizeGroupName(t *testing.T) {
for _, tc := range []struct {
name string
in string
want string
wantErr bool
}{
{"trims", " editors ", "editors", false},
{"spaces inside", "cn=admins with spaces,ou=x", "cn=admins with spaces,ou=x", false},
{"empty", "", "", true},
{"blank", " ", "", true},
{"newline", "a\nb", "", true},
{"too short", "a", "", true},
{"single wide char", "界", "界", false},
{"two chars", "ab", "ab", false},
{"single CJK char", "组", "组", false},
{"single non-letter non-ascii", "€", "", true},
{"too long", string(make([]byte, access.MaxGroupNameLength+1)), "", true},
} {
got, err := access.NormalizeGroupName(tc.in)
if tc.wantErr {
if err == nil {
t.Fatalf("%s: expected error, got %q", tc.name, got)
}
continue
}
if err != nil || got != tc.want {
t.Fatalf("%s: got %q, err=%v", tc.name, got, err)
}
}
// Long names of valid characters fail on length, not on content.
if _, err := access.NormalizeGroupName(string(make([]byte, access.MaxGroupNameLength+1))); err == nil {
t.Fatal("expected too-long name to fail")
}
if got := access.NormalizeMembers([]string{" a ", "b", "", "a", " "}); len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("unexpected members: %v", got)
}
}
func TestRemoveUserFromAllGroups(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("editors", []string{"alice", "bob"}); err != nil {
t.Fatal(err)
}
if err := s.SetGroupMembers("staff", []string{"alice"}); err != nil {
t.Fatal(err)
}
if err := s.RemoveUserFromAllGroups("alice"); err != nil {
t.Fatal(err)
}
if groups := s.GetUserGroups("alice"); len(groups) != 0 {
t.Fatalf("alice should be in no groups, got %v", groups)
}
if got := s.GetGroupMembers()["editors"]; len(got) != 1 || got[0] != "bob" {
t.Fatalf("expected [bob], got %v", got)
}
// The emptied group row still exists.
if _, ok := s.GetGroupMembers()["staff"]; !ok {
t.Fatal("empty group should remain")
}
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatal(err)
}
if _, ok := sqlGroups["staff"]["alice"]; ok {
t.Fatal("alice should be removed from staff in SQL")
}
// Removing a user who is in no group is a no-op.
if err := s.RemoveUserFromAllGroups("nobody"); err != nil {
t.Fatal(err)
}
}
func TestRenameUserInGroups(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
if err := s.SetGroupMembers("editors", []string{"alice", "bob"}); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInGroups("alice", "alicia"); err != nil {
t.Fatal(err)
}
got := s.GetGroupMembers()["editors"]
if len(got) != 2 || got[0] != "alicia" || got[1] != "bob" {
t.Fatalf("unexpected members: %v", got)
}
sqlGroups, err := sqlStore.GetAllGroups()
if err != nil {
t.Fatal(err)
}
if _, ok := sqlGroups["editors"]["alicia"]; !ok {
t.Fatal("alicia should be persisted in SQL")
}
if _, ok := sqlGroups["editors"]["alice"]; ok {
t.Fatal("alice should be removed in SQL")
}
// Renaming a non-member or same name is a no-op.
if err := s.RenameUserInGroups("nobody", "other"); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInGroups("alicia", "alicia"); err != nil {
t.Fatal(err)
}
}
@@ -1,12 +0,0 @@
package access
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
@@ -16,19 +16,18 @@ 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) DeleteGroupWithRules(string, []access.RuleUpsert, []access.RuleKey) error {
return nil
}
func (failingRevokePersister) SaveRevokedToken(string, int64) 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 errors.New("simulated retirement persistence failure")
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) 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) {
@@ -75,24 +74,6 @@ 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)
@@ -1,129 +0,0 @@
package access_test
import (
"slices"
"testing"
)
func TestRenameUserInRules(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
src := "test_source"
if err := s.AllowUser(src, idxPath("/allowed"), "alice"); err != nil {
t.Fatal(err)
}
if err := s.DenyUser(src, idxPath("/denied"), "alice"); err != nil {
t.Fatal(err)
}
if err := s.AllowUser(src, idxPath("/both"), "alice"); err != nil {
t.Fatal(err)
}
if err := s.DenyUser(src, idxPath("/both"), "alice"); err != nil {
t.Fatal(err)
}
// New name already listed on another path (set semantics: old entry removed, new unchanged).
if err := s.AllowUser(src, idxPath("/existing"), "alicia"); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInRules("alice", "alicia"); err != nil {
t.Fatal(err)
}
if rules := s.GetRulesForUser(src, "alice"); len(rules) != 0 {
t.Fatalf("expected no rules for old name alice, got %d", len(rules))
}
aliciaRules := s.GetRulesForUser(src, "alicia")
if len(aliciaRules) != 4 {
t.Fatalf("expected 4 paths for alicia, got %d", len(aliciaRules))
}
for _, path := range []string{"/allowed/", "/denied/", "/both/", "/existing/"} {
if _, ok := aliciaRules[path]; !ok {
t.Fatalf("missing rule at %q: %v", path, aliciaRules)
}
}
sqlRules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
for _, rule := range sqlRules[src] {
if _, ok := rule.Allow.Users["alice"]; ok {
t.Fatal("alice should not appear in allow lists in SQL")
}
if _, ok := rule.Deny.Users["alice"]; ok {
t.Fatal("alice should not appear in deny lists in SQL")
}
}
if err := s.RenameUserInRules("nobody", "other"); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInRules("alicia", "alicia"); err != nil {
t.Fatal(err)
}
}
func TestRemoveAllRulesForUserOnDelete(t *testing.T) {
setupTestSources()
s, _, sqlStore := createTestStorageWithSQL(t)
src := "test_source"
if err := s.AllowUser(src, idxPath("/a"), "bob"); err != nil {
t.Fatal(err)
}
if err := s.DenyUser(src, idxPath("/b"), "bob"); err != nil {
t.Fatal(err)
}
if err := s.RemoveAllRulesForUser("bob"); err != nil {
t.Fatal(err)
}
if rules := s.GetRulesForUser(src, "bob"); len(rules) != 0 {
t.Fatalf("expected no in-memory rules for bob, got %d", len(rules))
}
all, err := s.GetAllRules(src)
if err != nil {
t.Fatal(err)
}
if len(all) != 0 {
t.Fatalf("expected no rules left on source, got %d", len(all))
}
sqlRules, err := sqlStore.GetAllAccessRules()
if err != nil {
t.Fatal(err)
}
if len(sqlRules[src]) != 0 {
t.Fatalf("expected no SQL rules for source, got %v", sqlRules[src])
}
}
func TestRenameUserInRulesBothAllowAndDenySamePath(t *testing.T) {
setupTestSources()
s, _, _ := createTestStorageWithSQL(t)
src := "test_source"
if err := s.AllowUser(src, idxPath("/shared"), "old"); err != nil {
t.Fatal(err)
}
if err := s.DenyUser(src, idxPath("/shared"), "old"); err != nil {
t.Fatal(err)
}
if err := s.RenameUserInRules("old", "new"); err != nil {
t.Fatal(err)
}
rules, ok := s.GetFrontendRules(src, idxPath("/shared"))
if !ok {
t.Fatal("expected rule at /shared")
}
if !slices.Contains(rules.Allow.Users, "new") || slices.Contains(rules.Allow.Users, "old") {
t.Fatalf("allow users: %v", rules.Allow.Users)
}
if !slices.Contains(rules.Deny.Users, "new") || slices.Contains(rules.Deny.Users, "old") {
t.Fatalf("deny users: %v", rules.Deny.Users)
}
}
@@ -24,9 +24,6 @@ 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"
@@ -57,9 +54,6 @@ var AllEventTypes = []EventType{
EventAccessUpdate,
EventAccessCreate,
EventAccessDelete,
EventQuotaCreate,
EventQuotaUpdate,
EventQuotaDelete,
EventLogin,
EventLogout,
EventSignup,
@@ -90,13 +84,6 @@ 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{
@@ -121,15 +108,13 @@ 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, quotas, or shares")
return nil, fmt.Errorf("scope must be all, files, access, or shares")
}
if len(explicit) == 0 {
return allowed, nil
@@ -159,7 +144,6 @@ 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,18 +44,6 @@ func TestResolveScopeEventTypes(t *testing.T) {
t.Fatalf("access default missing %q in %v", et, got)
}
}
got, err = ResolveScopeEventTypes("quotas", nil)
if err != nil {
t.Fatal(err)
}
if len(got) != len(QuotaEventTypes) {
t.Fatalf("quotas default: got %d types, want %d", len(got), len(QuotaEventTypes))
}
for _, et := range QuotaEventTypes {
if !containsEventType(got, et) {
t.Fatalf("quotas default missing %q in %v", et, got)
}
}
}
func containsEventType(list []EventType, target EventType) bool {
-91
View File
@@ -1,91 +0,0 @@
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
}
@@ -1,116 +0,0 @@
package quota_test
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func setupMeterTestSource(t *testing.T, name, path string, indexingDisabled bool) {
t.Helper()
prevSourceMap := settings.Config.Server.SourceMap
prevNameToSource := settings.Config.Server.NameToSource
t.Cleanup(func() {
indexing.ClearTestIndices()
settings.Config.Server.SourceMap = prevSourceMap
settings.Config.Server.NameToSource = prevNameToSource
})
indexing.SetTestIndex(name, path)
idx := indexing.GetIndex(name)
if idx == nil {
t.Fatal("expected test index")
}
idx.Config.ResolvedRules.IndexingDisabled = indexingDisabled
settings.Config.Server.SourceMap = map[string]*settings.Source{
path: {Path: path, Name: name},
}
settings.Config.Server.NameToSource = map[string]*settings.Source{
name: settings.Config.Server.SourceMap[path],
}
}
func TestValidateConfiguredMeter_IndexingDisabled(t *testing.T) {
root := t.TempDir()
name := "meter-src"
setupMeterTestSource(t, name, root, true)
if err := quota.ValidateConfiguredMeter(name, quota.MeterIndexSize); err == nil {
t.Fatal("expected error for index_size when indexing disabled")
}
if err := quota.ValidateConfiguredMeter(name, quota.MeterIndexScope); err == nil {
t.Fatal("expected error for index_scope when indexing disabled")
}
if err := quota.ValidateConfiguredMeter(name, quota.MeterAccounted); err != nil {
t.Fatalf("accounted should be allowed: %v", err)
}
}
func TestEffectiveMeter_IndexingDisabledForcesAccounted(t *testing.T) {
root := t.TempDir()
name := "meter-src2"
setupMeterTestSource(t, name, root, true)
got := quota.EffectiveMeter(quota.MeterIndexSize, name, "/")
if got != quota.MeterAccounted {
t.Fatalf("expected accounted, got %q", got)
}
}
func TestEffectiveMeter_ExplicitAccountedWhenIndexOn(t *testing.T) {
root := t.TempDir()
name := "meter-src3"
setupMeterTestSource(t, name, root, false)
idx := indexing.GetIndex(name)
idx.SetFolderSize("/", 1024)
got := quota.EffectiveMeter(quota.MeterAccounted, name, "/")
if got != quota.MeterAccounted {
t.Fatalf("expected accounted, got %q", got)
}
}
func TestEffectiveMeter_IndexWhenSizeAvailable(t *testing.T) {
root := t.TempDir()
name := "meter-src4"
setupMeterTestSource(t, name, root, false)
idx := indexing.GetIndex(name)
idx.SetFolderSize("/", 2048)
got := quota.EffectiveMeter(quota.MeterIndexSize, name, "/")
if got != quota.MeterIndexSize {
t.Fatalf("expected index_size, got %q", got)
}
status := quota.MeasurementStatus(quota.MeterIndexSize, got)
if status != quota.MeasurementStatusReady {
t.Fatalf("expected ready, got %q", status)
}
}
func TestEffectiveMeter_IndexPathTrailingSlash(t *testing.T) {
root := t.TempDir()
name := "meter-src6"
setupMeterTestSource(t, name, root, false)
idx := indexing.GetIndex(name)
idx.SetFolderSize("/projects/", 4096)
got := quota.EffectiveMeter(quota.MeterIndexSize, name, "/projects")
if got != quota.MeterIndexSize {
t.Fatalf("expected index_size for path without trailing slash, got %q", got)
}
}
func TestEffectiveMeter_RuntimeFallback(t *testing.T) {
root := t.TempDir()
name := "meter-src5"
setupMeterTestSource(t, name, root, false)
got := quota.EffectiveMeter(quota.MeterIndexScope, name, "/missing")
if got != quota.MeterAccounted {
t.Fatalf("expected accounted fallback, got %q", got)
}
status := quota.MeasurementStatus(quota.MeterIndexScope, got)
if status != quota.MeasurementStatusAccountedFallback {
t.Fatalf("expected accounted_fallback, got %q", status)
}
}
-43
View File
@@ -1,43 +0,0 @@
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
}
+2 -19
View File
@@ -48,7 +48,6 @@ 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
@@ -86,11 +85,8 @@ type ShareEditable struct {
type ShareFrontend struct {
ShareEditable
ShareColumns
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"`
Username string `json:"username,omitempty"`
PathExists bool `json:"pathExists,omitempty"`
}
// SharePostBody is POST /api/share JSON. Plaintext password is hashed to Share.PasswordHash before persist.
@@ -102,19 +98,6 @@ 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) {
@@ -1,200 +0,0 @@
package sql
import (
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/pkg/indexing/iteminfo"
)
// TestCreatedTime_InsertAndReadBack checks that an inserted Created value is returned unchanged.
func TestCreatedTime_InsertAndReadBack(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(""))
defer pop()
db, _, err := NewIndexDB("created_time_set", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
created := time.Unix(1700000000, 0)
info := &iteminfo.FileInfo{
Path: "/a.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "a.txt",
Size: 12,
Created: &created,
ModTime: time.Unix(1, 0),
Type: "text/plain",
},
}
if insertErr := db.InsertItem("src1", "/a.txt", info); insertErr != nil {
t.Fatal(insertErr)
}
got, err := db.GetItem("src1", "/a.txt")
if err != nil {
t.Fatal(err)
}
if got.Created == nil {
t.Fatal("expected non-nil Created")
}
if !got.Created.Equal(created.Truncate(time.Second)) {
t.Errorf("Created = %v, want %v", got.Created, created.Truncate(time.Second))
}
}
// TestCreatedTime_NilStaysNil checks that an item inserted without Created reads back with nil.
func TestCreatedTime_NilStaysNil(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(""))
defer pop()
db, _, err := NewIndexDB("created_time_nil", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
info := &iteminfo.FileInfo{
Path: "/b.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "b.txt",
Size: 12,
ModTime: time.Unix(1, 0),
Type: "text/plain",
},
}
if insertErr := db.InsertItem("src1", "/b.txt", info); insertErr != nil {
t.Fatal(insertErr)
}
got, err := db.GetItem("src1", "/b.txt")
if err != nil {
t.Fatal(err)
}
if got.Created != nil {
t.Errorf("expected nil Created, got %v", got.Created)
}
}
// TestCreatedTime_UpsertSetsCreated checks that an upsert fills in a previously nil Created.
func TestCreatedTime_UpsertSetsCreated(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(""))
defer pop()
db, _, err := NewIndexDB("created_time_upsert", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
info := &iteminfo.FileInfo{
Path: "/c.txt",
ItemInfo: iteminfo.ItemInfo{
Name: "c.txt",
Size: 12,
ModTime: time.Unix(1, 0),
Type: "text/plain",
},
}
if insertErr := db.InsertItem("src1", "/c.txt", info); insertErr != nil {
t.Fatal(insertErr)
}
created := time.Unix(1700000000, 0)
info.Created = &created
if updateErr := db.InsertItem("src1", "/c.txt", info); updateErr != nil {
t.Fatal(updateErr)
}
got, err := db.GetItem("src1", "/c.txt")
if err != nil {
t.Fatal(err)
}
if got.Created == nil {
t.Fatal("expected non-nil Created after upsert")
}
if !got.Created.Equal(created.Truncate(time.Second)) {
t.Errorf("Created = %v, want %v", got.Created, created.Truncate(time.Second))
}
}
// TestCreatedTime_MigrationAddsColumn checks that CreateIndexTable adds created_time to an
// old-schema table and can be run repeatedly.
func TestCreatedTime_MigrationAddsColumn(t *testing.T) {
dir := t.TempDir()
pop := pushTestIndexConfig(t, dir, testIndexSQLConfig(""))
defer pop()
db, _, err := NewIndexDB("created_time_migration", "OFF", 1000, 32, false)
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`DROP TABLE index_items`); err != nil {
t.Fatal(err)
}
oldSchema := `
CREATE TABLE index_items (
source TEXT NOT NULL,
path TEXT NOT NULL,
parent_path TEXT NOT NULL,
name TEXT NOT NULL,
size INTEGER NOT NULL,
mod_time INTEGER NOT NULL,
type TEXT NOT NULL,
is_dir BOOLEAN NOT NULL,
is_hidden BOOLEAN NOT NULL,
has_preview BOOLEAN NOT NULL,
last_updated INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (source, path)
)`
if _, err := db.Exec(oldSchema); err != nil {
t.Fatal(err)
}
if err := db.CreateIndexTable(); err != nil {
t.Fatal(err)
}
if !hasCreatedTimeColumn(t, db) {
t.Fatal("expected created_time column after migration")
}
// Running again must not error (repeatable migration).
if err := db.CreateIndexTable(); err != nil {
t.Fatal(err)
}
if !hasCreatedTimeColumn(t, db) {
t.Fatal("expected created_time column after second CreateIndexTable call")
}
}
// hasCreatedTimeColumn reports whether index_items currently has a created_time column.
func hasCreatedTimeColumn(t *testing.T, db *IndexDB) bool {
t.Helper()
rows, err := db.Query(`PRAGMA table_info(index_items)`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var cid, notNull, pk int
var name, columnType string
var defaultValue interface{}
if err := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &pk); err != nil {
t.Fatal(err)
}
if name == "created_time" {
return true
}
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return false
}
+13 -69
View File
@@ -245,7 +245,6 @@ func (db *IndexDB) CreateIndexTable() error {
parent_path TEXT NOT NULL,
name TEXT NOT NULL,
size INTEGER NOT NULL,
created_time INTEGER NOT NULL DEFAULT 0,
mod_time INTEGER NOT NULL,
type TEXT NOT NULL,
is_dir BOOLEAN NOT NULL,
@@ -260,63 +259,17 @@ func (db *IndexDB) CreateIndexTable() error {
CREATE INDEX IF NOT EXISTS idx_last_updated ON index_items(source, last_updated);
`
_, err := db.Exec(query)
if err != nil {
return err
}
return db.ensureCreatedTimeColumn()
}
// ensureCreatedTimeColumn adds the created_time column to index_items when it is
// missing, so databases created before the column existed are upgraded in place.
func (db *IndexDB) ensureCreatedTimeColumn() error {
rows, err := db.Query(`PRAGMA table_info(index_items)`)
if err != nil {
return err
}
found := false
for rows.Next() {
var cid, notNull, pk int
var name, columnType string
var defaultValue interface{}
scanErr := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &pk)
if scanErr != nil {
rows.Close()
return scanErr
}
if name == "created_time" {
found = true
}
}
if rowsErr := rows.Err(); rowsErr != nil {
rows.Close()
return rowsErr
}
rows.Close()
if found {
return nil
}
_, err = db.Exec(`ALTER TABLE index_items ADD COLUMN created_time INTEGER NOT NULL DEFAULT 0`)
return err
}
// createdUnix converts a creation time to unix seconds for storage, returning 0
// when the time is nil or zero (stored as "unavailable").
func createdUnix(created *time.Time) int64 {
if created == nil || created.IsZero() {
return 0
}
return created.Unix()
}
func (db *IndexDB) InsertItem(source, path string, info *iteminfo.FileInfo) error {
query := `
INSERT INTO index_items (source, path, parent_path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO index_items (source, path, parent_path, name, size, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source, path) DO UPDATE SET
parent_path = excluded.parent_path,
name = excluded.name,
size = excluded.size,
created_time = excluded.created_time,
mod_time = excluded.mod_time,
type = excluded.type,
is_dir = excluded.is_dir,
@@ -331,7 +284,6 @@ func (db *IndexDB) InsertItem(source, path string, info *iteminfo.FileInfo) erro
parentPath,
info.Name,
info.Size,
createdUnix(info.Created),
info.ModTime.Unix(),
info.Type,
info.Type == "directory",
@@ -413,13 +365,12 @@ func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo)
}()
stmt, err := tx.Prepare(`
INSERT INTO index_items (source, path, parent_path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO index_items (source, path, parent_path, name, size, mod_time, type, is_dir, is_hidden, has_preview, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(source, path) DO UPDATE SET
parent_path = excluded.parent_path,
name = excluded.name,
size = excluded.size,
created_time = excluded.created_time,
mod_time = excluded.mod_time,
type = excluded.type,
is_dir = excluded.is_dir,
@@ -445,7 +396,6 @@ func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo)
parentPath,
info.Name,
info.Size,
createdUnix(info.Created),
info.ModTime.Unix(),
info.Type,
info.Type == "directory",
@@ -478,7 +428,7 @@ func (db *IndexDB) attemptBulkInsert(source string, items []*iteminfo.FileInfo)
// Busy/locked errors are returned so callers can fall back to the filesystem.
func (db *IndexDB) GetItem(source, path string) (*iteminfo.FileInfo, error) {
query := `
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items WHERE source = ? AND path = ?
`
row := db.QueryRow(query, source, path)
@@ -506,7 +456,7 @@ func (db *IndexDB) GetItemsByPaths(source string, paths []string) (map[string]*i
}
query := fmt.Sprintf(`
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items WHERE source = ? AND path IN (%s)
`, strings.Join(placeholders, ","))
@@ -531,7 +481,7 @@ func (db *IndexDB) GetItemsByPaths(source string, paths []string) (map[string]*i
// GetDirectoryChildren retrieves all children of a directory for a specific source.
func (db *IndexDB) GetDirectoryChildren(source, dirPath string) ([]*iteminfo.FileInfo, error) {
query := `
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items WHERE source = ? AND parent_path = ?
ORDER BY is_dir DESC, name ASC
`
@@ -977,7 +927,7 @@ func (db *IndexDB) GetFilesForMultipleSizes(source string, sizes []int64, pathPr
if pathPrefix != "" {
nextPrefix := getNextPathPrefix(pathPrefix)
query = fmt.Sprintf(`
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items
WHERE source = ? AND size IN (%s) AND is_dir = 0 AND path >= ? AND path < ?
ORDER BY size, type, name
@@ -985,7 +935,7 @@ func (db *IndexDB) GetFilesForMultipleSizes(source string, sizes []int64, pathPr
args = append(args, pathPrefix, nextPrefix)
} else {
query = fmt.Sprintf(`
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items
WHERE source = ? AND size IN (%s) AND is_dir = 0
ORDER BY size, type, name
@@ -1145,7 +1095,7 @@ func (db *IndexDB) GetFilesBySizeAndType(source string, size int64, fileType str
if pathPrefix != "" {
nextPrefix := getNextPathPrefix(pathPrefix)
query = `
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items
WHERE source = ? AND size = ? AND type = ? AND is_dir = 0 AND path >= ? AND path < ?
ORDER BY name
@@ -1153,7 +1103,7 @@ func (db *IndexDB) GetFilesBySizeAndType(source string, size int64, fileType str
args = []interface{}{source, size, fileType, pathPrefix, nextPrefix}
} else {
query = `
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items
WHERE source = ? AND size = ? AND type = ? AND is_dir = 0
ORDER BY name
@@ -1187,7 +1137,7 @@ func (db *IndexDB) GetFilesBySize(source string, size int64, pathPrefix string)
if pathPrefix != "" {
nextPrefix := getNextPathPrefix(pathPrefix)
query = `
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items
WHERE source = ? AND size = ? AND is_dir = 0 AND path >= ? AND path < ?
ORDER BY name
@@ -1195,7 +1145,7 @@ func (db *IndexDB) GetFilesBySize(source string, size int64, pathPrefix string)
args = []interface{}{source, size, pathPrefix, nextPrefix}
} else {
query = `
SELECT path, name, size, created_time, mod_time, type, is_dir, is_hidden, has_preview
SELECT path, name, size, mod_time, type, is_dir, is_hidden, has_preview
FROM index_items
WHERE source = ? AND size = ? AND is_dir = 0
ORDER BY name
@@ -1310,7 +1260,6 @@ func getParentPath(path string) string {
func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileInfo, error) {
var info iteminfo.FileInfo
var createdTime int64
var modTime int64
var isDir bool
@@ -1318,7 +1267,6 @@ func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileIn
&info.Path,
&info.Name,
&info.Size,
&createdTime,
&modTime,
&info.Type,
&isDir,
@@ -1328,10 +1276,6 @@ func scanItem(scanner interface{ Scan(...interface{}) error }) (*iteminfo.FileIn
if err != nil {
return nil, err
}
if createdTime > 0 {
t := time.Unix(createdTime, 0)
info.Created = &t
}
info.ModTime = time.Unix(modTime, 0)
info.IsDir = isDir
return &info, nil
+12 -13
View File
@@ -8,15 +8,14 @@ import (
// SearchItem represents a single search result row from the database
type SearchItem struct {
Source string
Path string
Name string
Size int64
CreatedTime int64
ModTime int64
Type string
IsDir bool
HasPreview bool
Source string
Path string
Name string
Size int64
ModTime int64
Type string
IsDir bool
HasPreview bool
}
// appendNameGlobSQL adds AND (name GLOB ? OR ...) or AND (name GLOB ? AND ...) when joinAnd is true.
@@ -45,8 +44,8 @@ func appendNameGlobSQL(query string, args []interface{}, patterns []string, join
// Returns rows that can be iterated to scan search results.
func (db *IndexDB) SearchItems(source string, scope string, largest bool, nameGlobPatterns []string, nameGlobPatternsAnd, caseExact bool) (*sql.Rows, error) {
query := `
SELECT path, name, size, created_time, mod_time, type, is_dir, has_preview
FROM index_items
SELECT path, name, size, mod_time, type, is_dir, has_preview
FROM index_items
WHERE source = ?
`
args := []interface{}{source}
@@ -78,8 +77,8 @@ func (db *IndexDB) SearchItemsMultiSource(sources []string, sourceScopes map[str
}
query := `
SELECT source, path, name, size, created_time, mod_time, type, is_dir, has_preview
FROM index_items
SELECT source, path, name, size, mod_time, type, is_dir, has_preview
FROM index_items
`
args := []interface{}{}
whereClauses := []string{}
-37
View File
@@ -101,43 +101,6 @@ 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 = ?`
@@ -1,98 +0,0 @@
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")
}
}
@@ -1,12 +0,0 @@
package sqldb
import (
"os"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/testutil"
)
func TestMain(m *testing.M) {
os.Exit(testutil.Main(m))
}
@@ -124,28 +124,6 @@ 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)
@@ -206,9 +184,6 @@ 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
}
@@ -234,48 +209,6 @@ func runMigrations(db *sql.DB, fromVersion int) error {
return nil
}
func migrateV2(db *sql.DB) error {
schema := `
CREATE TABLE IF NOT EXISTS quotas (
id TEXT PRIMARY KEY NOT NULL,
source TEXT NOT NULL,
path TEXT NOT NULL,
user_id TEXT,
limit_bytes INTEGER NOT NULL DEFAULT 0,
meter TEXT NOT NULL DEFAULT 'index_size',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_quotas_source ON quotas(source);
CREATE INDEX IF NOT EXISTS idx_quotas_source_path ON quotas(source, path);
CREATE TABLE IF NOT EXISTS quota_counters (
quota_id TEXT PRIMARY KEY NOT NULL,
used_bytes INTEGER NOT NULL DEFAULT 0,
reserved_bytes INTEGER NOT NULL DEFAULT 0,
version INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL
);
`
if _, err := db.Exec(schema); err != nil {
return fmt.Errorf("migrate v2: %w", err)
}
return migrateV2AddMeterColumn(db)
}
func migrateV2AddMeterColumn(db *sql.DB) error {
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('quotas') WHERE name = 'meter'`).Scan(&count); err != nil {
return fmt.Errorf("migrate v2 meter column check: %w", err)
}
if count > 0 {
return nil
}
if _, err := db.Exec(`ALTER TABLE quotas ADD COLUMN meter TEXT NOT NULL DEFAULT 'index_size'`); err != nil {
return fmt.Errorf("migrate v2 add meter column: %w", err)
}
return nil
}
// addHashedTokenSessionColumn adds the is_session flag to pre-existing
// hashed_tokens tables. Existing rows are treated as non-session (API) tokens.
func addHashedTokenSessionColumn(db *sql.DB) error {
-221
View File
@@ -1,221 +0,0 @@
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()
}
+7 -23
View File
@@ -32,34 +32,18 @@ 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)
}
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)
_, err = s.db.Exec(query, key, valueJSON)
if err != nil {
return fmt.Errorf("failed to save setting: %w", err)
}
return nil
}
// DeleteSetting deletes a setting by key
@@ -64,7 +64,6 @@ func APIScopesToBackend(apiScopes []FrontendScope) ([]BackendScope, error) {
Path: source.Path,
Scope: scope.Scope,
Permissions: frontendScopePermissions(scope),
Quota: scope.Quota,
})
}
return newScopes, nil
@@ -87,7 +86,6 @@ func (u *User) GetFrontendScopes() []FrontendScope {
Name: source.Name,
Scope: scope.Scope,
Permissions: &perms,
Quota: scope.Quota,
})
}
return newScopes
@@ -1,164 +0,0 @@
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
}
@@ -1,91 +0,0 @@
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
}
@@ -21,16 +21,3 @@ func TestValidateUsernameAcceptsNormalName(t *testing.T) {
t.Fatalf("expected valid username, got %v", err)
}
}
func TestValidateUsernameRejectsUnsafeNames(t *testing.T) {
for _, username := range []string{"..", "....", ".", "-", "al..ice", "bob/", "./bob", `bob\`, "alice/docs", " alice", "alice "} {
if err := ValidateUsername(username); err == nil {
t.Fatalf("expected %q to be rejected", username)
}
}
for _, username := range []string{"alice", "first.last", "j.r.r", "a-b_c", "user@example.com", "José", "李雷", ".alice", "users"} {
if err := ValidateUsername(username); err != nil {
t.Fatalf("expected %q to be accepted, got %v", username, err)
}
}
}
+5 -27
View File
@@ -47,10 +47,8 @@ var reservedUsernames = map[string]struct{}{
strings.ToLower(AnonymousUserName): {},
}
// ValidateUsername rejects empty and reserved login names, and names that do not map to their
// own createUserDir home directory.
// ValidateUsername rejects empty and reserved login names.
func ValidateUsername(username string) error {
raw := username
username = strings.TrimSpace(username)
if username == "" {
return fmt.Errorf("username is empty")
@@ -58,11 +56,6 @@ func ValidateUsername(username string) error {
if _, reserved := reservedUsernames[strings.ToLower(username)]; reserved {
return fmt.Errorf("username %q is reserved", username)
}
// The per-user home directory is derived from CleanUsername(username), so reject names
// that it would rewrite, the names "." and "-", and names containing path separators.
if CleanUsername(raw) != raw || raw == "." || raw == "-" || strings.ContainsAny(raw, `/\`) {
return fmt.Errorf("username %q contains characters that are not allowed", raw)
}
return nil
}
@@ -190,7 +183,6 @@ type FrontendUser struct {
LoginMethod LoginMethod `json:"loginMethod"`
OtpEnabled bool `json:"otpEnabled"`
ShowFirstLogin bool `json:"showFirstLogin"`
EffectiveToolAccess map[string]bool `json:"effectiveToolAccess,omitempty"`
Perm Permissions `json:"perm,omitzero"`
}
@@ -209,7 +201,6 @@ 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"`
@@ -220,28 +211,21 @@ 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
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
Permissions SourceFilePermissions `json:"permissions,omitempty"`
Quota *ScopeQuota `json:"quota,omitempty"`
}
// json tags must match variable name with smaller case first letter
type NonAdminEditable struct {
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
@@ -250,10 +234,9 @@ 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
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
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
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
@@ -270,19 +253,14 @@ type NonAdminEditable struct {
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
ShowTypeColumn bool `json:"showTypeColumn"` // show the Type column in list and compact views
ShowCreationDateColumn bool `json:"showCreationDateColumn"` // show the Creation date column in list and compact views
ShowToolsInSidebar bool `json:"showToolsInSidebar"` // when false, sidebar hides links with category "tool" (default: true)
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
ToolAccess ToolAccessMap `json:"toolAccess,omitempty"` // per-tool access overrides
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
PromptRightCloseButton bool `json:"promptRightCloseButton"` // show the prompts close button on the right
ShowFirstLogin bool `json:"showFirstLogin"`
PasskeyCredentials []WebAuthnCredential `json:"passkeyCredentials,omitempty"`
ShowAdvancedProfile bool `json:"showAdvancedProfile"`
}
type FileLoading struct {
-1
View File
@@ -36,5 +36,4 @@ var (
ErrPasskeyExists = errors.New("passkey credential already exists")
ErrPasskeyNotEnabled = errors.New("passkey authentication is not enabled")
ErrPasskeyInvalidSession = errors.New("invalid or expired passkey session")
ErrQuotaExceeded = errors.New("quota exceeded")
)
+10
View File
@@ -69,6 +69,11 @@ 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,
@@ -101,6 +106,11 @@ 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
+23 -16
View File
@@ -27,8 +27,7 @@ var global *Service
// InitOptions configures startup initialization.
type InitOptions struct {
FFmpegPath string
MaxConcurrent int // deprecated: use Concurrency
Concurrency goffmpeg.Concurrency
MaxConcurrent int
CacheDir string
SkipHWTests bool
HardwareAcceleration bool
@@ -36,6 +35,9 @@ 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()
}
@@ -44,20 +46,12 @@ func Initialize(ctx context.Context, opts InitOptions) error {
logger.Info("Detecting ffmpeg hardware codec support...")
}
cfg := goffmpeg.Config{
svc, err := goffmpeg.New(ctx, goffmpeg.Config{
FFmpegPath: opts.FFmpegPath,
Concurrency: opts.Concurrency,
MaxConcurrent: opts.MaxConcurrent,
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
@@ -113,12 +107,20 @@ func (s *Service) Release() {
}
// VideoPreview extracts a JPEG preview frame to w.
// 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 {
func (s *Service) VideoPreview(ctx context.Context, w io.Writer, videoPath string, percentageSeek int) error {
if s == nil || s.inner == nil {
return fmt.Errorf("ffmpeg service not available")
}
return s.inner.VideoPreview(ctx, w, opts)
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,
})
}
// ExtractSubtitle returns embedded subtitle content as WebVTT.
@@ -136,6 +138,11 @@ 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
@@ -1,106 +0,0 @@
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)
}
}
}
+20 -55
View File
@@ -5,15 +5,10 @@ package imagemeta
import (
"encoding/binary"
"io"
"math"
"os"
)
const (
heicMetadataInitialScan = 256 * 1024
heicMetadataScanLimit = 32 * 1024 * 1024
maxBMFFRecursionDepth = 64
)
const heicMetadataScanLimit = 32 * 1024 * 1024
type heicTransform struct {
irot uint8
@@ -28,37 +23,19 @@ func parseHEICTransform(f *os.File) heicTransform {
if err != nil {
return heicTransform{}
}
fileSize := info.Size()
if fileSize < 12 {
size := info.Size()
if size > heicMetadataScanLimit {
size = heicMetadataScanLimit
}
if size < 12 {
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
buf := make([]byte, size)
n, err := f.ReadAt(buf, 0)
if err != nil && err != io.EOF {
return heicTransform{}
}
return parseHEICPrimaryIrot(buf[:n])
}
func parseHEICPrimaryIrot(data []byte) heicTransform {
@@ -98,13 +75,10 @@ func parseHEICPrimaryIrot(data []byte) heicTransform {
}
func findBMFFBox(data []byte, typ string) []byte {
return findBMFFBoxRange(data, 0, len(data), typ, 0)
return findBMFFBoxRange(data, 0, len(data), typ)
}
func findBMFFBoxRange(data []byte, start, end int, typ string, depth int) []byte {
if depth > maxBMFFRecursionDepth {
return nil
}
func findBMFFBoxRange(data []byte, start, end int, typ string) []byte {
pos := start
for pos+8 <= end {
size, header, ok := bmffBoxHeader(data, pos, end)
@@ -112,14 +86,14 @@ func findBMFFBoxRange(data []byte, start, end int, typ string, depth int) []byte
break
}
boxEnd := pos + size
if boxEnd > end || boxEnd < pos {
if boxEnd > end {
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, depth+1); child != nil {
if child := findBMFFBoxRange(data, pos+header, boxEnd, typ); child != nil {
return child
}
}
@@ -133,7 +107,7 @@ func findBMFFChildBox(container []byte, typ string) []byte {
if payload == nil {
return nil
}
return findBMFFBoxRange(payload, 0, len(payload), typ, 0)
return findBMFFBoxRange(payload, 0, len(payload), typ)
}
func isBMFFContainerType(typ []byte) bool {
@@ -157,14 +131,7 @@ func bmffBoxHeader(data []byte, pos, end int) (size int, header int, ok bool) {
if pos+16 > end {
return 0, 0, false
}
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(binary.BigEndian.Uint64(data[pos+8 : pos+16])), 16, true
}
return int(size32), 8, true
}
@@ -189,7 +156,7 @@ func bmffBoxPayload(box []byte) []byte {
func isBMFFFullBoxType(typ string) bool {
switch typ {
case "meta", "pitm", "ispe", "ipma", "hdlr", "iloc", "iinf", "infe":
case "meta", "pitm", "irot", "imir", "ispe", "ipma", "hdlr", "iloc", "iinf", "infe":
return true
default:
return false
@@ -235,10 +202,8 @@ func parseIPCOProperties(ipco []byte) []ipcoProperty {
}
child := payload[pos : pos+size]
prop := ipcoProperty{typ: string(child[4:8])}
if prop.typ == "irot" {
if payload := bmffBoxPayload(child); len(payload) >= 1 {
prop.irot = payload[0] & 0x03
}
if prop.typ == "irot" && len(child) >= 13 {
prop.irot = child[12] & 0x03
}
props = append(props, prop)
pos += size
@@ -97,33 +97,6 @@ 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
@@ -247,18 +220,7 @@ func bmffPITMBox(itemID uint16) []byte {
}
func bmffIrotBox(angle uint8) []byte {
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
return bmffFullBox("irot", 0, []byte{angle & 0x03})
}
func bmffIPMABox(entries []ipmaEntryFixture) []byte {
+8 -20
View File
@@ -18,7 +18,6 @@ const (
tiffTypeASCII = 2
tiffTypeShort = 3
tiffTypeLong = 4
tiffTypeIFD = 13
tiffTypeUndefined = 7
maxTIFFIFDsVisited = 128
maxTIFFIFDQueueLen = 128
@@ -206,23 +205,19 @@ 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 elemSize*int(count) <= 4:
case tiffTagByteSize(typ)*int(count) <= 4:
var b [4]byte
order.PutUint32(b[:], valueOffset)
raw = b[:count*uint32(elemSize)]
raw = b[:count*uint32(tiffTagByteSize(typ))]
default:
off := int(valueOffset)
n := int(count) * elemSize
n := int(count) * tiffTagByteSize(typ)
if off < 0 || off+n > len(data) {
return nil
}
@@ -230,18 +225,11 @@ 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 * elemSize
if pos+elemSize > len(raw) {
pos := i * 4
if pos+4 > len(raw) {
break
}
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
}
out = append(out, order.Uint32(raw[pos:pos+4]))
}
return out
}
@@ -252,9 +240,9 @@ func tiffTagByteSize(typ uint16) int {
return 1
case tiffTypeShort:
return 2
case tiffTypeLong, tiffTypeIFD:
case tiffTypeLong:
return 4
default:
return 0
return 1
}
}
@@ -93,31 +93,9 @@ 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
}
@@ -138,9 +116,6 @@ 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)
@@ -174,7 +149,7 @@ func buildSyntheticTIFF(fix tiffFixture) []byte {
writeIFD(ifd1Off, ifd1Entries, 0)
ifd0Entries := []tiffEntry{
{tag: tiffTagSubIFDs, typ: fix.subIFDType, count: 1, value: ifd1Off},
{tag: tiffTagSubIFDs, typ: tiffTypeLong, count: 1, value: ifd1Off},
{tag: tiffTagJPEGInterchange, typ: tiffTypeLong, count: 1, value: thumbOff},
{tag: tiffTagJPEGInterchangeLen, typ: tiffTypeLong, count: 1, value: uint32(len(fix.thumbnailJPEG))},
}
@@ -8,7 +8,6 @@ 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;
@@ -18,25 +17,6 @@ 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).
@@ -105,9 +85,6 @@ 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;
@@ -115,32 +92,7 @@ static int fb_render_document_page_jpeg(
double dpi = 150.0;
ctm = fz_scale(dpi / 72.0, dpi / 72.0);
bounds = fz_transform_rect(bounds, ctm);
if (!fb_rect_usable(bounds)) {
fz_throw(ctx, FZ_ERROR_GENERIC, "invalid transformed page bounds");
}
bbox = fz_round_rect(bounds);
{
const int max_orig_w = 4096;
const int max_orig_h = 4096;
if (!fb_bbox_within(bbox, max_orig_w, max_orig_h)) {
int w = bbox.x1 - bbox.x0;
int h = bbox.y1 - bbox.y0;
if (w <= 0 || h <= 0) {
fz_throw(ctx, FZ_ERROR_GENERIC, "invalid page bounds");
}
double scale = fb_dmin((double)max_orig_w / (double)w, (double)max_orig_h / (double)h);
ctm = fz_concat(ctm, fz_scale(scale, scale));
bounds = fz_bound_page(ctx, page);
bounds = fz_transform_rect(bounds, ctm);
if (!fb_rect_usable(bounds)) {
fz_throw(ctx, FZ_ERROR_GENERIC, "invalid transformed page bounds");
}
bbox = fz_round_rect(bounds);
if (!fb_bbox_within(bbox, max_orig_w, max_orig_h)) {
fz_throw(ctx, FZ_ERROR_GENERIC, "page exceeds maximum render size");
}
}
}
} else {
double page_w = bounds.x1 - bounds.x0;
double page_h = bounds.y1 - bounds.y0;
+10
View File
@@ -17,6 +17,11 @@ 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":
@@ -41,6 +46,11 @@ 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 {
+4 -5
View File
@@ -286,7 +286,7 @@ func (s *Service) generateRawPreview(ctx context.Context, file iteminfo.Extended
return s.generateImagePreview(ctx, file, previewSize)
case previewTypeVideo:
return s.generateVideoPreviewBytes(ctx, file, previewSize, seekPercentage)
return s.generateVideoPreviewBytes(ctx, file, 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, previewSize string, seekPercentage int) ([]byte, error) {
func (s *Service) generateVideoPreviewBytes(ctx context.Context, file iteminfo.ExtendedFileInfo, 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, previewSize, videoSeekPercentage)
imageBytes, err := s.GenerateVideoPreview(ctx, file.RealPath, videoSeekPercentage)
if err != nil {
// Don't log client cancellations as errors
if ctx.Err() != context.Canceled {
@@ -506,8 +506,7 @@ 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 == previewTypeVideo && settings.Env.FFmpegAvailable)) {
(previewType == previewTypeDocument && settings.Env.MuPdfAvailable)) {
if err := service.fileCache.Store(ctx, cacheKey, imageBytes); err != nil {
logger.Errorf("failed to cache image: %v", err)
}
+2 -16
View File
@@ -6,16 +6,14 @@ 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, previewSize string, percentageSeek int) ([]byte, error) {
func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath 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")
@@ -27,20 +25,8 @@ func (s *Service) GenerateVideoPreview(ctx context.Context, videoPath, previewSi
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, ops.PreviewOptions{
Input: videoPath,
SeekPercent: float64(percentageSeek),
Width: params.width,
Height: params.height,
ScaleMode: params.scaleMode,
Quality: params.quality,
})
err := s.ffmpegService.VideoPreview(ctx, &buf, videoPath, percentageSeek)
if err != nil {
return nil, err
}
-35
View File
@@ -1,35 +0,0 @@
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
}
@@ -1,73 +0,0 @@
package preview
import (
"testing"
"github.com/gtsteffaniak/go-ffmpeg/ops"
"github.com/stretchr/testify/require"
)
func TestFfmpegPreviewParamsForSize(t *testing.T) {
t.Parallel()
tests := []struct {
name string
size string
wantW int
wantH int
wantMode ops.ScaleMode
wantQual int
wantError bool
}{
{
name: "small fill",
size: "small",
wantW: 256,
wantH: 256,
wantMode: ops.ScaleFill,
wantQual: 10,
},
{
name: "large fit",
size: "large",
wantW: 640,
wantH: 640,
wantMode: ops.ScaleFit,
wantQual: 10,
},
{
name: "xlarge fit",
size: "xlarge",
wantW: 1024,
wantH: 1024,
wantMode: ops.ScaleFit,
wantQual: 10,
},
{
name: "original no scale",
size: "original",
wantQual: 10,
},
{
name: "unsupported",
size: "huge",
wantError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := ffmpegPreviewParamsForSize(tt.size)
if tt.wantError {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.wantW, got.width)
require.Equal(t, tt.wantH, got.height)
require.Equal(t, tt.wantMode, got.scaleMode)
require.Equal(t, tt.wantQual, got.quality)
})
}
}
-72
View File
@@ -1,72 +0,0 @@
package quota
import (
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
)
// CopyMoveContext describes a copy/move/rename admission check.
type CopyMoveContext struct {
Principal *users.User
Action string
FromSourceName string
ToSourceName string
FromSourcePath string
ToSourcePath string
FromIndexPath string
ToIndexPath string
IsSrcDir bool
OverwriteBytes int64
ShareHash string
ShareLimitBytes int64
SessionID string
}
// ReserveCopyMove reserves quota before a copy/move/rename.
func ReserveCopyMove(ctx CopyMoveContext) error {
itemSize, ok := state.IndexedPathBytes(ctx.FromSourceName, ctx.FromIndexPath, ctx.IsSrcDir)
if !ok {
if state.HasApplicableQuotas(ctx.Principal, ctx.ToSourcePath, ctx.ToIndexPath, ctx.ShareHash, ctx.ShareLimitBytes) {
return newError(CodeUsageUnknown, "", "", 0, 0, 0, "indexed size unavailable for quota check")
}
return nil
}
checks := state.BuildCopyMoveReserveChecks(
ctx.Principal,
ctx.ToSourceName,
ctx.ToSourcePath,
ctx.FromIndexPath,
ctx.ToIndexPath,
ctx.Action,
itemSize,
ctx.OverwriteBytes,
ctx.ShareHash,
ctx.ShareLimitBytes,
)
if len(checks) == 0 {
return nil
}
if err := state.ReserveQuota(ctx.SessionID, checks); err != nil {
return mapReserveError(err, checks)
}
return nil
}
// CommitCopyMove commits reserved quota after a successful copy/move/rename.
func CommitCopyMove(sessionID string) error {
return state.CommitQuota(sessionID, 0)
}
// ReleaseCopyMove releases quota reservation on failure.
func ReleaseCopyMove(sessionID string) {
state.ReleaseQuota(sessionID)
}
// OverwriteBytesAtPath returns indexed bytes at dest when replacing an existing path.
func OverwriteBytesAtPath(sourceName, indexPath string, isDir bool) int64 {
size, ok := state.IndexedPathBytes(sourceName, indexPath, isDir)
if !ok {
return 0
}
return size
}
-95
View File
@@ -1,95 +0,0 @@
package quota
import (
"errors"
"fmt"
)
const (
CodeExceeded = "quota_exceeded"
CodeMeasurementUnavailable = "quota_measurement_unavailable"
CodePathNotCountable = "quota_path_not_countable"
CodeUsageUnknown = "quota_usage_unknown"
CodeReservedCapacity = "quota_reserved_capacity"
CodeLengthRequired = "quota_length_required"
CodeRootImmutable = "quota_root_immutable"
)
// Error is returned when a quota check fails.
type Error struct {
Code string
QuotaKind string
QuotaID string
LimitBytes int64
UsedBytes int64
ReservedBytes int64
Message string
}
func (e *Error) Error() string {
return e.DisplayMessage()
}
// DisplayMessage returns a user-facing error string (no internal IDs).
func (e *Error) DisplayMessage() string {
if e.Message != "" {
return e.Message
}
return e.defaultMessage()
}
func (e *Error) defaultMessage() string {
switch e.Code {
case CodeExceeded:
switch e.QuotaKind {
case "share":
return "Share storage quota exceeded"
case "scope":
return "Source storage quota exceeded"
case "folder":
return "Folder storage quota exceeded"
}
return "Storage quota exceeded"
case CodeMeasurementUnavailable:
return "Storage measurement is not available yet"
case CodePathNotCountable:
return "Storage for this path cannot be measured"
case CodeUsageUnknown:
return "Storage usage is not available for quota check"
case CodeReservedCapacity:
return "Storage quota reservation failed"
case CodeLengthRequired:
return "Upload size is required when storage quota applies"
case CodeRootImmutable:
return "Root storage quota cannot be changed"
default:
return fmt.Sprintf("quota error: %s", e.Code)
}
}
func newError(code, kind, id string, limit, used, reserved int64, msg string) *Error {
return &Error{
Code: code,
QuotaKind: kind,
QuotaID: id,
LimitBytes: limit,
UsedBytes: used,
ReservedBytes: reserved,
Message: msg,
}
}
// IsQuotaError reports whether err is a quota Error.
func IsQuotaError(err error) bool {
var qe *Error
return errors.As(err, &qe)
}
// AsError unwraps a quota Error.
func AsError(err error) (*Error, bool) {
var qe *Error
if errors.As(err, &qe) {
return qe, true
}
return nil, false
}
-18
View File
@@ -1,18 +0,0 @@
package quota
import "testing"
func TestErrorDisplayMessage(t *testing.T) {
exceeded := newError(CodeExceeded, "folder", "uuid-id", 100, 90, 5, "")
if exceeded.DisplayMessage() != "Folder storage quota exceeded" {
t.Fatalf("folder exceeded: got %q", exceeded.DisplayMessage())
}
if exceeded.Error() != "Folder storage quota exceeded" {
t.Fatalf("Error() should match DisplayMessage: got %q", exceeded.Error())
}
custom := newError(CodeExceeded, "folder", "uuid-id", 100, 90, 5, "Custom limit message")
if custom.DisplayMessage() != "Custom limit message" {
t.Fatalf("custom message: got %q", custom.DisplayMessage())
}
}
-122
View File
@@ -1,122 +0,0 @@
package quota
import (
"os"
quotadb "github.com/gtsteffaniak/filebrowser/backend/internal/database/quota"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
// UploadContext describes an upload admission check.
type UploadContext struct {
Principal *users.User
SourceName string
SourcePath string
DestIndexPath string
DestRealPath string
ShareHash string
ShareLimitBytes int64
SessionID string
TotalSize int64
HasKnownSize bool
}
// CheckUploadLength verifies size is known when quotas apply.
func CheckUploadLength(ctx UploadContext) error {
shareLimit := ctx.ShareLimitBytes
if !state.HasApplicableQuotas(ctx.Principal, ctx.SourcePath, ctx.DestIndexPath, ctx.ShareHash, shareLimit) {
return nil
}
if !ctx.HasKnownSize || ctx.TotalSize < 0 {
return newError(CodeLengthRequired, "", "", 0, 0, 0, "upload size required when storage quota applies")
}
return nil
}
// ReserveUpload reserves quota for an upload session.
func ReserveUpload(ctx UploadContext) error {
delta := uploadReserveDelta(ctx)
if delta <= 0 {
return nil
}
checks := state.BuildReserveChecks(ctx.Principal, ctx.SourceName, ctx.SourcePath, ctx.DestIndexPath, ctx.ShareHash, ctx.ShareLimitBytes, delta)
if len(checks) == 0 {
return nil
}
if err := state.ReserveQuota(ctx.SessionID, checks); err != nil {
return mapReserveError(err, checks)
}
return nil
}
// CommitUpload commits quota after successful upload.
func CommitUpload(ctx UploadContext) error {
commitDelta := uploadCommitDelta(ctx)
if commitDelta > 0 {
return state.CommitQuota(ctx.SessionID, commitDelta)
}
state.ReleaseQuota(ctx.SessionID)
if commitDelta < 0 {
return state.ApplyAccountedUsageDelta(ctx.Principal, ctx.SourceName, ctx.SourcePath, ctx.DestIndexPath, ctx.ShareHash, commitDelta)
}
return nil
}
// ReleaseUpload releases quota reservation on failure.
func ReleaseUpload(sessionID string) {
state.ReleaseQuota(sessionID)
}
// PrincipalForUpload returns the user whose quota is charged.
func PrincipalForUpload(actor *users.User, shareOwnerID uint64, shareHash string) *users.User {
if shareHash != "" && shareOwnerID != 0 {
owner, err := state.GetUserByID(shareOwnerID)
if err == nil {
return &owner
}
}
return actor
}
// UploadCommitDelta returns signed byte change for an upload (negative when file shrinks).
func UploadCommitDelta(ctx UploadContext) int64 {
return uploadCommitDelta(ctx)
}
func uploadCommitDelta(ctx UploadContext) int64 {
if !ctx.HasKnownSize {
return 0
}
oldSize := int64(0)
if ctx.DestRealPath != "" {
if stat, err := os.Stat(ctx.DestRealPath); err == nil && !stat.IsDir() {
oldSize = stat.Size()
}
}
return ctx.TotalSize - oldSize
}
func uploadReserveDelta(ctx UploadContext) int64 {
delta := uploadCommitDelta(ctx)
if delta < 0 {
return 0
}
return delta
}
// ScopeQuotaForSource returns scope quota snapshot for sidebar.
func ScopeQuotaForSource(user *users.User, sourceName string) (quotadb.Snapshot, bool) {
sourceInfo, ok := settings.Config.Server.NameToSource[sourceName]
if !ok {
return quotadb.Snapshot{}, false
}
for _, bs := range user.BackendScopes {
if bs.Path != sourceInfo.Path || bs.Quota == nil || bs.Quota.LimitBytes <= 0 {
continue
}
return state.ScopeQuotaSnapshot(user, sourceName, bs.Quota), true
}
return quotadb.Snapshot{}, false
}
-49
View File
@@ -1,49 +0,0 @@
package quota
import (
"os"
"path/filepath"
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
)
func TestUploadCommitDelta_shrinkReturnsNegative(t *testing.T) {
dir := t.TempDir()
dest := filepath.Join(dir, "file.bin")
if err := os.WriteFile(dest, make([]byte, 100), 0644); err != nil {
t.Fatal(err)
}
ctx := UploadContext{
Principal: &users.User{},
DestRealPath: dest,
TotalSize: 50,
HasKnownSize: true,
}
if got := UploadCommitDelta(ctx); got != -50 {
t.Fatalf("UploadCommitDelta shrink: got %d want -50", got)
}
if got := uploadReserveDelta(ctx); got != 0 {
t.Fatalf("uploadReserveDelta shrink: got %d want 0", got)
}
}
func TestUploadCommitDelta_growthPositive(t *testing.T) {
dir := t.TempDir()
dest := filepath.Join(dir, "file.bin")
if err := os.WriteFile(dest, make([]byte, 40), 0644); err != nil {
t.Fatal(err)
}
ctx := UploadContext{
Principal: &users.User{},
DestRealPath: dest,
TotalSize: 100,
HasKnownSize: true,
}
if got := UploadCommitDelta(ctx); got != 60 {
t.Fatalf("UploadCommitDelta growth: got %d want 60", got)
}
if got := uploadReserveDelta(ctx); got != 60 {
t.Fatalf("uploadReserveDelta growth: got %d want 60", got)
}
}
-28
View File
@@ -1,28 +0,0 @@
package quota
import (
"github.com/gtsteffaniak/filebrowser/backend/internal/state"
)
// mapReserveError converts reserve failures to quota errors.
func mapReserveError(err error, checks []state.QuotaReserveCheck) error {
if err == nil {
return nil
}
for _, chk := range checks {
if chk.LimitBytes <= 0 || chk.DeltaBytes <= 0 {
continue
}
reserved := state.SessionReservedForQuota(chk.QuotaID)
if chk.UsedBytes+reserved+chk.DeltaBytes > chk.LimitBytes {
return newError(CodeExceeded, chk.Kind, chk.QuotaID, chk.LimitBytes, chk.UsedBytes, reserved, "")
}
}
for _, chk := range checks {
if chk.LimitBytes > 0 {
reserved := state.SessionReservedForQuota(chk.QuotaID)
return newError(CodeExceeded, chk.Kind, chk.QuotaID, chk.LimitBytes, chk.UsedBytes, reserved, "")
}
}
return newError(CodeExceeded, "", "", 0, 0, 0, "")
}
-142
View File
@@ -1,142 +0,0 @@
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)
}
@@ -1,74 +0,0 @@
package sharedefaults
import (
"testing"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/share"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/users"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestApplyDefaultsToEditable_mergesClientOverDefaults(t *testing.T) {
defaults := settings.ShareDefaults{
ShareType: "normal",
AllowModify: true,
DisableDownload: true,
Title: "Default title",
}
editable := share.ShareEditable{
FrontendShareInfo: share.FrontendShareInfo{
ShareType: "upload",
Title: "Custom",
},
}
ApplyDefaultsToEditable(&editable, defaults)
if editable.ShareType != "upload" {
t.Fatalf("client shareType should win, got %q", editable.ShareType)
}
if editable.Title != "Custom" {
t.Fatalf("client title should win, got %q", editable.Title)
}
if !editable.AllowModify || !editable.DisableDownload {
t.Fatal("expected defaults applied for unset fields")
}
}
func TestValidateEditableNotEnforced_blocksMismatch(t *testing.T) {
defaults := settings.ShareDefaults{
AllowModify: true,
SidebarLinks: []users.SidebarLink{{Name: "Info", Category: "shareInfo", Target: "#"}},
}
enforced := settings.ShareDefaultsEnforcement{AllowModify: true, SidebarLinks: true}
editable := share.ShareEditable{
FrontendShareInfo: share.FrontendShareInfo{
AllowModify: false,
SidebarLinks: []users.SidebarLink{{Name: "Other", Category: "custom", Target: "/x"}},
},
}
if err := ValidateEditableNotEnforced(&editable, enforced, defaults); err == nil {
t.Fatal("expected enforcement error")
}
}
func TestValidateEditableNotEnforced_blocksZeroEnforcedValue(t *testing.T) {
defaults := settings.ShareDefaults{AllowModify: false}
enforced := settings.ShareDefaultsEnforcement{AllowModify: true}
editable := share.ShareEditable{
FrontendShareInfo: share.FrontendShareInfo{AllowModify: true},
}
if err := ValidateEditableNotEnforced(&editable, enforced, defaults); err == nil {
t.Fatal("expected enforcement error for zero-valued enforced default")
}
}
func TestApplyEnforcedDefaults_appliesZeroBool(t *testing.T) {
defaults := settings.ShareDefaults{AllowModify: false}
enforced := settings.ShareDefaultsEnforcement{AllowModify: true}
editable := share.ShareEditable{
FrontendShareInfo: share.FrontendShareInfo{AllowModify: true},
}
ApplyEnforcedDefaults(&editable, defaults, enforced)
if editable.AllowModify {
t.Fatal("expected enforced allowModify=false")
}
}
-89
View File
@@ -1,89 +0,0 @@
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,
}
}
@@ -1,47 +0,0 @@
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
}
@@ -1,31 +0,0 @@
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)
}
}
-63
View File
@@ -2,7 +2,6 @@ package state
import (
"fmt"
"slices"
"github.com/gtsteffaniak/filebrowser/backend/internal/database/access"
"github.com/gtsteffaniak/filebrowser/backend/internal/utils"
@@ -103,53 +102,6 @@ func SyncUserGroups(username string, newGroups []string) error {
return accessDb.SyncUserGroups(username, newGroups)
}
// GroupExists reports whether a group name is already registered.
func GroupExists(groupname string) bool {
if accessDb == nil {
return false
}
return slices.Contains(accessDb.GetAllGroups(), groupname)
}
// RemoveUserFromAllGroups removes a username from every group's member list.
func RemoveUserFromAllGroups(username string) error {
if accessDb == nil {
return nil
}
return accessDb.RemoveUserFromAllGroups(username)
}
// RenameUserInGroups replaces one username with another in all group member lists.
func RenameUserInGroups(oldName, newName string) error {
if accessDb == nil {
return nil
}
return accessDb.RenameUserInGroups(oldName, newName)
}
// RenameUserInRules replaces one username with another in all path access allow/deny lists.
func RenameUserInRules(oldName, newName string) error {
if accessDb == nil {
return nil
}
return accessDb.RenameUserInRules(oldName, newName)
}
// RemoveAllRulesForUser removes a username from every path access allow and deny list.
func RemoveAllRulesForUser(username string) error {
if accessDb == nil {
return nil
}
return accessDb.RemoveAllRulesForUser(username)
}
// 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)
}
@@ -229,18 +181,3 @@ 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)
}
@@ -10,10 +10,9 @@ import (
)
// bootstrapDefaultAdminPassword returns the plaintext password for initial admin creation.
// Uses auth.methods.password.adminPassword (backfilled from auth.adminPassword when unset).
// When config adminPassword is blank or "admin", a random speakable password is generated and logged once.
func bootstrapDefaultAdminPassword(username string) (plaintext string, generated bool, err error) {
cfg := settings.PasswordAdminPassword()
cfg := settings.Config.Auth.AdminPassword
if cfg != "" && cfg != "admin" {
return cfg, false, nil
}
@@ -23,7 +22,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.methods.password.adminPassword in config to override): %s",
"Generated initial admin password for user %q (set auth.adminPassword in config to override on future resets): %s",
username,
plaintext,
)
@@ -14,62 +14,30 @@ func TestBootstrapWordsCount(t *testing.T) {
}
}
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
}()
func TestBootstrapDefaultAdminPasswordUsesConfigWhenSet(t *testing.T) {
orig := settings.Config.Auth.AdminPassword
defer func() { settings.Config.Auth.AdminPassword = orig }()
settings.Config.Auth.Methods.PasswordAuth.AdminPassword = "from-nested"
settings.Config.Auth.AdminPassword = "from-legacy"
settings.Config.Auth.AdminPassword = "from-config"
plain, generated, err := bootstrapDefaultAdminPassword("admin")
if err != nil {
t.Fatal(err)
}
if generated {
t.Fatal("expected generated=false when nested config password is set")
t.Fatal("expected generated=false when config password is set")
}
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)
if plain != "from-config" {
t.Fatalf("password = %q, want from-config", plain)
}
}
var speakableBootstrapPasswordPattern = regexp.MustCompile(`^[a-z]+-[abcdefghjkmnpqrstuvwxyz23456789]{5}-[abcdefghjkmnpqrstuvwxyz23456789]{2}$`)
func TestBootstrapDefaultAdminPasswordGeneratesWhenDefault(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
}()
orig := settings.Config.Auth.AdminPassword
defer func() { settings.Config.Auth.AdminPassword = orig }()
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 {
+9 -5
View File
@@ -19,11 +19,15 @@ func isUserLookupNotFound(err error) bool {
}
func noAuthAdminUsername() string {
return settings.PasswordAdminUsername()
admin := settings.Config.Auth.AdminUsername
if admin == "" {
return "admin"
}
return admin
}
// ResolveNoAuthUser returns the user noauth mode impersonates at runtime: the password
// admin username when present, otherwise the sole user when exactly one account exists.
// ResolveNoAuthUser returns the user noauth mode impersonates at runtime: auth.adminUsername when
// present, otherwise the sole user when exactly one account exists.
func ResolveNoAuthUser() (users.User, error) {
target := noAuthAdminUsername()
user, err := GetUserByUsername(target)
@@ -46,7 +50,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.methods.password.adminUsername is missing and more than one user was migrated (a single migrated user is
// auth.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 {
@@ -84,7 +88,7 @@ func createNoAuthAdminUser(store *sqldb.SQLStore) error {
ApplyUserDefaults(user)
user.Username = target
password := settings.PasswordAdminPassword()
password := settings.Config.Auth.AdminPassword
if password == "" {
password = "admin"
}
+4 -1
View File
@@ -27,7 +27,10 @@ func QuickSetup() error {
user := &users.User{}
ApplyUserDefaults(user)
user.Username = settings.PasswordAdminUsername()
user.Username = settings.Config.Auth.AdminUsername
if user.Username == "" {
user.Username = "admin"
}
user.Permissions.Admin = true
user.LoginMethod = users.LoginMethodPassword
File diff suppressed because it is too large. Load diff
@@ -1,18 +0,0 @@
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)
}
}
@@ -1,167 +0,0 @@
package state
import (
"sync"
"testing"
"time"
"github.com/gtsteffaniak/filebrowser/backend/pkg/settings"
)
func TestQuotaFlusherConcurrentFlushAndStop(t *testing.T) {
quotaFlusherMu.Lock()
oldFlusher := quotaFlusher
quotaFlusher = nil
quotaFlusherMu.Unlock()
oldCounters := quotaCounters
initQuotaMaps()
defer func() {
StopQuotaFlusher()
quotasMux.Lock()
quotaCounters = oldCounters
quotasMux.Unlock()
quotaFlusherMu.Lock()
quotaFlusher = oldFlusher
quotaFlusherMu.Unlock()
}()
quotaCounters["test-quota"] = &quotaCounterMem{UsedBytes: 100, Dirty: true}
flusher := &quotaCounterFlusher{
dirtyIDs: map[string]struct{}{"test-quota": {}},
flushCh: make(chan struct{}, 1),
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
flushInterval: time.Hour,
maxBuffers: 1,
}
quotaFlusherMu.Lock()
quotaFlusher = flusher
quotaFlusherMu.Unlock()
go flusher.loop()
holdQuotas := make(chan struct{})
releaseQuotas := make(chan struct{})
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(releaseQuotas) }) }
defer release()
go func() {
quotasMux.Lock()
close(holdQuotas)
<-releaseQuotas
quotasMux.Unlock()
}()
<-holdQuotas
stopDone := make(chan struct{})
go func() {
defer close(stopDone)
StopQuotaFlusher()
}()
const total = 200
var wg sync.WaitGroup
wg.Add(total)
for i := 0; i < total; i++ {
go func() {
defer wg.Done()
markQuotaCounterDirty("test-quota")
signalQuotaFlush()
}()
}
writersDone := make(chan struct{})
go func() {
wg.Wait()
close(writersDone)
}()
select {
case <-writersDone:
case <-time.After(2 * time.Second):
release()
t.Fatal("quota writers blocked on quotasMux while the flusher was stopping")
}
release()
select {
case <-stopDone:
case <-time.After(2 * time.Second):
t.Fatal("StopQuotaFlusher deadlocked with concurrent flush/mutation while quotasMux was held")
}
}
func TestQuotaFlusherReplacementWhileQuotasLocked(t *testing.T) {
quotaFlusherMu.Lock()
oldFlusher := quotaFlusher
quotaFlusher = nil
quotaFlusherMu.Unlock()
oldCounters := quotaCounters
initQuotaMaps()
defer func() {
StopQuotaFlusher()
quotasMux.Lock()
quotaCounters = oldCounters
quotasMux.Unlock()
quotaFlusherMu.Lock()
quotaFlusher = oldFlusher
quotaFlusherMu.Unlock()
}()
quotaCounters["test-quota"] = &quotaCounterMem{UsedBytes: 50, Dirty: true}
startQuotaFlusher(settings.QuotasConfig{
FlushIntervalSeconds: 3600,
FlushMaxBuffers: 1,
})
holdQuotas := make(chan struct{})
releaseQuotas := make(chan struct{})
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { close(releaseQuotas) }) }
defer release()
go func() {
quotasMux.Lock()
close(holdQuotas)
<-releaseQuotas
quotasMux.Unlock()
}()
<-holdQuotas
replaceDone := make(chan struct{})
go func() {
defer close(replaceDone)
startQuotaFlusher(settings.QuotasConfig{
FlushIntervalSeconds: 3600,
FlushMaxBuffers: 1,
})
}()
const total = 100
var wg sync.WaitGroup
wg.Add(total)
for i := 0; i < total; i++ {
go func() {
defer wg.Done()
markQuotaCounterDirty("test-quota")
}()
}
writersDone := make(chan struct{})
go func() {
wg.Wait()
close(writersDone)
}()
select {
case <-writersDone:
case <-time.After(2 * time.Second):
release()
t.Fatal("quota writers blocked on quotasMux while replacing the flusher")
}
release()
select {
case <-replaceDone:
case <-time.After(2 * time.Second):
t.Fatal("startQuotaFlusher deadlocked while replacing flusher with quotasMux held")
}
}
Loaded 100 of 598 files, more files were not shown because too many files have changed in this diff. Show more